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