Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
1.00% |
4 / 402 |
|
6.25% |
1 / 16 |
CRAP | |
0.00% |
0 / 1 |
AbstractEventRegistrationSpecialPage | |
1.00% |
4 / 402 |
|
6.25% |
1 / 16 |
7775.90 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
42 | |||
outputErrorBox | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getFormMessages | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getFormFields | |
0.00% |
0 / 178 |
|
0.00% |
0 / 1 |
756 | |||
getParticipantQuestionsFields | |
0.00% |
0 / 42 |
|
0.00% |
0 / 1 |
30 | |||
convertTimezoneForForm | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
alterForm | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
30 | |||
parseSubmittedTimezone | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
20 | |||
onSubmit | |
0.00% |
0 / 63 |
|
0.00% |
0 / 1 |
462 | |||
getTimezone | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
getOrganizerUsernames | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
20 | |||
onSuccess | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getDisplayFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getMessagePrefix | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getValidationFlags | n/a |
0 / 0 |
n/a |
0 / 0 |
0 |
1 | <?php |
2 | |
3 | declare( strict_types=1 ); |
4 | |
5 | namespace MediaWiki\Extension\CampaignEvents\Special; |
6 | |
7 | use DateTime; |
8 | use DateTimeZone; |
9 | use Exception; |
10 | use HTMLForm; |
11 | use LogicException; |
12 | use MediaWiki\Extension\CampaignEvents\Event\EditEventCommand; |
13 | use MediaWiki\Extension\CampaignEvents\Event\EventFactory; |
14 | use MediaWiki\Extension\CampaignEvents\Event\EventRegistration; |
15 | use MediaWiki\Extension\CampaignEvents\Event\InvalidEventDataException; |
16 | use MediaWiki\Extension\CampaignEvents\Event\Store\IEventLookup; |
17 | use MediaWiki\Extension\CampaignEvents\Hooks\CampaignEventsHookRunner; |
18 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup; |
19 | use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUserNotFoundException; |
20 | use MediaWiki\Extension\CampaignEvents\MWEntity\HiddenCentralUserException; |
21 | use MediaWiki\Extension\CampaignEvents\MWEntity\MWAuthorityProxy; |
22 | use MediaWiki\Extension\CampaignEvents\Organizers\OrganizersStore; |
23 | use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker; |
24 | use MediaWiki\Extension\CampaignEvents\PolicyMessagesLookup; |
25 | use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry; |
26 | use MediaWiki\Extension\CampaignEvents\Questions\UnknownQuestionException; |
27 | use MediaWiki\Extension\CampaignEvents\TrackingTool\InvalidToolURLException; |
28 | use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolRegistry; |
29 | use MediaWiki\Extension\CampaignEvents\Utils; |
30 | use MediaWiki\Html\Html; |
31 | use MediaWiki\SpecialPage\FormSpecialPage; |
32 | use MediaWiki\Status\Status; |
33 | use MediaWiki\User\UserTimeCorrection; |
34 | use OOUI\FieldLayout; |
35 | use OOUI\HtmlSnippet; |
36 | use OOUI\MessageWidget; |
37 | use OOUI\Tag; |
38 | use RuntimeException; |
39 | use StatusValue; |
40 | use Wikimedia\RequestTimeout\TimeoutException; |
41 | |
42 | abstract class AbstractEventRegistrationSpecialPage extends FormSpecialPage { |
43 | private const PAGE_FIELD_NAME_HTMLFORM = 'EventPage'; |
44 | public const PAGE_FIELD_NAME = 'wp' . self::PAGE_FIELD_NAME_HTMLFORM; |
45 | public const DETAILS_SECTION = 'campaignevents-edit-form-details-label'; |
46 | private const PARTICIPANT_QUESTIONS_SECTION = 'campaignevents-edit-form-questions-label'; |
47 | |
48 | /** @var array<string,string> */ |
49 | private array $formMessages; |
50 | protected IEventLookup $eventLookup; |
51 | private EventFactory $eventFactory; |
52 | private EditEventCommand $editEventCommand; |
53 | private PolicyMessagesLookup $policyMessagesLookup; |
54 | private OrganizersStore $organizersStore; |
55 | protected PermissionChecker $permissionChecker; |
56 | private CampaignsCentralUserLookup $centralUserLookup; |
57 | private TrackingToolRegistry $trackingToolRegistry; |
58 | private EventQuestionsRegistry $eventQuestionsRegistry; |
59 | private CampaignEventsHookRunner $hookRunner; |
60 | |
61 | protected ?int $eventID = null; |
62 | protected ?EventRegistration $event = null; |
63 | protected MWAuthorityProxy $performer; |
64 | |
65 | /** |
66 | * @var string|null Prefixedtext of the event page, set upon form submission and guaranteed to be |
67 | * a string on success. |
68 | */ |
69 | private ?string $eventPagePrefixedText = null; |
70 | /** |
71 | * @var string[] Usernames of invalid organizers, used for live validation in JavaScript. |
72 | */ |
73 | private array $invalidOrganizerNames = []; |
74 | private ?StatusValue $saveWarningsStatus; |
75 | |
76 | /** |
77 | * @param string $name |
78 | * @param string $restriction |
79 | * @param IEventLookup $eventLookup |
80 | * @param EventFactory $eventFactory |
81 | * @param EditEventCommand $editEventCommand |
82 | * @param PolicyMessagesLookup $policyMessagesLookup |
83 | * @param OrganizersStore $organizersStore |
84 | * @param PermissionChecker $permissionChecker |
85 | * @param CampaignsCentralUserLookup $centralUserLookup |
86 | * @param TrackingToolRegistry $trackingToolRegistry |
87 | * @param EventQuestionsRegistry $eventQuestionsRegistry |
88 | * @param CampaignEventsHookRunner $hookRunner |
89 | */ |
90 | public function __construct( |
91 | string $name, |
92 | string $restriction, |
93 | IEventLookup $eventLookup, |
94 | EventFactory $eventFactory, |
95 | EditEventCommand $editEventCommand, |
96 | PolicyMessagesLookup $policyMessagesLookup, |
97 | OrganizersStore $organizersStore, |
98 | PermissionChecker $permissionChecker, |
99 | CampaignsCentralUserLookup $centralUserLookup, |
100 | TrackingToolRegistry $trackingToolRegistry, |
101 | EventQuestionsRegistry $eventQuestionsRegistry, |
102 | CampaignEventsHookRunner $hookRunner |
103 | ) { |
104 | parent::__construct( $name, $restriction ); |
105 | $this->eventLookup = $eventLookup; |
106 | $this->eventFactory = $eventFactory; |
107 | $this->editEventCommand = $editEventCommand; |
108 | $this->policyMessagesLookup = $policyMessagesLookup; |
109 | $this->organizersStore = $organizersStore; |
110 | $this->permissionChecker = $permissionChecker; |
111 | $this->centralUserLookup = $centralUserLookup; |
112 | $this->trackingToolRegistry = $trackingToolRegistry; |
113 | $this->eventQuestionsRegistry = $eventQuestionsRegistry; |
114 | $this->hookRunner = $hookRunner; |
115 | |
116 | $this->performer = new MWAuthorityProxy( $this->getAuthority() ); |
117 | $this->formMessages = $this->getFormMessages(); |
118 | } |
119 | |
120 | /** |
121 | * @inheritDoc |
122 | */ |
123 | public function execute( $par ): void { |
124 | $this->requireNamedUser(); |
125 | $this->addHelpLink( 'Help:Extension:CampaignEvents/Registration' ); |
126 | $this->getOutput()->addModules( [ |
127 | 'ext.campaignEvents.editeventregistration', |
128 | ] ); |
129 | |
130 | if ( $this->eventID ) { |
131 | $eventCreator = $this->organizersStore->getEventCreator( |
132 | $this->eventID, |
133 | OrganizersStore::GET_CREATOR_INCLUDE_DELETED |
134 | ); |
135 | if ( !$eventCreator ) { |
136 | throw new RuntimeException( "Did not find event creator." ); |
137 | } |
138 | try { |
139 | $eventCreatorUsername = $this->centralUserLookup->getUserName( $eventCreator->getUser() ); |
140 | } catch ( CentralUserNotFoundException | HiddenCentralUserException $_ ) { |
141 | $eventCreatorUsername = null; |
142 | } |
143 | $performerUserName = $this->performer->getName(); |
144 | $isEventCreator = $performerUserName === $eventCreatorUsername; |
145 | } else { |
146 | $isEventCreator = true; |
147 | $eventCreatorUsername = $this->performer->getName(); |
148 | } |
149 | |
150 | $this->getOutput()->addJsConfigVars( [ |
151 | 'wgCampaignEventsIsEventCreator' => $isEventCreator, |
152 | 'wgCampaignEventsEventCreatorUsername' => $eventCreatorUsername, |
153 | 'wgCampaignEventsEventID' => $this->eventID, |
154 | 'wgCampaignEventsIsPastEvent' => $this->event && $this->event->isPast(), |
155 | 'wgCampaignEventsEventHasAnswers' => $this->event && |
156 | $this->editEventCommand->eventHasAnswersOrAggregates( $this->eventID ), |
157 | ] ); |
158 | // By default, OOUI is only enabled upon showing the form. But since we're using MessageWidget directly in a |
159 | // couple places, we need to manually enable OOUI now (T354384). |
160 | $this->getOutput()->enableOOUI(); |
161 | |
162 | parent::execute( $par ); |
163 | // Note: this has to be added after parent::execute, which is where the validation runs. |
164 | $this->getOutput()->addJsConfigVars( [ |
165 | 'wgCampaignEventsInvalidOrganizers' => $this->invalidOrganizerNames |
166 | ] ); |
167 | } |
168 | |
169 | /** |
170 | * @param string $errorMsg |
171 | * @param mixed ...$msgParams |
172 | * @return void |
173 | */ |
174 | protected function outputErrorBox( string $errorMsg, ...$msgParams ): void { |
175 | $this->setHeaders(); |
176 | $this->getOutput()->addHTML( Html::errorBox( |
177 | $this->msg( $errorMsg )->params( ...$msgParams )->parseAsBlock() |
178 | ) ); |
179 | } |
180 | |
181 | /** |
182 | * Returns messages to be used on the page. 'form-legend' and 'submit' must not use markup or take any parameter. |
183 | * 'success' can contain markup, and will be passed the prefixedtext of the event page as the $1 parameter. |
184 | * @phan-return array{success:string,details-section-subtitle:string,submit:string} |
185 | * @return array |
186 | */ |
187 | abstract protected function getFormMessages(): array; |
188 | |
189 | /** |
190 | * @inheritDoc |
191 | */ |
192 | protected function getFormFields(): array { |
193 | $eventPageDefault = null; |
194 | if ( $this->event ) { |
195 | $eventPageDefault = $this->event->getPage()->getPrefixedText(); |
196 | } |
197 | |
198 | $formFields = []; |
199 | $formFields[self::PAGE_FIELD_NAME_HTMLFORM] = [ |
200 | 'type' => 'title', |
201 | 'label-message' => 'campaignevents-edit-field-page', |
202 | // TODO Interwiki support (T307108) |
203 | 'exists' => true, |
204 | 'namespace' => NS_EVENT, |
205 | 'default' => $eventPageDefault, |
206 | 'help-message' => 'campaignevents-edit-field-page-help', |
207 | 'help-inline' => false, |
208 | 'required' => true, |
209 | 'section' => self::DETAILS_SECTION, |
210 | ]; |
211 | |
212 | if ( $this->event ) { |
213 | $formFields['EventStatus'] = [ |
214 | 'type' => 'select', |
215 | 'label-message' => 'campaignevents-edit-field-event-status', |
216 | 'default' => $this->event->getStatus(), |
217 | 'options-messages' => [ |
218 | 'campaignevents-edit-field-status-open' => EventRegistration::STATUS_OPEN, |
219 | 'campaignevents-edit-field-status-closed' => EventRegistration::STATUS_CLOSED, |
220 | ], |
221 | 'required' => true, |
222 | 'section' => self::DETAILS_SECTION, |
223 | ]; |
224 | } |
225 | |
226 | if ( $this->event ) { |
227 | $defaultTimezone = self::convertTimezoneForForm( $this->event->getTimezone() ); |
228 | } else { |
229 | $defaultTimezone = '+00:00'; |
230 | } |
231 | |
232 | $formFields['TimeZone'] = [ |
233 | 'type' => 'timezone', |
234 | 'label-message' => 'campaignevents-edit-field-timezone', |
235 | 'default' => $defaultTimezone, |
236 | 'required' => true, |
237 | 'cssclass' => 'ext-campaignevents-timezone-input', |
238 | 'section' => self::DETAILS_SECTION, |
239 | ]; |
240 | |
241 | $timezone = $this->getTimezone(); |
242 | $curLocalTime = ( new DateTime( 'now', $timezone ) )->format( 'Y-m-d H:i:s' ); |
243 | $minTime = $this->event ? '' : wfTimestamp( TS_MW, $curLocalTime ); |
244 | $maxTime = $this->eventID && $this->event->isPast() && |
245 | $this->editEventCommand->eventHasAnswersOrAggregates( $this->eventID ) ? |
246 | wfTimestamp( TS_MW, $curLocalTime ) : |
247 | ''; |
248 | |
249 | // Disable auto-infusion because we want to change the configuration. |
250 | $timeFieldClasses = 'ext-campaignevents-time-input mw-htmlform-autoinfuse-lazy'; |
251 | $formFields['EventStart'] = [ |
252 | 'type' => 'datetime', |
253 | 'label-message' => 'campaignevents-edit-field-start', |
254 | 'min' => $minTime, |
255 | 'max' => $maxTime, |
256 | 'default' => $this->event ? wfTimestamp( TS_ISO_8601, $this->event->getStartLocalTimestamp() ) : '', |
257 | 'required' => true, |
258 | 'section' => self::DETAILS_SECTION, |
259 | 'cssclass' => 'ext-campaignevents-time-input-event-start ' . $timeFieldClasses, |
260 | ]; |
261 | $formFields['EventEnd'] = [ |
262 | 'type' => 'datetime', |
263 | 'label-message' => 'campaignevents-edit-field-end', |
264 | 'min' => $minTime, |
265 | 'max' => $maxTime, |
266 | 'default' => $this->event ? wfTimestamp( TS_ISO_8601, $this->event->getEndLocalTimestamp() ) : '', |
267 | 'required' => true, |
268 | 'section' => self::DETAILS_SECTION, |
269 | 'cssclass' => 'ext-campaignevents-time-input-event-end ' . $timeFieldClasses, |
270 | ]; |
271 | |
272 | $formFields['EventOrganizerUsernames'] = [ |
273 | 'type' => 'usersmultiselect', |
274 | 'label-message' => 'campaignevents-edit-field-organizers', |
275 | 'default' => implode( "\n", $this->getOrganizerUsernames() ), |
276 | 'exists' => true, |
277 | 'help-message' => 'campaignevents-edit-field-organizers-help', |
278 | 'max' => EditEventCommand::MAX_ORGANIZERS_PER_EVENT, |
279 | 'min' => 1, |
280 | 'cssclass' => 'ext-campaignevents-organizers-multiselect-input', |
281 | 'placeholder-message' => 'campaignevents-edit-field-organizers-placeholder', |
282 | 'validation-callback' => function ( $value, $alldata ) { |
283 | $organizers = $alldata['EventOrganizerUsernames'] !== '' |
284 | ? explode( "\n", $alldata['EventOrganizerUsernames'] ) |
285 | : []; |
286 | $validationStatus = $this->editEventCommand->validateOrganizers( $organizers ); |
287 | |
288 | if ( !$validationStatus->isGood() ) { |
289 | if ( $validationStatus->getValue() ) { |
290 | $this->invalidOrganizerNames = $validationStatus->getValue(); |
291 | } |
292 | $msg = $validationStatus->getMessages()[0]; |
293 | return $this->msg( $msg )->text(); |
294 | } |
295 | |
296 | return true; |
297 | }, |
298 | 'section' => self::DETAILS_SECTION, |
299 | ]; |
300 | |
301 | $availableTrackingTools = $this->trackingToolRegistry->getDataForForm(); |
302 | if ( $availableTrackingTools ) { |
303 | if ( |
304 | count( $availableTrackingTools ) > 1 || |
305 | $availableTrackingTools[0]['user-id'] !== 'wikimedia-pe-dashboard' |
306 | ) { |
307 | throw new LogicException( "Only the P&E Dashboard should be available as a tool for now" ); |
308 | } |
309 | $formFields['EventTrackingToolID'] = [ |
310 | 'type' => 'hidden', |
311 | 'default' => 'wikimedia-pe-dashboard', |
312 | 'section' => self::DETAILS_SECTION, |
313 | ]; |
314 | if ( $this->event ) { |
315 | $curTrackingTools = $this->event->getTrackingTools(); |
316 | if ( $curTrackingTools ) { |
317 | if ( |
318 | count( $curTrackingTools ) > 1 || |
319 | $curTrackingTools[0]->getToolID() !== 1 |
320 | ) { |
321 | throw new LogicException( "Only the P&E Dashboard should be available as a tool for now" ); |
322 | } |
323 | $userInfo = $this->trackingToolRegistry->getUserInfo( |
324 | $curTrackingTools[0]->getToolID(), |
325 | $curTrackingTools[0]->getToolEventID() |
326 | ); |
327 | $defaultDashboardURL = $userInfo['tool-event-url']; |
328 | } else { |
329 | $defaultDashboardURL = ''; |
330 | } |
331 | } else { |
332 | $defaultDashboardURL = ''; |
333 | } |
334 | $formFields['EventDashboardURL'] = [ |
335 | 'type' => 'url', |
336 | 'label-message' => 'campaignevents-edit-field-tracking-tools', |
337 | 'default' => $defaultDashboardURL, |
338 | 'help-message' => 'campaignevents-edit-field-tracking-tools-help', |
339 | 'placeholder-message' => 'campaignevents-edit-field-tracking-tools-placeholder', |
340 | 'validation-callback' => function ( $value, $allData ) { |
341 | if ( $value === '' ) { |
342 | return true; |
343 | } |
344 | try { |
345 | $this->trackingToolRegistry->getToolEventIDFromURL( $allData['EventTrackingToolID'], $value ); |
346 | return true; |
347 | } catch ( InvalidToolURLException $e ) { |
348 | $baseURL = rtrim( $e->getExpectedBaseURL(), '/' ) . '/courses'; |
349 | return $this->msg( 'campaignevents-error-invalid-dashboard-url' ) |
350 | ->params( $baseURL ) |
351 | ->text(); |
352 | } |
353 | }, |
354 | 'section' => self::DETAILS_SECTION, |
355 | ]; |
356 | } |
357 | |
358 | $formFields['EventMeetingType'] = [ |
359 | 'type' => 'radio', |
360 | 'label-message' => 'campaignevents-edit-field-meeting-type', |
361 | 'options-messages' => [ |
362 | 'campaignevents-edit-field-type-online' => EventRegistration::MEETING_TYPE_ONLINE, |
363 | 'campaignevents-edit-field-type-in-person' => EventRegistration::MEETING_TYPE_IN_PERSON, |
364 | 'campaignevents-edit-field-type-online-and-in-person' => |
365 | EventRegistration::MEETING_TYPE_ONLINE_AND_IN_PERSON |
366 | ], |
367 | 'default' => $this->event ? $this->event->getMeetingType() : null, |
368 | 'required' => true, |
369 | 'section' => self::DETAILS_SECTION, |
370 | ]; |
371 | |
372 | $formFields['EventMeetingURL'] = [ |
373 | 'type' => 'url', |
374 | 'label-message' => 'campaignevents-edit-field-meeting-url', |
375 | 'hide-if' => [ '===', 'EventMeetingType', (string)EventRegistration::MEETING_TYPE_IN_PERSON ], |
376 | 'default' => $this->event ? $this->event->getMeetingURL() : '', |
377 | 'section' => self::DETAILS_SECTION, |
378 | ]; |
379 | $formFields['EventMeetingCountry'] = [ |
380 | 'type' => 'text', |
381 | 'label-message' => 'campaignevents-edit-field-country', |
382 | 'hide-if' => [ '===', 'EventMeetingType', (string)EventRegistration::MEETING_TYPE_ONLINE ], |
383 | 'default' => $this->event ? $this->event->getMeetingCountry() : '', |
384 | 'section' => self::DETAILS_SECTION, |
385 | ]; |
386 | $formFields['EventMeetingAddress'] = [ |
387 | 'type' => 'textarea', |
388 | 'rows' => 5, |
389 | 'label-message' => 'campaignevents-edit-field-address', |
390 | 'hide-if' => [ '===', 'EventMeetingType', (string)EventRegistration::MEETING_TYPE_ONLINE ], |
391 | 'default' => $this->event ? $this->event->getMeetingAddress() : '', |
392 | 'section' => self::DETAILS_SECTION, |
393 | ]; |
394 | $formFields['EventChatURL'] = [ |
395 | 'type' => 'url', |
396 | 'label-message' => 'campaignevents-edit-field-chat-url', |
397 | 'default' => $this->event ? $this->event->getChatURL() : '', |
398 | 'help-message' => 'campaignevents-edit-field-chat-url-help', |
399 | 'help-inline' => false, |
400 | 'section' => self::DETAILS_SECTION, |
401 | ]; |
402 | |
403 | $this->hookRunner->onCampaignEventsRegistrationFormLoad( $formFields, $this->eventID ); |
404 | $formFields = array_merge( $formFields, $this->getParticipantQuestionsFields() ); |
405 | |
406 | return $formFields; |
407 | } |
408 | |
409 | /** |
410 | * Return the form fields for the participant questions section. |
411 | * |
412 | * @return array |
413 | */ |
414 | private function getParticipantQuestionsFields(): array { |
415 | $fields = []; |
416 | |
417 | $introText = Html::element( 'p', [], $this->msg( 'campaignevents-edit-form-questions-intro' )->text() ) . |
418 | Html::element( 'p', [], $this->msg( 'campaignevents-edit-form-questions-explanation' )->text() ); |
419 | $fields['ParticipantQuestionsInfo'] = [ |
420 | 'type' => 'info', |
421 | 'default' => $introText, |
422 | 'raw' => true, |
423 | 'section' => self::PARTICIPANT_QUESTIONS_SECTION, |
424 | ]; |
425 | |
426 | $questionLabels = $this->eventQuestionsRegistry->getQuestionLabelsForOrganizerForm(); |
427 | $questionOptions = []; |
428 | if ( $questionLabels['non-pii'] ) { |
429 | $questionOptions['campaignevents-edit-form-questions-non-pii-label'] = $questionLabels['non-pii']; |
430 | } |
431 | if ( $questionLabels['pii'] ) { |
432 | $questionOptions['campaignevents-edit-form-questions-pii-label'] = $questionLabels['pii']; |
433 | } |
434 | |
435 | // XXX: The section headers of this field look identical to the form section headers and might be confusing. |
436 | // See T358490. |
437 | $fields['ParticipantQuestions'] = [ |
438 | 'type' => 'multiselect', |
439 | 'options-messages' => $questionOptions, |
440 | 'default' => $this->event ? $this->event->getParticipantQuestions() : [], |
441 | // Edits are not allowed once an event has ended, see T354880 |
442 | 'disabled' => $this->event && $this->event->isPast(), |
443 | 'section' => self::PARTICIPANT_QUESTIONS_SECTION, |
444 | ]; |
445 | |
446 | $piiNotice = new MessageWidget( [ |
447 | 'type' => 'notice', |
448 | 'inline' => true, |
449 | 'label' => ( new Tag( 'span' ) ) |
450 | ->appendContent( new HtmlSnippet( |
451 | $this->msg( 'campaignevents-edit-form-questions-pii-notice' )->parse() |
452 | ) ) |
453 | // XXX HACK: Override the font weight with inline style to avoid creating a new RL module. T351818 |
454 | ->setAttributes( [ 'style' => 'font-weight: normal' ] ) |
455 | ] ); |
456 | $fields['ParticipantQuestionsPIINotice'] = [ |
457 | 'type' => 'info', |
458 | 'default' => $piiNotice->toString(), |
459 | 'raw' => true, |
460 | 'section' => self::PARTICIPANT_QUESTIONS_SECTION, |
461 | // XXX: Ideally we would use a `hide-if` here, but that doesn't work with `multiselect` (T358060). |
462 | // Or we could implement it manually in JS, except it still won't work because the multiselect cannot |
463 | // be infused (T358682). |
464 | ]; |
465 | |
466 | return $fields; |
467 | } |
468 | |
469 | /** |
470 | * @internal |
471 | * Converts a DateTimeZone object to a string that can be used as (default) value of the timezone input. |
472 | * |
473 | * @param DateTimeZone $tz |
474 | * @return string |
475 | */ |
476 | public static function convertTimezoneForForm( DateTimeZone $tz ): string { |
477 | $userTimeCorrectionObj = Utils::timezoneToUserTimeCorrection( $tz ); |
478 | if ( $userTimeCorrectionObj->getCorrectionType() === UserTimeCorrection::OFFSET ) { |
479 | return UserTimeCorrection::formatTimezoneOffset( $userTimeCorrectionObj->getTimeOffset() ); |
480 | } |
481 | return $userTimeCorrectionObj->toString(); |
482 | } |
483 | |
484 | /** |
485 | * @inheritDoc |
486 | */ |
487 | protected function alterForm( HTMLForm $form ): void { |
488 | $form->addHeaderHtml( |
489 | $this->msg( $this->formMessages['details-section-subtitle'] )->parseAsBlock(), |
490 | self::DETAILS_SECTION |
491 | ); |
492 | $form->setSubmitTextMsg( $this->formMessages['submit'] ); |
493 | // XXX HACK: Override the font weight with inline style to avoid creating a new RL module just for this. T316820 |
494 | $footerNotice = ( new Tag( 'span' ) ) |
495 | ->setAttributes( [ 'style' => 'font-weight: normal' ] ); |
496 | |
497 | $footerHasContent = false; |
498 | if ( $this->event && !$this->event->getParticipantQuestions() ) { |
499 | $footerHasContent = true; |
500 | $footerNotice->appendContent( new HtmlSnippet( $this->msg( 'campaignevents-edit-form-notice' )->parse() ) ); |
501 | } |
502 | |
503 | $policyMsg = $this->policyMessagesLookup->getPolicyMessageForRegistrationForm(); |
504 | if ( $policyMsg !== null ) { |
505 | $footerHasContent = true; |
506 | $footerNotice->appendContent( |
507 | new HtmlSnippet( $this->msg( $policyMsg )->parseAsBlock() ) |
508 | ); |
509 | } |
510 | |
511 | if ( $footerHasContent ) { |
512 | $form->addFooterHtml( new FieldLayout( new MessageWidget( [ |
513 | 'type' => 'notice', |
514 | 'inline' => true, |
515 | 'label' => $footerNotice |
516 | ] ) ) ); |
517 | } |
518 | } |
519 | |
520 | private function parseSubmittedTimezone( string $rawVal ): string { |
521 | $timeCorrection = new UserTimeCorrection( $rawVal ); |
522 | $timezoneObj = $timeCorrection->getTimeZone(); |
523 | if ( $timezoneObj ) { |
524 | $timezone = $timezoneObj->getName(); |
525 | } elseif ( $timeCorrection->getCorrectionType() === UserTimeCorrection::SYSTEM ) { |
526 | $timezone = UserTimeCorrection::formatTimezoneOffset( $timeCorrection->getTimeOffset() ); |
527 | } else { |
528 | // User entered an offset directly, pass the value through without letting UserTimeCorrection |
529 | // parse and accept raw offsets in minutes or things like "+0:555" that DateTimeZone doesn't support. |
530 | // However, add a plus sign to valid positive offsets for consistency with the timezone selector core widget |
531 | $timezone = $rawVal; |
532 | if ( preg_match( '/^\d{2}:\d{2}$/', $timezone ) ) { |
533 | $timezone = "+$timezone"; |
534 | } |
535 | } |
536 | return $timezone; |
537 | } |
538 | |
539 | /** |
540 | * @inheritDoc |
541 | */ |
542 | public function onSubmit( array $data ) { |
543 | $meetingType = (int)$data['EventMeetingType']; |
544 | // The value for these fields is the empty string if the field was not filled, but EventFactory distinguishes |
545 | // empty string (= the value was explicitly specified as an empty string) vs null (=value not specified). |
546 | // That's mostly intended for API consumers, and here for the UI we can just assume that |
547 | // empty string === not specified. |
548 | $nullableFields = [ 'EventMeetingURL', 'EventMeetingCountry', 'EventMeetingAddress', 'EventChatURL' ]; |
549 | foreach ( $nullableFields as $fieldName ) { |
550 | $data[$fieldName] = $data[$fieldName] !== '' ? $data[$fieldName] : null; |
551 | } |
552 | |
553 | if ( isset( $data['EventDashboardURL'] ) && $data['EventDashboardURL'] !== '' ) { |
554 | $trackingToolUserID = $data['EventTrackingToolID']; |
555 | try { |
556 | $trackingToolEventID = $this->trackingToolRegistry->getToolEventIDFromURL( |
557 | $trackingToolUserID, |
558 | $data['EventDashboardURL'] |
559 | ); |
560 | } catch ( InvalidToolURLException $_ ) { |
561 | throw new LogicException( 'This should have been caught by validation-callback' ); |
562 | } |
563 | } else { |
564 | $trackingToolUserID = null; |
565 | $trackingToolEventID = null; |
566 | } |
567 | |
568 | if ( $this->event && $this->event->isPast() ) { |
569 | // Edits are not allowed once an event has ended, see T354880 |
570 | $participantQuestionIDs = $this->event->getParticipantQuestions(); |
571 | } else { |
572 | $participantQuestionIDs = array_map( 'intval', $data['ParticipantQuestions'] ); |
573 | } |
574 | $participantQuestionNames = []; |
575 | foreach ( $participantQuestionIDs as $questionID ) { |
576 | try { |
577 | $participantQuestionNames[] = $this->eventQuestionsRegistry->dbIDToName( $questionID ); |
578 | } catch ( UnknownQuestionException $e ) { |
579 | // TODO This could presumably happen if a question is removed. Maybe we should just ignore it in |
580 | // that case. |
581 | throw new LogicException( 'Unknown question in the database', 0, $e ); |
582 | } |
583 | } |
584 | |
585 | try { |
586 | $event = $this->eventFactory->newEvent( |
587 | $this->eventID, |
588 | $data[self::PAGE_FIELD_NAME_HTMLFORM], |
589 | $data['EventChatURL'], |
590 | $trackingToolUserID, |
591 | $trackingToolEventID, |
592 | $this->event ? $data['EventStatus'] : EventRegistration::STATUS_OPEN, |
593 | $this->parseSubmittedTimezone( $data['TimeZone'] ), |
594 | // Converting timestamps to TS_MW also gets rid of the UTC timezone indicator in them |
595 | wfTimestamp( TS_MW, $data['EventStart'] ), |
596 | wfTimestamp( TS_MW, $data['EventEnd'] ), |
597 | EventRegistration::TYPE_GENERIC, |
598 | $meetingType, |
599 | ( $meetingType & EventRegistration::MEETING_TYPE_ONLINE ) ? $data['EventMeetingURL'] : null, |
600 | ( $meetingType & EventRegistration::MEETING_TYPE_IN_PERSON ) ? $data['EventMeetingCountry'] : null, |
601 | ( $meetingType & EventRegistration::MEETING_TYPE_IN_PERSON ) ? $data['EventMeetingAddress'] : null, |
602 | $participantQuestionNames, |
603 | $this->event ? $this->event->getCreationTimestamp() : null, |
604 | $this->event ? $this->event->getLastEditTimestamp() : null, |
605 | $this->event ? $this->event->getDeletionTimestamp() : null, |
606 | $this->getValidationFlags() |
607 | ); |
608 | } catch ( InvalidEventDataException $e ) { |
609 | return Status::wrap( $e->getStatus() ); |
610 | } |
611 | |
612 | $this->eventPagePrefixedText = $event->getPage()->getPrefixedText(); |
613 | $organizerUsernames = $data[ 'EventOrganizerUsernames' ] |
614 | ? explode( "\n", $data[ 'EventOrganizerUsernames' ] ) |
615 | : []; |
616 | |
617 | $res = $this->editEventCommand->doEditIfAllowed( |
618 | $event, |
619 | $this->performer, |
620 | $organizerUsernames |
621 | ); |
622 | if ( $res->isOK() ) { |
623 | if ( !empty( $data[ 'ClickWrapCheckbox' ] ) ) { |
624 | $this->organizersStore->updateClickwrapAcceptance( |
625 | $res->getValue(), |
626 | $this->centralUserLookup->newFromAuthority( $this->performer ) |
627 | ); |
628 | } |
629 | $this->hookRunner->onCampaignEventsRegistrationFormSubmit( $data, $res->getValue() ); |
630 | } |
631 | [ $errorsStatus, $this->saveWarningsStatus ] = $res->splitByErrorType(); |
632 | return Status::wrap( $errorsStatus ); |
633 | } |
634 | |
635 | /** |
636 | * @return DateTimeZone |
637 | */ |
638 | private function getTimezone() { |
639 | // HACK: If the form has been submitted, adjust the minimum allowed dates according to the selected |
640 | // time zone, or the validation will be off (T348579). The proper solution would be for time fields to |
641 | // accept a timezone parameter (T315874). |
642 | if ( $this->getRequest()->wasPosted() ) { |
643 | $rawTZ = $this->getRequest()->getVal( 'wpTimeZone' ); |
644 | if ( $rawTZ === 'other' ) { |
645 | // See HTMLSelectOrOtherField::loadDataFromRequest |
646 | $rawTZ = $this->getRequest()->getVal( 'wpTimeZone-other' ); |
647 | } |
648 | $tzString = $this->parseSubmittedTimezone( $rawTZ ); |
649 | try { |
650 | return new DateTimeZone( $tzString ); |
651 | } catch ( TimeoutException $e ) { |
652 | throw $e; |
653 | } catch ( Exception $_ ) { |
654 | return new DateTimeZone( 'UTC' ); |
655 | } |
656 | } |
657 | |
658 | return $this->event ? $this->event->getTimezone() : new DateTimeZone( 'UTC' ); |
659 | } |
660 | |
661 | /** |
662 | * @return array of usernames |
663 | */ |
664 | private function getOrganizerUsernames(): array { |
665 | if ( !$this->eventID ) { |
666 | return [ $this->performer->getName() ]; |
667 | } |
668 | $organizerUserNames = []; |
669 | $organizers = $this->organizersStore->getEventOrganizers( |
670 | $this->eventID, |
671 | EditEventCommand::MAX_ORGANIZERS_PER_EVENT |
672 | ); |
673 | foreach ( $organizers as $organizer ) { |
674 | $user = $organizer->getUser(); |
675 | try { |
676 | $organizerUserNames[] = $this->centralUserLookup->getUserName( $user ); |
677 | } catch ( CentralUserNotFoundException | HiddenCentralUserException $_ ) { |
678 | // If this happens we just don't display the user name |
679 | } |
680 | } |
681 | |
682 | return $organizerUserNames; |
683 | } |
684 | |
685 | /** |
686 | * @inheritDoc |
687 | */ |
688 | public function onSuccess(): void { |
689 | $this->getOutput()->prependHTML( Html::successBox( |
690 | $this->msg( $this->formMessages['success'] )->params( $this->eventPagePrefixedText )->parse() |
691 | ) ); |
692 | if ( $this->saveWarningsStatus ) { |
693 | foreach ( $this->saveWarningsStatus->getMessages() as $msg ) { |
694 | $this->getOutput()->prependHTML( Html::warningBox( $this->msg( $msg )->escaped() ) ); |
695 | } |
696 | } |
697 | } |
698 | |
699 | /** |
700 | * @inheritDoc |
701 | */ |
702 | protected function getDisplayFormat(): string { |
703 | return 'ooui'; |
704 | } |
705 | |
706 | /** |
707 | * @inheritDoc |
708 | */ |
709 | protected function getGroupName(): string { |
710 | return 'campaignevents'; |
711 | } |
712 | |
713 | /** |
714 | * @inheritDoc |
715 | */ |
716 | public function doesWrites(): bool { |
717 | return true; |
718 | } |
719 | |
720 | /** |
721 | * @inheritDoc |
722 | */ |
723 | protected function getMessagePrefix(): string { |
724 | return ''; |
725 | } |
726 | |
727 | /** |
728 | * @return int |
729 | */ |
730 | abstract protected function getValidationFlags(): int; |
731 | } |