Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.24% covered (success)
91.24%
177 / 194
63.64% covered (warning)
63.64%
7 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditEventCommand
91.24% covered (success)
91.24%
177 / 194
63.64% covered (warning)
63.64%
7 / 11
55.96
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%
8 / 8
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\Permissions\PermissionStatus;
24use MediaWiki\Utils\MWTimestamp;
25use Message;
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( $performer, $registrationID ) ) {
129            // @phan-suppress-previous-line PhanTypeMismatchArgumentNullable
130            return PermissionStatus::newFatal( 'campaignevents-edit-not-allowed-registration' );
131        }
132        return PermissionStatus::newGood();
133    }
134
135    /**
136     * @param EventRegistration $registration
137     * @param ICampaignsAuthority $performer
138     * @param string[] $organizerUsernames These must be local usernames
139     * @return StatusValue If good, the value shall be the ID of the event. Else this can be a fatal status, or a
140     *   non-fatal status with warnings.
141     */
142    public function doEditUnsafe(
143        EventRegistration $registration,
144        ICampaignsAuthority $performer,
145        array $organizerUsernames
146    ): StatusValue {
147        $existingRegistrationForPage = $this->pageEventLookup->getRegistrationForPage( $registration->getPage() );
148        if ( $existingRegistrationForPage ) {
149            if ( $existingRegistrationForPage->getID() !== $registration->getID() ) {
150                $msg = $existingRegistrationForPage->getDeletionTimestamp() !== null
151                    ? 'campaignevents-error-page-already-registered-deleted'
152                    : 'campaignevents-error-page-already-registered';
153                return StatusValue::newFatal( $msg );
154            }
155            if ( $existingRegistrationForPage->getDeletionTimestamp() !== null ) {
156                return StatusValue::newFatal( 'campaignevents-edit-registration-deleted' );
157            }
158        }
159
160        try {
161            $performerCentralUser = $this->centralUserLookup->newFromAuthority( $performer );
162        } catch ( UserNotGlobalException $_ ) {
163            return StatusValue::newFatal( 'campaignevents-edit-need-central-account' );
164        }
165
166        $organizerValidationStatus = $this->validateOrganizers( $organizerUsernames );
167        if ( !$organizerValidationStatus->isGood() ) {
168            return $organizerValidationStatus;
169        }
170        $organizerCentralUserIDs = $organizerValidationStatus->getValue();
171
172        $registrationID = $registration->getID();
173        if ( $registrationID ) {
174            $checkOrganizerNotRemovingCreatorStatus = $this->checkOrganizerNotRemovingTheCreator(
175                $organizerCentralUserIDs,
176                $registrationID,
177                $performerCentralUser
178            );
179
180            if ( !$checkOrganizerNotRemovingCreatorStatus->isGood() ) {
181                return $checkOrganizerNotRemovingCreatorStatus;
182            }
183        } elseif ( !in_array( $performerCentralUser->getCentralID(), $organizerCentralUserIDs, true ) ) {
184            return StatusValue::newFatal( 'campaignevents-edit-no-creator' );
185        }
186
187        $organizerCentralUsers = array_map( static function ( int $centralID ): CentralUser {
188            return new CentralUser( $centralID );
189        }, $organizerCentralUserIDs );
190        if ( $registrationID ) {
191            $previousVersion = $this->eventLookup->getEventByID( $registrationID );
192            if ( !$this->checkCanEditEventDates( $registration, $previousVersion ) ) {
193                return StatusValue::newFatal( 'campaignevents-event-dates-cannot-be-changed' );
194            }
195            $trackingToolValidationStatus = $this->trackingToolEventWatcher->validateEventUpdate(
196                $previousVersion,
197                $registration,
198                $organizerCentralUsers
199            );
200        } else {
201            $previousVersion = null;
202            $trackingToolValidationStatus = $this->trackingToolEventWatcher->validateEventCreation(
203                $registration,
204                $organizerCentralUsers
205            );
206        }
207        if ( !$trackingToolValidationStatus->isGood() ) {
208            return $trackingToolValidationStatus;
209        }
210
211        $newEventID = $this->eventStore->saveRegistration( $registration );
212        $this->addOrganizers( $registrationID === null, $newEventID, $organizerCentralUserIDs, $performerCentralUser );
213        $toolStatus = $this->updateTrackingTools(
214            $newEventID,
215            $previousVersion,
216            $registration,
217            $organizerCentralUsers
218        );
219
220        $this->eventPageCacheUpdater->purgeEventPageCache( $registration );
221
222        $ret = StatusValue::newGood( $newEventID );
223        if ( !$toolStatus->isGood() ) {
224            foreach ( $toolStatus->getMessages( 'error' ) as $msg ) {
225                $ret->warning( $msg );
226            }
227        }
228        return $ret;
229    }
230
231    /**
232     * @param int $eventID
233     * @param ExistingEventRegistration|null $previousVersion
234     * @param EventRegistration $newVersion
235     * @param CentralUser[] $organizers
236     * @return StatusValue
237     */
238    private function updateTrackingTools(
239        int $eventID,
240        ?ExistingEventRegistration $previousVersion,
241        EventRegistration $newVersion,
242        array $organizers
243    ): StatusValue {
244        // Use a RAII callback to log failures at this stage that could leave the database in an inconsistent state
245        // but could not be logged elsewhere, e.g. due to timeouts.
246        // @codeCoverageIgnoreStart - testing code run in __destruct is hard and unreliable.
247        $failureLogger = new ScopedCallback( function () use ( $eventID ) {
248            $this->logger->error(
249                'Post-sync update failed for tracking tools, event {event_id}.',
250                [
251                    'event_id' => $eventID,
252                ]
253            );
254        } );
255        // @codeCoverageIgnoreEnd
256
257        if ( $previousVersion ) {
258            $trackingToolStatus = $this->trackingToolEventWatcher->onEventUpdated(
259                $previousVersion,
260                $newVersion,
261                $organizers
262            );
263        } else {
264            $trackingToolStatus = $this->trackingToolEventWatcher->onEventCreated(
265                $eventID,
266                $newVersion,
267                $organizers
268            );
269        }
270
271        // Update the tracking tools stored in the DB. This has two purpose:
272        //  - Updates the sync status and TS for tools that are now successfully connecyed
273        //  - Removes any tools that we could not sync, and adds back any tools that could not be removed
274        // Note that we can't do this in reverse, i.e. connecting the tools first, then saving the event with only
275        // tools whose sync succeeded, because we might not have an event ID yet. Also, for that we would
276        // need an atomic section to encapsulate the event update and the tool change, but we can't easily open it
277        // from here.
278        // XXX However, we might be able to save the event without tools first, and then add the tools later once
279        // they were connected, with a separate query.
280        $newTools = $trackingToolStatus->getValue();
281        $this->trackingToolUpdater->replaceEventTools( $eventID, $newTools );
282        ScopedCallback::cancel( $failureLogger );
283        return $trackingToolStatus;
284    }
285
286    /**
287     * @param bool $isCreation
288     * @param int $eventID
289     * @param array $organizerCentralIDs
290     * @param CentralUser $performer
291     */
292    private function addOrganizers(
293        bool $isCreation,
294        int $eventID,
295        array $organizerCentralIDs,
296        CentralUser $performer
297    ): void {
298        if ( !$isCreation ) {
299            $eventCreator = $this->organizerStore->getEventCreator(
300                $eventID,
301                OrganizersStore::GET_CREATOR_INCLUDE_DELETED
302            );
303            if ( !$eventCreator ) {
304                throw new RuntimeException( "Existing event without a creator" );
305            }
306            $eventCreatorID = $eventCreator->getUser()->getCentralID();
307        } else {
308            $eventCreatorID = $performer->getCentralID();
309        }
310        $organizersAndRoles = [];
311        foreach ( $organizerCentralIDs as $organizerCentralUserID ) {
312            $organizersAndRoles[$organizerCentralUserID] = $organizerCentralUserID === $eventCreatorID
313                ? [ Roles::ROLE_CREATOR ]
314                : [ Roles::ROLE_ORGANIZER ];
315        }
316        if ( !$isCreation ) {
317            $this->organizerStore->removeOrganizersFromEventExcept( $eventID, $organizerCentralIDs );
318        }
319        $this->organizerStore->addOrganizersToEvent( $eventID, $organizersAndRoles );
320    }
321
322    /**
323     * @param string[] $organizerUsernames
324     * @return StatusValue Fatal with an error, or a good Status whose value is a list of central IDs for the given
325     * local usernames. If fatal, the status' value *may* be a list of invalid organizer usernames.
326     */
327    public function validateOrganizers( array $organizerUsernames ): StatusValue {
328        if ( count( $organizerUsernames ) < 1 ) {
329            return StatusValue::newFatal(
330                'campaignevents-edit-no-organizers'
331            );
332        }
333
334        if ( count( $organizerUsernames ) > self::MAX_ORGANIZERS_PER_EVENT ) {
335            return StatusValue::newFatal(
336                'campaignevents-edit-too-many-organizers',
337                self::MAX_ORGANIZERS_PER_EVENT
338            );
339        }
340
341        $invalidOrganizers = [];
342        foreach ( $organizerUsernames as $username ) {
343            if ( !$this->permissionChecker->userCanOrganizeEvents( $username ) ) {
344                $invalidOrganizers[] = $username;
345            }
346        }
347
348        if ( $invalidOrganizers ) {
349            $ret = StatusValue::newFatal(
350                'campaignevents-edit-organizers-not-allowed',
351                Message::numParam( count( $invalidOrganizers ) ),
352                Message::listParam( $invalidOrganizers )
353            );
354            $ret->value = $invalidOrganizers;
355            return $ret;
356        }
357
358        $centralIDsStatus = $this->organizerNamesToCentralIDs( $organizerUsernames );
359        if ( !$centralIDsStatus->isGood() ) {
360            return $centralIDsStatus;
361        }
362
363        return StatusValue::newGood( $centralIDsStatus->getValue() );
364    }
365
366    /**
367     * @param string[] $localUsernames
368     * @return StatusValue Fatal with an error, or a good Status whose value is a list of central IDs for the given
369     * local usernames.
370     */
371    private function organizerNamesToCentralIDs( array $localUsernames ): StatusValue {
372        $organizerCentralUserIDs = [];
373        $organizersWithoutGlobalAccount = [];
374        foreach ( $localUsernames as $organizerUserName ) {
375            if ( !$this->centralUserLookup->isValidLocalUsername( $organizerUserName ) ) {
376                return StatusValue::newFatal(
377                    'campaignevents-edit-invalid-username',
378                    $organizerUserName
379                );
380            }
381            try {
382                $organizerCentralUserIDs[] = $this->centralUserLookup
383                    ->newFromLocalUsername( $organizerUserName )->getCentralID();
384            } catch ( UserNotGlobalException $_ ) {
385                $organizersWithoutGlobalAccount[] = $organizerUserName;
386            }
387        }
388
389        if ( $organizersWithoutGlobalAccount ) {
390            return StatusValue::newFatal(
391                'campaignevents-edit-organizer-need-central-account',
392                Message::numParam( count( $organizersWithoutGlobalAccount ) ),
393                Message::listParam( $organizersWithoutGlobalAccount )
394            );
395        }
396        return StatusValue::newGood( $organizerCentralUserIDs );
397    }
398
399    /**
400     * @param int[] $organizerCentralUserIDs
401     * @param int $eventID
402     * @param CentralUser $performer
403     * @return StatusValue
404     */
405    private function checkOrganizerNotRemovingTheCreator(
406        array $organizerCentralUserIDs,
407        int $eventID,
408        CentralUser $performer
409    ): StatusValue {
410        $eventCreator = $this->organizerStore->getEventCreator(
411            $eventID,
412            OrganizersStore::GET_CREATOR_EXCLUDE_DELETED
413        );
414
415        if ( !$eventCreator ) {
416            // If there is no event creator it means that the event creator removed themself
417            return StatusValue::newGood();
418        }
419
420        try {
421            $eventCreatorUsername = $this->centralUserLookup->getUserName( $eventCreator->getUser() );
422        } catch ( CentralUserNotFoundException | HiddenCentralUserException $_ ) {
423            // Allow the removal of deleted/suppressed organizers, since they're not shown in the editing interface
424            return StatusValue::newGood();
425        }
426
427        $creatorGlobalUserID = $eventCreator->getUser()->getCentralID();
428        if (
429            $performer->getCentralID() !== $creatorGlobalUserID &&
430            !in_array( $creatorGlobalUserID, $organizerCentralUserIDs, true )
431        ) {
432            return StatusValue::newFatal(
433                'campaignevents-edit-removed-creator',
434                Message::plaintextParam( $eventCreatorUsername )
435            );
436        }
437
438        return StatusValue::newGood();
439    }
440
441    /**
442     * @param EventRegistration $registration
443     * @param ExistingEventRegistration $previousVersion
444     * @return bool
445     */
446    private function checkCanEditEventDates(
447        EventRegistration $registration,
448        ExistingEventRegistration $previousVersion
449    ): bool {
450        $givenUnixTimestamp = wfTimestamp( TS_UNIX, $registration->getEndUTCTimestamp() );
451        $currentUnixTimestamp = MWTimestamp::now( TS_UNIX );
452        // if there are answers for this event and end date is past
453        // then the organizer can not edit the event dates and they should be disabled
454        if (
455            $givenUnixTimestamp > $currentUnixTimestamp &&
456            $previousVersion->isPast() &&
457            $this->eventHasAnswersOrAggregates( $previousVersion->getID() )
458        ) {
459            return false;
460        }
461        return true;
462    }
463
464    /**
465     * @param int $registrationID
466     * @return bool
467     */
468    public function eventHasAnswersOrAggregates( int $registrationID ): bool {
469        return $this->answersStore->eventHasAnswers( $registrationID ) ||
470            $this->aggregatedAnswersStore->eventHasAggregates( $registrationID );
471    }
472}