Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
95.43% |
167 / 175 |
|
84.21% |
16 / 19 |
CRAP | |
0.00% |
0 / 1 |
WikiEduDashboard | |
95.43% |
167 / 175 |
|
84.21% |
16 / 19 |
53 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
validateToolAddition | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addToNewEvent | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addToExistingEvent | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
makeNewEventRequest | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
validateToolRemoval | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
removeFromEvent | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
validateEventDeletion | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
onEventDeleted | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
validateParticipantAdded | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
addParticipant | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
validateParticipantsRemoved | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
removeParticipants | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
syncParticipants | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
1 | |||
makePostRequest | |
96.30% |
26 / 27 |
|
0.00% |
0 / 1 |
5 | |||
parseResponseJSON | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
makeErrorStatus | |
86.11% |
31 / 36 |
|
0.00% |
0 / 1 |
10.27 | |||
buildToolEventURL | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
extractEventIDFromURL | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
12.09 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\CampaignEvents\TrackingTool\Tool; |
6 | |
7 | use IDBAccessObject; |
8 | use JsonException; |
9 | use LogicException; |
10 | use MediaWiki\Extension\CampaignEvents\Event\EventRegistration; |
11 | use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration; |
12 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup; |
13 | use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser; |
14 | use MediaWiki\Extension\CampaignEvents\Participants\Participant; |
15 | use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore; |
16 | use MediaWiki\Extension\CampaignEvents\TrackingTool\InvalidToolURLException; |
17 | use MediaWiki\Http\HttpRequestFactory; |
18 | use Message; |
19 | use MWHttpRequest; |
20 | use StatusValue; |
21 | use Wikimedia\Assert\Assert; |
22 | |
23 | /** |
24 | * This class implements the WikiEduDashboard software as a tracking tool. |
25 | */ |
26 | class WikiEduDashboard extends TrackingTool { |
27 | private HttpRequestFactory $httpRequestFactory; |
28 | private CampaignsCentralUserLookup $centralUserLookup; |
29 | private ParticipantsStore $participantsStore; |
30 | |
31 | private string $apiSecret; |
32 | private ?string $apiProxy; |
33 | |
34 | /** |
35 | * @inheritDoc |
36 | */ |
37 | public function __construct( |
38 | HttpRequestFactory $httpRequestFactory, |
39 | CampaignsCentralUserLookup $centralUserLookup, |
40 | ParticipantsStore $participantsStore, |
41 | int $dbID, |
42 | string $baseURL, |
43 | array $extra |
44 | ) { |
45 | parent::__construct( $dbID, $baseURL, $extra ); |
46 | $this->httpRequestFactory = $httpRequestFactory; |
47 | $this->centralUserLookup = $centralUserLookup; |
48 | $this->participantsStore = $participantsStore; |
49 | $this->apiSecret = $extra['secret']; |
50 | $this->apiProxy = $extra['proxy']; |
51 | } |
52 | |
53 | /** |
54 | * @inheritDoc |
55 | */ |
56 | public function validateToolAddition( |
57 | EventRegistration $event, |
58 | array $organizers, |
59 | string $toolEventID |
60 | ): StatusValue { |
61 | return $this->makeNewEventRequest( null, $organizers, $toolEventID, true ); |
62 | } |
63 | |
64 | /** |
65 | * @inheritDoc |
66 | */ |
67 | public function addToNewEvent( |
68 | int $eventID, |
69 | EventRegistration $event, |
70 | array $organizers, |
71 | string $toolEventID |
72 | ): StatusValue { |
73 | return $this->makeNewEventRequest( $eventID, $organizers, $toolEventID, false ); |
74 | } |
75 | |
76 | /** |
77 | * @inheritDoc |
78 | */ |
79 | public function addToExistingEvent( |
80 | ExistingEventRegistration $event, |
81 | array $organizers, |
82 | string $toolEventID |
83 | ): StatusValue { |
84 | $addToolStatus = $this->makeNewEventRequest( $event->getID(), $organizers, $toolEventID, false ); |
85 | if ( !$addToolStatus->isGood() ) { |
86 | return $addToolStatus; |
87 | } |
88 | // Also sync the participants, as the dashboard won't do that automatically when syncing an event. |
89 | // This is particularly important when the tool is added to an event that already has participants, as |
90 | // is potentially the case for existing events. |
91 | return $this->syncParticipants( $event, $toolEventID, false ); |
92 | } |
93 | |
94 | /** |
95 | * @param int|null $eventID May only be null when $dryRun is true |
96 | * @param CentralUser[] $organizers |
97 | * @param string $toolEventID |
98 | * @param bool $dryRun |
99 | * @return StatusValue |
100 | */ |
101 | private function makeNewEventRequest( |
102 | ?int $eventID, |
103 | array $organizers, |
104 | string $toolEventID, |
105 | bool $dryRun |
106 | ): StatusValue { |
107 | Assert::precondition( $eventID !== null || $dryRun, 'Cannot sync tools with events without ID' ); |
108 | $organizerIDsMap = array_fill_keys( |
109 | array_map( static fn ( CentralUser $u ) => $u->getCentralID(), $organizers ), |
110 | null |
111 | ); |
112 | $organizerNames = array_values( $this->centralUserLookup->getNames( $organizerIDsMap ) ); |
113 | return $this->makePostRequest( |
114 | 'confirm_event_sync', |
115 | $eventID, |
116 | $toolEventID, |
117 | $dryRun, |
118 | [ |
119 | 'organizer_usernames' => $organizerNames, |
120 | ] |
121 | ); |
122 | } |
123 | |
124 | /** |
125 | * @inheritDoc |
126 | */ |
127 | public function validateToolRemoval( ExistingEventRegistration $event, string $toolEventID ): StatusValue { |
128 | $status = $this->makePostRequest( |
129 | 'unsync_event', |
130 | $event->getID(), |
131 | $toolEventID, |
132 | true |
133 | ); |
134 | if ( |
135 | $status->hasMessage( 'campaignevents-tracking-tool-wikiedu-course-not-found-error' ) || |
136 | $status->hasMessage( 'campaignevents-tracking-tool-wikiedu-not-connected-error' ) |
137 | ) { |
138 | // T358732 - Do not fail if the course no longer exists in the Dashboard |
139 | // T363187 - Do not fail if the course has been unsynced somehow |
140 | return StatusValue::newGood(); |
141 | } |
142 | return $status; |
143 | } |
144 | |
145 | /** |
146 | * @inheritDoc |
147 | */ |
148 | public function removeFromEvent( ExistingEventRegistration $event, string $toolEventID ): StatusValue { |
149 | $status = $this->makePostRequest( |
150 | 'unsync_event', |
151 | $event->getID(), |
152 | $toolEventID, |
153 | false |
154 | ); |
155 | if ( |
156 | $status->hasMessage( 'campaignevents-tracking-tool-wikiedu-course-not-found-error' ) || |
157 | $status->hasMessage( 'campaignevents-tracking-tool-wikiedu-not-connected-error' ) |
158 | ) { |
159 | // T358732 - Do not fail if the course no longer exists in the Dashboard |
160 | // T363187 - Do not fail if the course has been unsynced somehow |
161 | return StatusValue::newGood(); |
162 | } |
163 | return $status; |
164 | } |
165 | |
166 | /** |
167 | * @inheritDoc |
168 | */ |
169 | public function validateEventDeletion( ExistingEventRegistration $event, string $toolEventID ): StatusValue { |
170 | return $this->validateToolRemoval( $event, $toolEventID ); |
171 | } |
172 | |
173 | /** |
174 | * @inheritDoc |
175 | */ |
176 | public function onEventDeleted( ExistingEventRegistration $event, string $toolEventID ): StatusValue { |
177 | return $this->removeFromEvent( $event, $toolEventID ); |
178 | } |
179 | |
180 | /** |
181 | * @inheritDoc |
182 | */ |
183 | public function validateParticipantAdded( |
184 | ExistingEventRegistration $event, |
185 | string $toolEventID, |
186 | CentralUser $participant, |
187 | bool $private |
188 | ): StatusValue { |
189 | return $this->syncParticipants( $event, $toolEventID, true ); |
190 | } |
191 | |
192 | /** |
193 | * @inheritDoc |
194 | */ |
195 | public function addParticipant( |
196 | ExistingEventRegistration $event, |
197 | string $toolEventID, |
198 | CentralUser $participant, |
199 | bool $private |
200 | ): StatusValue { |
201 | // Note, even if private participants aren't synced, this method can also be called when a previously-public |
202 | // participant switches to private, so we must sync participant all the same. |
203 | return $this->syncParticipants( $event, $toolEventID, false ); |
204 | } |
205 | |
206 | /** |
207 | * @inheritDoc |
208 | */ |
209 | public function validateParticipantsRemoved( |
210 | ExistingEventRegistration $event, |
211 | string $toolEventID, |
212 | ?array $participants, |
213 | bool $invertSelection |
214 | ): StatusValue { |
215 | return $this->syncParticipants( $event, $toolEventID, true ); |
216 | } |
217 | |
218 | /** |
219 | * @inheritDoc |
220 | */ |
221 | public function removeParticipants( |
222 | ExistingEventRegistration $event, |
223 | string $toolEventID, |
224 | ?array $participants, |
225 | bool $invertSelection |
226 | ): StatusValue { |
227 | return $this->syncParticipants( $event, $toolEventID, false ); |
228 | } |
229 | |
230 | /** |
231 | * @param ExistingEventRegistration $event |
232 | * @param string $toolEventID |
233 | * @param bool $dryRun |
234 | * @return StatusValue |
235 | */ |
236 | private function syncParticipants( |
237 | ExistingEventRegistration $event, |
238 | string $toolEventID, |
239 | bool $dryRun |
240 | ): StatusValue { |
241 | $eventID = $event->getID(); |
242 | $latestParticipants = $this->participantsStore->getEventParticipants( |
243 | $eventID, |
244 | null, |
245 | null, |
246 | null, |
247 | null, |
248 | false, |
249 | null, |
250 | IDBAccessObject::READ_LATEST |
251 | ); |
252 | $participantIDsMap = array_fill_keys( |
253 | array_map( static fn ( Participant $p ) => $p->getUser()->getCentralID(), $latestParticipants ), |
254 | null |
255 | ); |
256 | $participantNames = array_values( $this->centralUserLookup->getNames( $participantIDsMap ) ); |
257 | return $this->makePostRequest( |
258 | 'update_event_participants', |
259 | $eventID, |
260 | $toolEventID, |
261 | $dryRun, |
262 | [ |
263 | 'participant_usernames' => $participantNames |
264 | ] |
265 | ); |
266 | } |
267 | |
268 | /** |
269 | * @param string $endpoint |
270 | * @param int|null $eventID |
271 | * @param string $courseID |
272 | * @param bool $dryRun |
273 | * @param array $extraParams |
274 | * @return StatusValue |
275 | */ |
276 | private function makePostRequest( |
277 | string $endpoint, |
278 | ?int $eventID, |
279 | string $courseID, |
280 | bool $dryRun, |
281 | array $extraParams = [] |
282 | ): StatusValue { |
283 | $postData = $extraParams + [ |
284 | 'course_slug' => $courseID, |
285 | 'secret' => $this->apiSecret, |
286 | 'dry_run' => $dryRun, |
287 | ]; |
288 | if ( $eventID !== null ) { |
289 | $postData['event_id'] = $eventID; |
290 | } |
291 | $options = [ |
292 | 'method' => 'POST', |
293 | 'timeout' => 5, |
294 | 'postData' => json_encode( $postData ) |
295 | ]; |
296 | if ( $this->apiProxy ) { |
297 | $options['proxy'] = $this->apiProxy; |
298 | } |
299 | $req = $this->httpRequestFactory->create( |
300 | $this->baseURL . 'wikimedia_event_center/' . $endpoint, |
301 | $options, |
302 | __METHOD__ |
303 | ); |
304 | $req->setHeader( 'Content-Type', 'application/json' ); |
305 | |
306 | $status = $req->execute(); |
307 | $respObj = $this->parseResponseJSON( $req ); |
308 | if ( $respObj === null ) { |
309 | return StatusValue::newFatal( 'campaignevents-tracking-tool-http-error' ); |
310 | } |
311 | if ( $status->isGood() ) { |
312 | return StatusValue::newGood(); |
313 | } |
314 | return $this->makeErrorStatus( $respObj, $courseID ); |
315 | } |
316 | |
317 | private function parseResponseJSON( MWHttpRequest $request ): ?array { |
318 | $contentTypeHeader = $request->getResponseHeader( 'Content-Type' ); |
319 | if ( !$contentTypeHeader ) { |
320 | return null; |
321 | } |
322 | $contentType = strtolower( explode( ';', $contentTypeHeader )[0] ); |
323 | if ( $contentType !== 'application/json' ) { |
324 | return null; |
325 | } |
326 | |
327 | try { |
328 | $parsedResponse = json_decode( $request->getContent(), true, 512, JSON_THROW_ON_ERROR ); |
329 | } catch ( JsonException $_ ) { |
330 | return null; |
331 | } |
332 | |
333 | return is_array( $parsedResponse ) ? $parsedResponse : null; |
334 | } |
335 | |
336 | /** |
337 | * @param array $response |
338 | * @param string $courseID |
339 | * @return StatusValue |
340 | */ |
341 | private function makeErrorStatus( array $response, string $courseID ): StatusValue { |
342 | if ( !isset( $response['error_code'] ) ) { |
343 | return StatusValue::newFatal( 'campaignevents-tracking-tool-http-error' ); |
344 | } |
345 | |
346 | switch ( $response['error_code'] ) { |
347 | case 'invalid_secret': |
348 | $msg = 'campaignevents-tracking-tool-wikiedu-config-error'; |
349 | $params = [ new Message( 'campaignevents-tracking-tool-p&e-dashboard-name' ) ]; |
350 | break; |
351 | case 'course_not_found': |
352 | $msg = 'campaignevents-tracking-tool-wikiedu-course-not-found-error'; |
353 | $params = [ |
354 | $courseID, |
355 | new Message( 'campaignevents-tracking-tool-p&e-dashboard-name' ) |
356 | ]; |
357 | break; |
358 | case 'not_organizer': |
359 | $msg = 'campaignevents-tracking-tool-wikiedu-not-organizer-error'; |
360 | $params = [ $courseID ]; |
361 | break; |
362 | case 'already_in_use': |
363 | $msg = 'campaignevents-tracking-tool-wikiedu-already-in-use-error'; |
364 | $params = [ $courseID ]; |
365 | break; |
366 | case 'sync_already_enabled': |
367 | $msg = 'campaignevents-tracking-tool-wikiedu-already-connected-error'; |
368 | $params = [ $courseID ]; |
369 | break; |
370 | case 'sync_not_enabled': |
371 | $msg = 'campaignevents-tracking-tool-wikiedu-not-connected-error'; |
372 | $params = [ $courseID ]; |
373 | break; |
374 | case 'missing_event_id': |
375 | // This should never happen. |
376 | throw new LogicException( 'Made request to the Dashboard without an event ID.' ); |
377 | default: |
378 | $msg = 'campaignevents-tracking-tool-http-error'; |
379 | $params = []; |
380 | break; |
381 | } |
382 | return StatusValue::newFatal( $msg, ...$params ); |
383 | } |
384 | |
385 | /** |
386 | * @inheritDoc |
387 | */ |
388 | public static function buildToolEventURL( string $baseURL, string $toolEventID ): string { |
389 | return rtrim( $baseURL, '/' ) . '/courses/' . $toolEventID; |
390 | } |
391 | |
392 | /** |
393 | * @inheritDoc |
394 | */ |
395 | public static function extractEventIDFromURL( string $baseURL, string $url ): string { |
396 | if ( str_starts_with( $url, '//' ) ) { |
397 | // Protocol-relative, assume HTTPS |
398 | $urlBits = parse_url( "https:$url" ); |
399 | } else { |
400 | $urlBits = parse_url( $url ); |
401 | if ( !isset( $urlBits['scheme'] ) ) { |
402 | // Missing protocol, assume HTTPS |
403 | $urlBits = parse_url( "https://$url" ); |
404 | } |
405 | } |
406 | if ( $urlBits === false ) { |
407 | throw new InvalidToolURLException( $baseURL, 'Badly malformed URL: ' . $url ); |
408 | } |
409 | if ( !isset( $urlBits['scheme'] ) ) { |
410 | // Probably shouldn't happen given the fixes above, but just to be sure... |
411 | throw new InvalidToolURLException( $baseURL, 'No scheme: ' . $url ); |
412 | } |
413 | if ( !isset( $urlBits['host'] ) ) { |
414 | throw new InvalidToolURLException( $baseURL, 'No host: ' . $url ); |
415 | } |
416 | $scheme = strtolower( $urlBits['scheme'] ); |
417 | if ( $scheme !== 'https' && $scheme !== 'http' ) { |
418 | throw new InvalidToolURLException( $baseURL, 'Bad scheme: ' . $url ); |
419 | } |
420 | $expectedHost = parse_url( $baseURL, PHP_URL_HOST ); |
421 | if ( strtolower( $urlBits['host'] ) !== $expectedHost ) { |
422 | throw new InvalidToolURLException( $baseURL, 'Bad host: ' . $url ); |
423 | } |
424 | if ( !isset( $urlBits['path'] ) ) { |
425 | throw new InvalidToolURLException( $baseURL, 'No path: ' . $url ); |
426 | } |
427 | |
428 | $pathBits = explode( '/', trim( $urlBits['path'], '/' ) ); |
429 | if ( count( $pathBits ) !== 3 || $pathBits[0] !== 'courses' ) { |
430 | throw new InvalidToolURLException( $baseURL, 'Invalid path: ' . $url ); |
431 | } |
432 | |
433 | return urldecode( $pathBits[1] ) . '/' . urldecode( $pathBits[2] ); |
434 | } |
435 | } |