Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.18% covered (success)
95.18%
158 / 166
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ListParticipantsHandler
95.18% covered (success)
95.18%
158 / 166
57.14% covered (warning)
57.14%
4 / 7
27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
4
 getResponseData
98.00% covered (success)
98.00%
49 / 50
0.00% covered (danger)
0.00%
0 / 1
9
 getUserBatch
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
5.12
 getParticipantNonPIIAnswers
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 getQuestionAnswer
81.82% covered (warning)
81.82%
18 / 22
0.00% covered (danger)
0.00%
0 / 1
3.05
 getParamSettings
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\Rest;
6
7use MediaWiki\Context\RequestContext;
8use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
9use MediaWiki\Extension\CampaignEvents\Event\Store\IEventLookup;
10use MediaWiki\Extension\CampaignEvents\Messaging\CampaignsUserMailer;
11use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
12use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser;
13use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsAuthority;
14use MediaWiki\Extension\CampaignEvents\MWEntity\MWAuthorityProxy;
15use MediaWiki\Extension\CampaignEvents\MWEntity\UserLinker;
16use MediaWiki\Extension\CampaignEvents\Participants\Participant;
17use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
18use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker;
19use MediaWiki\Extension\CampaignEvents\Questions\Answer;
20use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry;
21use MediaWiki\Rest\LocalizedHttpException;
22use MediaWiki\Rest\Response;
23use MediaWiki\Rest\SimpleHandler;
24use MediaWiki\User\UserArray;
25use MediaWiki\User\UserFactory;
26use Wikimedia\Message\IMessageFormatterFactory;
27use Wikimedia\Message\ITextFormatter;
28use Wikimedia\Message\MessageValue;
29use Wikimedia\ParamValidator\ParamValidator;
30
31class ListParticipantsHandler extends SimpleHandler {
32    use EventIDParamTrait;
33
34    // TODO: Implement proper pagination (T305389)
35    private const RES_LIMIT = 20;
36
37    private PermissionChecker $permissionChecker;
38    private IEventLookup $eventLookup;
39    private ParticipantsStore $participantsStore;
40    private CampaignsCentralUserLookup $centralUserLookup;
41    private UserLinker $userLinker;
42    private UserFactory $userFactory;
43    private CampaignsUserMailer $campaignsUserMailer;
44    private EventQuestionsRegistry $questionsRegistry;
45    private IMessageFormatterFactory $messageFormatterFactory;
46
47    /**
48     * @param PermissionChecker $permissionChecker
49     * @param IEventLookup $eventLookup
50     * @param ParticipantsStore $participantsStore
51     * @param CampaignsCentralUserLookup $centralUserLookup
52     * @param UserLinker $userLinker
53     * @param UserFactory $userFactory
54     * @param CampaignsUserMailer $campaignsUserMailer
55     * @param EventQuestionsRegistry $questionsRegistry
56     * @param IMessageFormatterFactory $messageFormatterFactory
57     */
58    public function __construct(
59        PermissionChecker $permissionChecker,
60        IEventLookup $eventLookup,
61        ParticipantsStore $participantsStore,
62        CampaignsCentralUserLookup $centralUserLookup,
63        UserLinker $userLinker,
64        UserFactory $userFactory,
65        CampaignsUserMailer $campaignsUserMailer,
66        EventQuestionsRegistry $questionsRegistry,
67        IMessageFormatterFactory $messageFormatterFactory
68    ) {
69        $this->permissionChecker = $permissionChecker;
70        $this->eventLookup = $eventLookup;
71        $this->participantsStore = $participantsStore;
72        $this->centralUserLookup = $centralUserLookup;
73        $this->userLinker = $userLinker;
74        $this->userFactory = $userFactory;
75        $this->campaignsUserMailer = $campaignsUserMailer;
76        $this->questionsRegistry = $questionsRegistry;
77        $this->messageFormatterFactory = $messageFormatterFactory;
78    }
79
80    /**
81     * @param int $eventID
82     * @return Response
83     */
84    protected function run( int $eventID ): Response {
85        $event = $this->getRegistrationOrThrow( $this->eventLookup, $eventID );
86
87        $params = $this->getValidatedParams();
88        $usernameFilter = $params['username_filter'];
89        if ( $usernameFilter === '' ) {
90            throw new LocalizedHttpException(
91                new MessageValue( 'campaignevents-rest-list-participants-empty-filter' ),
92                400
93            );
94        }
95
96        $includePrivate = $params['include_private'];
97        $authority = new MWAuthorityProxy( $this->getAuthority() );
98        if (
99            $includePrivate &&
100            !$this->permissionChecker->userCanViewPrivateParticipants( $authority, $event )
101        ) {
102            throw new LocalizedHttpException(
103                new MessageValue( 'campaignevents-rest-list-participants-cannot-see-private' ),
104                403
105            );
106        }
107
108        $participants = $this->participantsStore->getEventParticipants(
109            $eventID,
110            self::RES_LIMIT,
111            $params['last_participant_id'],
112            $usernameFilter,
113            null,
114            $includePrivate,
115            $params['exclude_users']
116        );
117
118        $responseData = $this->getResponseData( $authority, $event, $participants );
119        return $this->getResponseFactory()->createJson( $responseData );
120    }
121
122    /**
123     * @param ICampaignsAuthority $authority
124     * @param ExistingEventRegistration $event
125     * @param Participant[] $participants
126     * @return array
127     */
128    private function getResponseData(
129        ICampaignsAuthority $authority,
130        ExistingEventRegistration $event,
131        array $participants
132    ): array {
133        // TODO: remove global when T269492 is resolved
134        $language = RequestContext::getMain()->getLanguage();
135        $msgFormatter = $this->messageFormatterFactory->getTextFormatter( $language->getCode() );
136        $performer = $this->userFactory->newFromAuthority( $this->getAuthority() );
137        $canEmailParticipants = $this->permissionChecker->userCanEmailParticipants( $authority, $event );
138        $userCanViewNonPIIParticipantData = $this->permissionChecker->userCanViewNonPIIParticipantsData(
139            $authority,
140            $event
141        );
142        $includeNonPIIData = !$event->isPast() && $userCanViewNonPIIParticipantData;
143
144        $centralIDs = array_map( static fn ( Participant $p ) => $p->getUser()->getCentralID(), $participants );
145        [ $usernamesMap, $usersByName ] = $this->getUserBatch( $centralIDs );
146
147        $respDataByCentralID = [];
148        foreach ( $participants as $participant ) {
149            $centralUser = $participant->getUser();
150            $centralID = $centralUser->getCentralID();
151            $respDataByCentralID[$centralID] = [
152                'participant_id' => $participant->getParticipantID(),
153                'user_id' => $centralID,
154                'user_registered_at' => wfTimestamp( TS_MW, $participant->getRegisteredAt() ),
155                'user_registered_at_formatted' => $language->userTimeAndDate(
156                    $participant->getRegisteredAt(),
157                    $this->getAuthority()->getUser()
158                ),
159                'private' => $participant->isPrivateRegistration(),
160            ];
161
162            $usernameOrError = $usernamesMap[$centralID];
163            // Use an invalid username to force unspecified gender when the real username can't be determined.
164            $genderUsername = '@';
165            if ( $usernameOrError === CampaignsCentralUserLookup::USER_HIDDEN ) {
166                $respDataByCentralID[$centralID]['hidden'] = true;
167            } elseif ( $usernameOrError === CampaignsCentralUserLookup::USER_NOT_FOUND ) {
168                $respDataByCentralID[$centralID]['not_found'] = true;
169            } else {
170                $genderUsername = $usernameOrError;
171                $user = $usersByName[$usernameOrError];
172                $respDataByCentralID[$centralID] += [
173                    'user_name' => $usernameOrError,
174                    'user_page' => $this->userLinker->getUserPagePath( new CentralUser( $centralID ) ),
175                ];
176                if ( $canEmailParticipants ) {
177                    $respDataByCentralID[$centralID]['user_is_valid_recipient'] =
178                        $user !== null && $this->campaignsUserMailer->validateTarget( $user, $performer ) === null;
179                }
180            }
181
182            if ( $includeNonPIIData ) {
183                if ( $participant->getAggregationTimestamp() ) {
184                    $respDataByCentralID[$centralID]['non_pii_answers'] = $msgFormatter->format(
185                        MessageValue::new( 'campaignevents-participant-question-have-been-aggregated' )
186                            ->params( $genderUsername )
187                    );
188                } else {
189                    $respDataByCentralID[$centralID]['non_pii_answers'] = $this->getParticipantNonPIIAnswers(
190                        $participant, $event, $msgFormatter
191                    );
192                }
193            }
194        }
195
196        return array_values( $respDataByCentralID );
197    }
198
199    /**
200     * Loads usernames and User objects for a list of given central user IDs. This must use a single DB query for
201     * performance. It also preloads the data needed for user page links.
202     *
203     * @param int[] $centralIDs
204     * @return array
205     * @phan-return array{0:array<int,string>,1:array<string,\MediaWiki\User\User|null>}
206     */
207    private function getUserBatch( array $centralIDs ): array {
208        $centralIDsMap = array_fill_keys( $centralIDs, null );
209        $usernamesMap = $this->centralUserLookup->getNamesIncludingDeletedAndSuppressed( $centralIDsMap );
210        $usernamesToPreload = array_filter(
211            $usernamesMap,
212            static function ( $name ) {
213                return $name !== CampaignsCentralUserLookup::USER_HIDDEN &&
214                    $name !== CampaignsCentralUserLookup::USER_NOT_FOUND;
215            }
216        );
217        $this->userLinker->preloadUserLinks( $usernamesToPreload );
218        $usersByName = [];
219        // XXX We have to use MW-specific classes (including the god object User) because email-related
220        // code still lives mostly inside User.
221        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
222            $userArray = UserArray::newFromNames( $usernamesToPreload );
223            foreach ( $userArray as $user ) {
224                $usersByName[$user->getName()] = $user;
225            }
226        } else {
227            // UserArray is highly untestable, fall back to the slow version
228            foreach ( $usernamesToPreload as $name ) {
229                $usersByName[$name] = $this->userFactory->newFromName( $name );
230            }
231        }
232
233        return [ $usernamesMap, $usersByName ];
234    }
235
236    /**
237     * @param Participant $participant
238     * @param ExistingEventRegistration $event
239     * @param ITextFormatter $msgFormatter
240     * @return array
241     */
242    private function getParticipantNonPIIAnswers(
243        Participant $participant,
244        ExistingEventRegistration $event,
245        ITextFormatter $msgFormatter
246    ): array {
247        $answeredQuestions = [];
248        foreach ( $participant->getAnswers() as $answer ) {
249            $answeredQuestions[ $answer->getQuestionDBID() ] = $answer;
250        }
251
252        $nonPIIQuestionIDs = $this->questionsRegistry->getNonPIIQuestionIDs(
253            $event->getParticipantQuestions()
254        );
255        $answers = [];
256        foreach ( $nonPIIQuestionIDs as $nonPIIQuestionID ) {
257            if ( array_key_exists( $nonPIIQuestionID, $answeredQuestions ) ) {
258                $answers[] = $this->getQuestionAnswer( $answeredQuestions[ $nonPIIQuestionID ], $msgFormatter );
259            } else {
260                $answers[] = [
261                    'message' => $msgFormatter->format(
262                            MessageValue::new( 'campaignevents-participant-question-no-response' )
263                        ),
264                    'questionID' => $nonPIIQuestionID
265                ];
266            }
267        }
268        return $answers;
269    }
270
271    /**
272     * @param Answer $answer
273     * @param ITextFormatter $msgFormatter
274     * @return array
275     */
276    private function getQuestionAnswer( Answer $answer, ITextFormatter $msgFormatter ): array {
277        $questionAnswer = [
278            'message' => $msgFormatter->format(
279                    MessageValue::new( 'campaignevents-participant-question-no-response' )
280                ),
281            'questionID' => $answer->getQuestionDBID()
282        ];
283        $option = $answer->getOption();
284        if ( $option === null ) {
285            return $questionAnswer;
286        }
287        $questionAnswerMessageKey = $this->questionsRegistry->getQuestionOptionMessageByID(
288                $answer->getQuestionDBID(),
289                $option
290            );
291        $questionAnswer[ 'message' ] = $msgFormatter->format(
292            MessageValue::new( $questionAnswerMessageKey )
293        );
294
295        $textOption = $answer->getText();
296        if ( $textOption ) {
297            $questionAnswer[ 'message' ] .= $msgFormatter->format(
298                MessageValue::new( 'colon-separator' )
299            ) . $textOption;
300        }
301        return $questionAnswer;
302    }
303
304    /**
305     * @inheritDoc
306     */
307    public function getParamSettings(): array {
308        return array_merge(
309            $this->getIDParamSetting(),
310            [
311                'include_private' => [
312                    static::PARAM_SOURCE => 'query',
313                    ParamValidator::PARAM_REQUIRED => true,
314                    ParamValidator::PARAM_TYPE => 'boolean'
315                ],
316                'last_participant_id' => [
317                    static::PARAM_SOURCE => 'query',
318                    ParamValidator::PARAM_TYPE => 'integer'
319                ],
320                'username_filter' => [
321                    static::PARAM_SOURCE => 'query',
322                    ParamValidator::PARAM_TYPE => 'string'
323                ],
324                'exclude_users' => [
325                    static::PARAM_SOURCE => 'query',
326                    ParamValidator::PARAM_TYPE => 'integer',
327                    ParamValidator::PARAM_ISMULTI => true
328                ],
329            ]
330        );
331    }
332}