Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.43% covered (success)
95.43%
167 / 175
84.21% covered (warning)
84.21%
16 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiEduDashboard
95.43% covered (success)
95.43%
167 / 175
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.30% covered (success)
96.30%
26 / 27
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 IDBAccessObject;
8use JsonException;
9use LogicException;
10use MediaWiki\Extension\CampaignEvents\Event\EventRegistration;
11use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
12use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
13use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser;
14use MediaWiki\Extension\CampaignEvents\Participants\Participant;
15use MediaWiki\Extension\CampaignEvents\Participants\ParticipantsStore;
16use MediaWiki\Extension\CampaignEvents\TrackingTool\InvalidToolURLException;
17use MediaWiki\Http\HttpRequestFactory;
18use Message;
19use MWHttpRequest;
20use StatusValue;
21use Wikimedia\Assert\Assert;
22
23/**
24 * This class implements the WikiEduDashboard software as a tracking tool.
25 */
26class 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}