Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.45% covered (success)
95.45%
168 / 176
84.21% covered (warning)
84.21%
16 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiEduDashboard
95.45% covered (success)
95.45%
168 / 176
84.21% covered (warning)
84.21%
16 / 19
53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 validateToolAddition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addToNewEvent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addToExistingEvent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeNewEventRequest
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 validateToolRemoval
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 removeFromEvent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 validateEventDeletion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 onEventDeleted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateParticipantAdded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addParticipant
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateParticipantsRemoved
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeParticipants
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 syncParticipants
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 makePostRequest
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
5
 parseResponseJSON
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 makeErrorStatus
86.11% covered (warning)
86.11%
31 / 36
0.00% covered (danger)
0.00%
0 / 1
10.27
 buildToolEventURL
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 extractEventIDFromURL
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
12.09
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\TrackingTool\Tool;
6
7use JsonException;
8use LogicException;
9use MediaWiki\Extension\CampaignEvents\Event\EventRegistration;
10use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
11use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
12use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser;
13use MediaWiki\Extension\CampaignEvents\Participants\Participant;
14use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
15use MediaWiki\Extension\CampaignEvents\TrackingTool\InvalidToolURLException;
16use MediaWiki\Http\HttpRequestFactory;
17use MediaWiki\Logger\LoggerFactory;
18use MWHttpRequest;
19use StatusValue;
20use Wikimedia\Assert\Assert;
21use Wikimedia\Message\MessageValue;
22use Wikimedia\Rdbms\IDBAccessObject;
23
24/**
25 * This class implements the WikiEduDashboard software as a tracking tool.
26 */
27class 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}