Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.33% |
179 / 196 |
|
63.64% |
7 / 11 |
CRAP | |
0.00% |
0 / 1 |
EditEventCommand | |
91.33% |
179 / 196 |
|
63.64% |
7 / 11 |
55.90 | |
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% |
10 / 10 |
|
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\Message\Message; |
24 | use MediaWiki\Permissions\PermissionStatus; |
25 | use MediaWiki\Utils\MWTimestamp; |
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( |
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 | } |