Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.18% |
158 / 166 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
ListParticipantsHandler | |
95.18% |
158 / 166 |
|
57.14% |
4 / 7 |
27 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
run | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
4 | |||
getResponseData | |
98.00% |
49 / 50 |
|
0.00% |
0 / 1 |
9 | |||
getUserBatch | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
5.12 | |||
getParticipantNonPIIAnswers | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
getQuestionAnswer | |
81.82% |
18 / 22 |
|
0.00% |
0 / 1 |
3.05 | |||
getParamSettings | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\CampaignEvents\Rest; |
6 | |
7 | use MediaWiki\Context\RequestContext; |
8 | use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration; |
9 | use MediaWiki\Extension\CampaignEvents\Event\Store\IEventLookup; |
10 | use MediaWiki\Extension\CampaignEvents\Messaging\CampaignsUserMailer; |
11 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup; |
12 | use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser; |
13 | use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsAuthority; |
14 | use MediaWiki\Extension\CampaignEvents\MWEntity\MWAuthorityProxy; |
15 | use MediaWiki\Extension\CampaignEvents\MWEntity\UserLinker; |
16 | use MediaWiki\Extension\CampaignEvents\Participants\Participant; |
17 | use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore; |
18 | use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker; |
19 | use MediaWiki\Extension\CampaignEvents\Questions\Answer; |
20 | use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry; |
21 | use MediaWiki\Rest\LocalizedHttpException; |
22 | use MediaWiki\Rest\Response; |
23 | use MediaWiki\Rest\SimpleHandler; |
24 | use MediaWiki\User\UserArray; |
25 | use MediaWiki\User\UserFactory; |
26 | use Wikimedia\Message\IMessageFormatterFactory; |
27 | use Wikimedia\Message\ITextFormatter; |
28 | use Wikimedia\Message\MessageValue; |
29 | use Wikimedia\ParamValidator\ParamValidator; |
30 | |
31 | class 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 | } |