Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.00% covered (danger)
1.00%
4 / 402
6.25% covered (danger)
6.25%
1 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
AbstractEventRegistrationSpecialPage
1.00% covered (danger)
1.00%
4 / 402
6.25% covered (danger)
6.25%
1 / 16
7775.90
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 outputErrorBox
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getFormMessages
n/a
0 / 0
n/a
0 / 0
0
 getFormFields
0.00% covered (danger)
0.00%
0 / 178
0.00% covered (danger)
0.00%
0 / 1
756
 getParticipantQuestionsFields
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
30
 convertTimezoneForForm
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 alterForm
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
30
 parseSubmittedTimezone
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 onSubmit
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
462
 getTimezone
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 getOrganizerUsernames
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 onSuccess
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getDisplayFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMessagePrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getValidationFlags
n/a
0 / 0
n/a
0 / 0
0
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\Special;
6
7use DateTime;
8use DateTimeZone;
9use Exception;
10use HTMLForm;
11use LogicException;
12use MediaWiki\Extension\CampaignEvents\Event\EditEventCommand;
13use MediaWiki\Extension\CampaignEvents\Event\EventFactory;
14use MediaWiki\Extension\CampaignEvents\Event\EventRegistration;
15use MediaWiki\Extension\CampaignEvents\Event\InvalidEventDataException;
16use MediaWiki\Extension\CampaignEvents\Event\Store\IEventLookup;
17use MediaWiki\Extension\CampaignEvents\Hooks\CampaignEventsHookRunner;
18use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
19use MediaWiki\Extension\CampaignEvents\MWEntity\CentralUserNotFoundException;
20use MediaWiki\Extension\CampaignEvents\MWEntity\HiddenCentralUserException;
21use MediaWiki\Extension\CampaignEvents\MWEntity\MWAuthorityProxy;
22use MediaWiki\Extension\CampaignEvents\Organizers\OrganizersStore;
23use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker;
24use MediaWiki\Extension\CampaignEvents\PolicyMessagesLookup;
25use MediaWiki\Extension\CampaignEvents\Questions\EventQuestionsRegistry;
26use MediaWiki\Extension\CampaignEvents\Questions\UnknownQuestionException;
27use MediaWiki\Extension\CampaignEvents\TrackingTool\InvalidToolURLException;
28use MediaWiki\Extension\CampaignEvents\TrackingTool\TrackingToolRegistry;
29use MediaWiki\Extension\CampaignEvents\Utils;
30use MediaWiki\Html\Html;
31use MediaWiki\SpecialPage\FormSpecialPage;
32use MediaWiki\Status\Status;
33use MediaWiki\User\UserTimeCorrection;
34use OOUI\FieldLayout;
35use OOUI\HtmlSnippet;
36use OOUI\MessageWidget;
37use OOUI\Tag;
38use RuntimeException;
39use StatusValue;
40use Wikimedia\RequestTimeout\TimeoutException;
41
42abstract 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}