Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.24% |
177 / 194 |
|
63.64% |
7 / 11 |
CRAP | |
0.00% |
0 / 1 |
EditEventCommand | |
91.24% |
177 / 194 |
|
63.64% |
7 / 11 |
55.96 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
doEditIfAllowed | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
authorizeEdit | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
doEditUnsafe | |
88.33% |
53 / 60 |
|
0.00% |
0 / 1 |
15.36 | |||
updateTrackingTools | |
73.91% |
17 / 23 |
|
0.00% |
0 / 1 |
2.07 | |||
addOrganizers | |
94.12% |
16 / 17 |
|
0.00% |
0 / 1 |
6.01 | |||
validateOrganizers | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
7 | |||
organizerNamesToCentralIDs | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
5 | |||
checkOrganizerNotRemovingTheCreator | |
82.35% |
14 / 17 |
|
0.00% |
0 / 1 |
5.14 | |||
checkCanEditEventDates | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
eventHasAnswersOrAggregates | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\CampaignEvents\Event; |
6 | |
7 | use MediaWiki\Extension\CampaignEvents\Event\Store\IEventLookup; |
8 | use MediaWiki\Extension\CampaignEvents\Event\Store\IEventStore; |
9 | use MediaWiki\Extension\CampaignEvents\EventPage\EventPageCacheUpdater; |
10 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup; |
11 | use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUser; |
12 | use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUserNotFoundException; |
13 | use MediaWiki\Extension\CampaignEvents\MWEntity\HiddenCentralUserException; |
14 | use MediaWiki\Extension\CampaignEvents\MWEntity\ICampaignsAuthority; |
15 | use MediaWiki\Extension\CampaignEvents\MWEntity\UserNotGlobalException; |
16 | use MediaWiki\Extension\CampaignEvents\Organizers\OrganizersStore; |
17 | use MediaWiki\Extension\CampaignEvents\Organizers\Roles; |
18 | use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker; |
19 | use MediaWiki\Extension\CampaignEvents\Questions\EventAggregatedAnswersStore; |
20 | use MediaWiki\Extension\CampaignEvents\Questions\ParticipantAnswersStore; |
21 | use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolEventWatcher; |
22 | use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolUpdater; |
23 | use MediaWiki\Permissions\PermissionStatus; |
24 | use MediaWiki\Utils\MWTimestamp; |
25 | use Message; |
26 | use Psr\Log\LoggerInterface; |
27 | use RuntimeException; |
28 | use StatusValue; |
29 | use 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 | */ |
35 | class 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 | } |