Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.33% covered (success)
91.33%
179 / 196
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditEventCommand
91.33% covered (success)
91.33%
179 / 196
63.64% covered (warning)
63.64%
7 / 11
55.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 doEditIfAllowed
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 authorizeEdit
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 doEditUnsafe
88.33% covered (warning)
88.33%
53 / 60
0.00% covered (danger)
0.00%
0 / 1
15.36
 updateTrackingTools
73.91% covered (warning)
73.91%
17 / 23
0.00% covered (danger)
0.00%
0 / 1
2.07
 addOrganizers
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 validateOrganizers
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
7
 organizerNamesToCentralIDs
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
5
 checkOrganizerNotRemovingTheCreator
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
5.14
 checkCanEditEventDates
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 eventHasAnswersOrAggregates
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\Event;
6
7use MediaWiki\Extension\CampaignEvents\Event\Store\IEventLookup;
8use MediaWiki\Extension\CampaignEvents\Event\Store\IEventStore;
9use MediaWiki\Extension\CampaignEvents\EventPage\EventPageCacheUpdater;
10use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
11use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser;
12use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUserNotFoundException;
13use MediaWiki\Extension\CampaignEvents\MWEntity\HiddenCentralUserException;
14use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsAuthority;
15use MediaWiki\Extension\CampaignEvents\MWEntity\UserNotGlobalException;
16use MediaWiki\Extension\CampaignEvents\Organizers\OrganizersStore;
17use MediaWiki\Extension\CampaignEvents\Organizers\Roles;
18use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker;
19use MediaWiki\Extension\CampaignEvents\Questions\EventAggregatedAnswersStore;
20use MediaWiki\Extension\CampaignEvents\Questions\ParticipantAnswersStore;
21use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolEventWatcher;
22use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolUpdater;
23use MediaWiki\Message\Message;
24use MediaWiki\Permissions\PermissionStatus;
25use MediaWiki\Utils\MWTimestamp;
26use Psr\Log\LoggerInterface;
27use RuntimeException;
28use StatusValue;
29use Wikimedia\ScopedCallback;
30
31/**
32 * Command object used for creation and editing of event registrations.
33 * @todo The logic for adding organizers might perhaps be moved to a separate command.
34 */
35class EditEventCommand {
36    public const SERVICE_NAME = 'CampaignEventsEditEventCommand';
37
38    public const MAX_ORGANIZERS_PER_EVENT = 10;
39
40    private IEventStore $eventStore;
41    private IEventLookup $eventLookup;
42    private OrganizersStore $organizerStore;
43    private PermissionChecker $permissionChecker;
44    private CampaignsCentralUserLookup $centralUserLookup;
45    private EventPageCacheUpdater $eventPageCacheUpdater;
46    private TrackingToolEventWatcher $trackingToolEventWatcher;
47    private TrackingToolUpdater $trackingToolUpdater;
48    private LoggerInterface $logger;
49    private ParticipantAnswersStore $answersStore;
50    private EventAggregatedAnswersStore $aggregatedAnswersStore;
51    private PageEventLookup $pageEventLookup;
52
53    /**
54     * @param IEventStore $eventStore
55     * @param IEventLookup $eventLookup
56     * @param OrganizersStore $organizersStore
57     * @param PermissionChecker $permissionChecker
58     * @param CampaignsCentralUserLookup $centralUserLookup
59     * @param EventPageCacheUpdater $eventPageCacheUpdater
60     * @param TrackingToolEventWatcher $trackingToolEventWatcher
61     * @param TrackingToolUpdater $trackingToolUpdater
62     * @param LoggerInterface $logger
63     * @param ParticipantAnswersStore $answersStore
64     * @param EventAggregatedAnswersStore $aggregatedAnswersStore
65     * @param PageEventLookup $pageEventLookup
66     */
67    public function __construct(
68        IEventStore $eventStore,
69        IEventLookup $eventLookup,
70        OrganizersStore $organizersStore,
71        PermissionChecker $permissionChecker,
72        CampaignsCentralUserLookup $centralUserLookup,
73        EventPageCacheUpdater $eventPageCacheUpdater,
74        TrackingToolEventWatcher $trackingToolEventWatcher,
75        TrackingToolUpdater $trackingToolUpdater,
76        LoggerInterface $logger,
77        ParticipantAnswersStore $answersStore,
78        EventAggregatedAnswersStore $aggregatedAnswersStore,
79        PageEventLookup $pageEventLookup
80    ) {
81        $this->eventStore = $eventStore;
82        $this->eventLookup = $eventLookup;
83        $this->organizerStore = $organizersStore;
84        $this->permissionChecker = $permissionChecker;
85        $this->centralUserLookup = $centralUserLookup;
86        $this->eventPageCacheUpdater = $eventPageCacheUpdater;
87        $this->trackingToolEventWatcher = $trackingToolEventWatcher;
88        $this->trackingToolUpdater = $trackingToolUpdater;
89        $this->logger = $logger;
90        $this->answersStore = $answersStore;
91        $this->aggregatedAnswersStore = $aggregatedAnswersStore;
92        $this->pageEventLookup = $pageEventLookup;
93    }
94
95    /**
96     * @param EventRegistration $registration
97     * @param ICampaignsAuthority $performer
98     * @param string[] $organizerUsernames These must be local usernames
99     * @return StatusValue If good, the value shall be the ID of the event. Will be a PermissionStatus for
100     *   permissions-related errors. This can be a fatal status, or a non-fatal status with warnings.
101     */
102    public function doEditIfAllowed(
103        EventRegistration $registration,
104        ICampaignsAuthority $performer,
105        array $organizerUsernames
106    ): StatusValue {
107        $permStatus = $this->authorizeEdit( $registration, $performer );
108        if ( !$permStatus->isGood() ) {
109            return $permStatus;
110        }
111        return $this->doEditUnsafe( $registration, $performer, $organizerUsernames );
112    }
113
114    /**
115     * @param EventRegistration $registration
116     * @param ICampaignsAuthority $performer
117     * @return PermissionStatus
118     */
119    private function authorizeEdit(
120        EventRegistration $registration,
121        ICampaignsAuthority $performer
122    ): PermissionStatus {
123        $registrationID = $registration->getID();
124        $isCreation = $registrationID === null;
125        $eventPage = $registration->getPage();
126        if ( $isCreation && !$this->permissionChecker->userCanEnableRegistration( $performer, $eventPage ) ) {
127            return PermissionStatus::newFatal( 'campaignevents-enable-registration-not-allowed-page' );
128        } elseif ( !$isCreation && !$this->permissionChecker->userCanEditRegistration(
129            $performer,
130            $this->eventLookup->getEventByID( (int)$registrationID ) ) ) {
131            return PermissionStatus::newFatal( 'campaignevents-edit-not-allowed-registration' );
132        }
133        return PermissionStatus::newGood();
134    }
135
136    /**
137     * @param EventRegistration $registration
138     * @param ICampaignsAuthority $performer
139     * @param string[] $organizerUsernames These must be local usernames
140     * @return StatusValue If good, the value shall be the ID of the event. Else this can be a fatal status, or a
141     *   non-fatal status with warnings.
142     */
143    public function doEditUnsafe(
144        EventRegistration $registration,
145        ICampaignsAuthority $performer,
146        array $organizerUsernames
147    ): StatusValue {
148        $existingRegistrationForPage = $this->pageEventLookup->getRegistrationForPage( $registration->getPage() );
149        if ( $existingRegistrationForPage ) {
150            if ( $existingRegistrationForPage->getID() !== $registration->getID() ) {
151                $msg = $existingRegistrationForPage->getDeletionTimestamp() !== null
152                    ? 'campaignevents-error-page-already-registered-deleted'
153                    : 'campaignevents-error-page-already-registered';
154                return StatusValue::newFatal( $msg );
155            }
156            if ( $existingRegistrationForPage->getDeletionTimestamp() !== null ) {
157                return StatusValue::newFatal( 'campaignevents-edit-registration-deleted' );
158            }
159        }
160
161        try {
162            $performerCentralUser = $this->centralUserLookup->newFromAuthority( $performer );
163        } catch ( UserNotGlobalException $_ ) {
164            return StatusValue::newFatal( 'campaignevents-edit-need-central-account' );
165        }
166
167        $organizerValidationStatus = $this->validateOrganizers( $organizerUsernames );
168        if ( !$organizerValidationStatus->isGood() ) {
169            return $organizerValidationStatus;
170        }
171        $organizerCentralUserIDs = $organizerValidationStatus->getValue();
172
173        $registrationID = $registration->getID();
174        if ( $registrationID ) {
175            $checkOrganizerNotRemovingCreatorStatus = $this->checkOrganizerNotRemovingTheCreator(
176                $organizerCentralUserIDs,
177                $registrationID,
178                $performerCentralUser
179            );
180
181            if ( !$checkOrganizerNotRemovingCreatorStatus->isGood() ) {
182                return $checkOrganizerNotRemovingCreatorStatus;
183            }
184        } elseif ( !in_array( $performerCentralUser->getCentralID(), $organizerCentralUserIDs, true ) ) {
185            return StatusValue::newFatal( 'campaignevents-edit-no-creator' );
186        }
187
188        $organizerCentralUsers = array_map( static function ( int $centralID ): CentralUser {
189            return new CentralUser( $centralID );
190        }, $organizerCentralUserIDs );
191        if ( $registrationID ) {
192            $previousVersion = $this->eventLookup->getEventByID( $registrationID );
193            if ( !$this->checkCanEditEventDates( $registration, $previousVersion ) ) {
194                return StatusValue::newFatal( 'campaignevents-event-dates-cannot-be-changed' );
195            }
196            $trackingToolValidationStatus = $this->trackingToolEventWatcher->validateEventUpdate(
197                $previousVersion,
198                $registration,
199                $organizerCentralUsers
200            );
201        } else {
202            $previousVersion = null;
203            $trackingToolValidationStatus = $this->trackingToolEventWatcher->validateEventCreation(
204                $registration,
205                $organizerCentralUsers
206            );
207        }
208        if ( !$trackingToolValidationStatus->isGood() ) {
209            return $trackingToolValidationStatus;
210        }
211
212        $newEventID = $this->eventStore->saveRegistration( $registration );
213        $this->addOrganizers( $registrationID === null, $newEventID, $organizerCentralUserIDs, $performerCentralUser );
214        $toolStatus = $this->updateTrackingTools(
215            $newEventID,
216            $previousVersion,
217            $registration,
218            $organizerCentralUsers
219        );
220
221        $this->eventPageCacheUpdater->purgeEventPageCache( $registration );
222
223        $ret = StatusValue::newGood( $newEventID );
224        if ( !$toolStatus->isGood() ) {
225            foreach ( $toolStatus->getMessages( 'error' ) as $msg ) {
226                $ret->warning( $msg );
227            }
228        }
229        return $ret;
230    }
231
232    /**
233     * @param int $eventID
234     * @param ExistingEventRegistration|null $previousVersion
235     * @param EventRegistration $newVersion
236     * @param CentralUser[] $organizers
237     * @return StatusValue
238     */
239    private function updateTrackingTools(
240        int $eventID,
241        ?ExistingEventRegistration $previousVersion,
242        EventRegistration $newVersion,
243        array $organizers
244    ): StatusValue {
245        // Use a RAII callback to log failures at this stage that could leave the database in an inconsistent state
246        // but could not be logged elsewhere, e.g. due to timeouts.
247        // @codeCoverageIgnoreStart - testing code run in __destruct is hard and unreliable.
248        $failureLogger = new ScopedCallback( function () use ( $eventID ) {
249            $this->logger->error(
250                'Post-sync update failed for tracking tools, event {event_id}.',
251                [
252                    'event_id' => $eventID,
253                ]
254            );
255        } );
256        // @codeCoverageIgnoreEnd
257
258        if ( $previousVersion ) {
259            $trackingToolStatus = $this->trackingToolEventWatcher->onEventUpdated(
260                $previousVersion,
261                $newVersion,
262                $organizers
263            );
264        } else {
265            $trackingToolStatus = $this->trackingToolEventWatcher->onEventCreated(
266                $eventID,
267                $newVersion,
268                $organizers
269            );
270        }
271
272        // Update the tracking tools stored in the DB. This has two purpose:
273        //  - Updates the sync status and TS for tools that are now successfully connecyed
274        //  - Removes any tools that we could not sync, and adds back any tools that could not be removed
275        // Note that we can't do this in reverse, i.e. connecting the tools first, then saving the event with only
276        // tools whose sync succeeded, because we might not have an event ID yet. Also, for that we would
277        // need an atomic section to encapsulate the event update and the tool change, but we can't easily open it
278        // from here.
279        // XXX However, we might be able to save the event without tools first, and then add the tools later once
280        // they were connected, with a separate query.
281        $newTools = $trackingToolStatus->getValue();
282        $this->trackingToolUpdater->replaceEventTools( $eventID, $newTools );
283        ScopedCallback::cancel( $failureLogger );
284        return $trackingToolStatus;
285    }
286
287    /**
288     * @param bool $isCreation
289     * @param int $eventID
290     * @param array $organizerCentralIDs
291     * @param CentralUser $performer
292     */
293    private function addOrganizers(
294        bool $isCreation,
295        int $eventID,
296        array $organizerCentralIDs,
297        CentralUser $performer
298    ): void {
299        if ( !$isCreation ) {
300            $eventCreator = $this->organizerStore->getEventCreator(
301                $eventID,
302                OrganizersStore::GET_CREATOR_INCLUDE_DELETED
303            );
304            if ( !$eventCreator ) {
305                throw new RuntimeException( "Existing event without a creator" );
306            }
307            $eventCreatorID = $eventCreator->getUser()->getCentralID();
308        } else {
309            $eventCreatorID = $performer->getCentralID();
310        }
311        $organizersAndRoles = [];
312        foreach ( $organizerCentralIDs as $organizerCentralUserID ) {
313            $organizersAndRoles[$organizerCentralUserID] = $organizerCentralUserID === $eventCreatorID
314                ? [ Roles::ROLE_CREATOR ]
315                : [ Roles::ROLE_ORGANIZER ];
316        }
317        if ( !$isCreation ) {
318            $this->organizerStore->removeOrganizersFromEventExcept( $eventID, $organizerCentralIDs );
319        }
320        $this->organizerStore->addOrganizersToEvent( $eventID, $organizersAndRoles );
321    }
322
323    /**
324     * @param string[] $organizerUsernames
325     * @return StatusValue Fatal with an error, or a good Status whose value is a list of central IDs for the given
326     * local usernames. If fatal, the status' value *may* be a list of invalid organizer usernames.
327     */
328    public function validateOrganizers( array $organizerUsernames ): StatusValue {
329        if ( count( $organizerUsernames ) < 1 ) {
330            return StatusValue::newFatal(
331                'campaignevents-edit-no-organizers'
332            );
333        }
334
335        if ( count( $organizerUsernames ) > self::MAX_ORGANIZERS_PER_EVENT ) {
336            return StatusValue::newFatal(
337                'campaignevents-edit-too-many-organizers',
338                self::MAX_ORGANIZERS_PER_EVENT
339            );
340        }
341
342        $invalidOrganizers = [];
343        foreach ( $organizerUsernames as $username ) {
344            if ( !$this->permissionChecker->userCanOrganizeEvents( $username ) ) {
345                $invalidOrganizers[] = $username;
346            }
347        }
348
349        if ( $invalidOrganizers ) {
350            $ret = StatusValue::newFatal(
351                'campaignevents-edit-organizers-not-allowed',
352                Message::numParam( count( $invalidOrganizers ) ),
353                Message::listParam( $invalidOrganizers )
354            );
355            $ret->value = $invalidOrganizers;
356            return $ret;
357        }
358
359        $centralIDsStatus = $this->organizerNamesToCentralIDs( $organizerUsernames );
360        if ( !$centralIDsStatus->isGood() ) {
361            return $centralIDsStatus;
362        }
363
364        return StatusValue::newGood( $centralIDsStatus->getValue() );
365    }
366
367    /**
368     * @param string[] $localUsernames
369     * @return StatusValue Fatal with an error, or a good Status whose value is a list of central IDs for the given
370     * local usernames.
371     */
372    private function organizerNamesToCentralIDs( array $localUsernames ): StatusValue {
373        $organizerCentralUserIDs = [];
374        $organizersWithoutGlobalAccount = [];
375        foreach ( $localUsernames as $organizerUserName ) {
376            if ( !$this->centralUserLookup->isValidLocalUsername( $organizerUserName ) ) {
377                return StatusValue::newFatal(
378                    'campaignevents-edit-invalid-username',
379                    $organizerUserName
380                );
381            }
382            try {
383                $organizerCentralUserIDs[] = $this->centralUserLookup
384                    ->newFromLocalUsername( $organizerUserName )->getCentralID();
385            } catch ( UserNotGlobalException $_ ) {
386                $organizersWithoutGlobalAccount[] = $organizerUserName;
387            }
388        }
389
390        if ( $organizersWithoutGlobalAccount ) {
391            return StatusValue::newFatal(
392                'campaignevents-edit-organizer-need-central-account',
393                Message::numParam( count( $organizersWithoutGlobalAccount ) ),
394                Message::listParam( $organizersWithoutGlobalAccount )
395            );
396        }
397        return StatusValue::newGood( $organizerCentralUserIDs );
398    }
399
400    /**
401     * @param int[] $organizerCentralUserIDs
402     * @param int $eventID
403     * @param CentralUser $performer
404     * @return StatusValue
405     */
406    private function checkOrganizerNotRemovingTheCreator(
407        array $organizerCentralUserIDs,
408        int $eventID,
409        CentralUser $performer
410    ): StatusValue {
411        $eventCreator = $this->organizerStore->getEventCreator(
412            $eventID,
413            OrganizersStore::GET_CREATOR_EXCLUDE_DELETED
414        );
415
416        if ( !$eventCreator ) {
417            // If there is no event creator it means that the event creator removed themself
418            return StatusValue::newGood();
419        }
420
421        try {
422            $eventCreatorUsername = $this->centralUserLookup->getUserName( $eventCreator->getUser() );
423        } catch ( CentralUserNotFoundException | HiddenCentralUserException $_ ) {
424            // Allow the removal of deleted/suppressed organizers, since they're not shown in the editing interface
425            return StatusValue::newGood();
426        }
427
428        $creatorGlobalUserID = $eventCreator->getUser()->getCentralID();
429        if (
430            $performer->getCentralID() !== $creatorGlobalUserID &&
431            !in_array( $creatorGlobalUserID, $organizerCentralUserIDs, true )
432        ) {
433            return StatusValue::newFatal(
434                'campaignevents-edit-removed-creator',
435                Message::plaintextParam( $eventCreatorUsername )
436            );
437        }
438
439        return StatusValue::newGood();
440    }
441
442    /**
443     * @param EventRegistration $registration
444     * @param ExistingEventRegistration $previousVersion
445     * @return bool
446     */
447    private function checkCanEditEventDates(
448        EventRegistration $registration,
449        ExistingEventRegistration $previousVersion
450    ): bool {
451        $givenUnixTimestamp = wfTimestamp( TS_UNIX, $registration->getEndUTCTimestamp() );
452        $currentUnixTimestamp = MWTimestamp::now( TS_UNIX );
453        // if there are answers for this event and end date is past
454        // then the organizer can not edit the event dates and they should be disabled
455        if (
456            $givenUnixTimestamp > $currentUnixTimestamp &&
457            $previousVersion->isPast() &&
458            $this->eventHasAnswersOrAggregates( $previousVersion->getID() )
459        ) {
460            return false;
461        }
462        return true;
463    }
464
465    /**
466     * @param int $registrationID
467     * @return bool
468     */
469    public function eventHasAnswersOrAggregates( int $registrationID ): bool {
470        return $this->answersStore->eventHasAnswers( $registrationID ) ||
471            $this->aggregatedAnswersStore->eventHasAggregates( $registrationID );
472    }
473}