diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index e0ecc025..8d16d7d6 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -6,10 +6,10 @@ use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; -use Icinga\Module\Notifications\Forms\EntryForm; +use Icinga\Module\Notifications\Forms\MoveRotationForm; +use Icinga\Module\Notifications\Forms\RotationConfigForm; use Icinga\Module\Notifications\Forms\ScheduleForm; use Icinga\Module\Notifications\Model\Schedule; -use Icinga\Module\Notifications\Widget\Calendar\Controls; use Icinga\Module\Notifications\Widget\RecipientSuggestions; use Icinga\Module\Notifications\Widget\Schedule as ScheduleWidget; use ipl\Html\Form; @@ -37,20 +37,30 @@ public function indexAction(): void $this->addTitleTab(sprintf(t('Schedule: %s'), $schedule->name)); $this->controls->addHtml( + Html::tag('h2', null, $schedule->name), (new ButtonLink( null, Links::scheduleSettings($id), 'cog' + ))->openInModal(), + (new ButtonLink( + $this->translate('Add Rotation'), + Links::rotationAdd($id), + 'plus' ))->openInModal() ); - $this->controls->addAttributes(['class' => 'schedule-controls']); + $this->controls->addAttributes(['class' => 'schedule-detail-controls']); - $calendarControls = (new Controls()) + $scheduleControls = (new ScheduleWidget\Controls()) ->setAction(Url::fromRequest()->getAbsoluteUrl()) + ->populate(['mode' => $this->params->get('mode')]) + ->on(Form::ON_SUCCESS, function (ScheduleWidget\Controls $controls) use ($id) { + $this->redirectNow(Links::schedule($id)->with(['mode' => $controls->getMode()])); + }) ->handleRequest($this->getServerRequest()); - $this->addContent(new ScheduleWidget($calendarControls, $schedule)); + $this->addContent(new ScheduleWidget($schedule, $scheduleControls)); } public function settingsAction(): void @@ -99,24 +109,15 @@ public function addAction(): void $this->addContent($form); } - public function addEntryAction(): void + public function addRotationAction(): void { $scheduleId = (int) $this->params->getRequired('schedule'); - $start = $this->params->get('start'); - $form = new EntryForm(); - $form->setAction($this->getRequest()->getUrl()->getAbsoluteUrl()); + $form = new RotationConfigForm($scheduleId, Database::get()); + $form->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl()); $form->setSuggestionUrl(Url::fromPath('notifications/schedule/suggest-recipient')); - $form->populate(['when' => ['start' => $start]]); - $form->on(EntryForm::ON_SUCCESS, function ($form) use ($scheduleId) { - $form->addEntry($scheduleId); - $this->sendExtraUpdates(['#col2']); - $this->redirectNow('__CLOSE__'); - }); - $form->on(EntryForm::ON_SENT, function () use ($form) { - if ($form->hasBeenCancelled()) { - $this->redirectNow('__CLOSE__'); - } elseif (! $form->hasBeenSubmitted()) { + $form->on(RotationConfigForm::ON_SENT, function ($form) { + if (! $form->hasBeenSubmitted()) { foreach ($form->getPartUpdates() as $update) { if (! is_array($update)) { $update = [$update]; @@ -126,44 +127,42 @@ public function addEntryAction(): void } } }); + $form->on(RotationConfigForm::ON_SUCCESS, function (RotationConfigForm $form) use ($scheduleId) { + $form->addRotation(); + $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); + }); $form->handleRequest($this->getServerRequest()); if (empty($this->parts)) { - $this->addPart(Html::tag( - 'div', - ['id' => $this->getRequest()->getHeader('X-Icinga-Container')], - [ - Html::tag('h2', null, $this->translate('Add Entry')), - $form - ] - )); + $this->setTitle($this->translate('Add Rotation')); + $this->addContent($form); } } - public function editEntryAction(): void + public function editRotationAction(): void { - $entryId = (int) $this->params->getRequired('id'); + $id = (int) $this->params->getRequired('id'); $scheduleId = (int) $this->params->getRequired('schedule'); - $form = new EntryForm(); + $form = new RotationConfigForm($scheduleId, Database::get()); + $form->disableModeSelection(); $form->setShowRemoveButton(); - $form->loadEntry($scheduleId, $entryId); + $form->loadRotation($id); $form->setSubmitLabel($this->translate('Save Changes')); - $form->setAction($this->getRequest()->getUrl()->getAbsoluteUrl()); + $form->setAction($this->getRequest()->getUrl()->setParam('showCompact')->getAbsoluteUrl()); $form->setSuggestionUrl(Url::fromPath('notifications/schedule/suggest-recipient')); - $form->on(EntryForm::ON_SUCCESS, function () use ($form, $entryId, $scheduleId) { - $form->editEntry($scheduleId, $entryId); - $this->sendExtraUpdates(['#col2']); - $this->redirectNow('__CLOSE__'); + $form->on(RotationConfigForm::ON_SUCCESS, function (RotationConfigForm $form) use ($id, $scheduleId) { + $form->editRotation($id); + $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); }); - $form->on(EntryForm::ON_SENT, function ($form) use ($entryId, $scheduleId) { - if ($form->hasBeenCancelled()) { - $this->redirectNow('__CLOSE__'); - } elseif ($form->hasBeenRemoved()) { - $form->removeEntry($scheduleId, $entryId); - $this->sendExtraUpdates(['#col2']); - $this->redirectNow('__CLOSE__'); + $form->on(RotationConfigForm::ON_SENT, function (RotationConfigForm $form) use ($id, $scheduleId) { + if ($form->hasBeenRemoved()) { + $form->removeRotation($id); + $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); + } elseif ($form->hasBeenWiped()) { + $form->wipeRotation(); + $this->closeModalAndRefreshRelatedView(Links::schedule($scheduleId)); } elseif (! $form->hasBeenSubmitted()) { foreach ($form->getPartUpdates() as $update) { if (! is_array($update)) { @@ -178,17 +177,25 @@ public function editEntryAction(): void $form->handleRequest($this->getServerRequest()); if (empty($this->parts)) { - $this->addPart(Html::tag( - 'div', - ['id' => $this->getRequest()->getHeader('X-Icinga-Container')], - [ - Html::tag('h2', null, $this->translate('Edit Entry')), - $form - ] - )); + $this->setTitle($this->translate('Edit Rotation')); + $this->addContent($form); } } + public function moveRotationAction(): void + { + $this->assertHttpMethod('POST'); + + $form = new MoveRotationForm(Database::get()); + $form->on(MoveRotationForm::ON_SUCCESS, function (MoveRotationForm $form) { + $this->redirectNow(Links::schedule($form->getScheduleId())); + }); + + $form->handleRequest($this->getServerRequest()); + + $this->addContent($form); + } + public function suggestRecipientAction(): void { $suggestions = new RecipientSuggestions(); diff --git a/application/controllers/SchedulesController.php b/application/controllers/SchedulesController.php index ad37ee9b..a9af10dc 100644 --- a/application/controllers/SchedulesController.php +++ b/application/controllers/SchedulesController.php @@ -59,7 +59,7 @@ public function indexAction(): void $this->addControl($sortControl); $this->addControl($limitControl); $this->addControl($searchBar); - $this->addControl( + $this->addContent( (new ButtonLink( t('New Schedule'), Links::scheduleAdd(), diff --git a/application/forms/EntryForm.php b/application/forms/EntryForm.php deleted file mode 100644 index ed821b1a..00000000 --- a/application/forms/EntryForm.php +++ /dev/null @@ -1,503 +0,0 @@ -submitLabel = $label; - - return $this; - } - - public function getSubmitLabel(): string - { - return $this->submitLabel ?? $this->translate('Add Entry'); - } - - public function setShowRemoveButton(bool $state = true): self - { - $this->showRemoveButton = $state; - - return $this; - } - - public function setSuggestionUrl(Url $url): self - { - $this->suggestionUrl = $url; - - return $this; - } - - public function getPartUpdates(): array - { - $this->ensureAssembled(); - - return array_merge( - $this->getElement('when')->prepareMultipartUpdate($this->getRequest()), - $this->getElement('recipient')->prepareMultipartUpdate($this->getRequest()) - ); - } - - public function hasBeenCancelled(): bool - { - $btn = $this->getPressedSubmitElement(); - - return $btn !== null && $btn->getName() === 'cancel'; - } - - public function hasBeenRemoved(): bool - { - $btn = $this->getPressedSubmitElement(); - $csrf = $this->getElement('CSRFToken'); - - return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'remove'; - } - - public function loadEntry(int $scheduleId, int $id): self - { - $entry = TimeperiodEntry::on(Database::get()) - ->filter(Filter::equal('id', $id)) - ->first(); - if ($entry === null) { - throw new HttpNotFoundException($this->translate('Entry not found')); - } - - $values = [ - 'timeperiod_id' => $entry->timeperiod_id, - 'end_at' => $entry->end_time, - 'description' => $entry->description - ]; - - if (isset($entry->frequency)) { - $values['when'] = RRule::fromJson(json_encode([ - 'frequency' => $entry->frequency, - 'rrule' => $entry->rrule, - 'start' => $entry->start_time->format(Frequency::SERIALIZED_DATETIME_FORMAT) - ])); - } else { - $values['when'] = OneOff::fromJson( - '"' . $entry->start_time->format(Frequency::SERIALIZED_DATETIME_FORMAT) . '"' - ); - } - - $members = ScheduleMember::on(Database::get()) - ->filter(Filter::all( - Filter::equal('schedule_id', $scheduleId), - Filter::equal('timeperiod_id', $entry->timeperiod_id) - )); - - $recipients = []; - foreach ($members as $member) { - if ($member->contact_id !== null) { - $recipients[] = 'contact:' . $member->contact_id; - } else { - $recipients[] = 'group:' . $member->contactgroup_id; - } - } - - $values['recipient'] = implode(',', $recipients); - - $this->populate($values); - - return $this; - } - - protected function assemble() - { - $scheduleElement = new class ('when') extends ScheduleElement { - /** @var EntryForm */ - private $parent; - - protected function init(): void - { - parent::init(); - - unset($this->advanced[self::CRON_EXPR]); - unset($this->regulars[RRule::MINUTELY]); - unset($this->regulars[RRule::HOURLY]); - } - - public function setParent(EntryForm $parent): self - { - $this->parent = $parent; - - return $this; - } - - protected function assemble() - { - parent::assemble(); - - $end = $this->createElement('localDateTime', 'end_at', [ - 'required' => true, - 'label' => $this->translate('End') - ]); - $this->decorate($end); - $this->parent->registerElement($end); - - $this->getElement('start') - ->setDescription(null) - ->addValidators([new CallbackValidator(function ($value, $validator) { - $endTime = $this->parent->getValue('end_at'); - if ($value >= $endTime) { - $validator->addMessage( - $this->translate('The start date must not be later than the end.') - ); - - return false; - } elseif ($this->getValue('frequency') !== self::NO_REPEAT) { - /** @var Frequency $frequency */ - $frequency = $this->getValue(); - $next = $frequency->getNextDue($value); - - if ($endTime > $next) { - $validator->addMessage( - $this->translate( - 'The entry should end before the next occurrence.' - ) - ); - - return false; - } - } - - return true; - })])->addWrapper( - (new HtmlDocument()) - ->setHtmlContent( - $this->getElement('start')->getWrapper(), - $end - ) - ); - - $this->getElement('frequency') - ->setDescription(null) - ->setLabel($this->translate('Repeat')) - ->getOption(self::NO_REPEAT) - ->setLabel($this->translate('Never')); - - $useEndTime = $this->getElement('use-end-time'); - $useEndTime->setLabel($this->translate('Use Until Time')); - - if ($useEndTime->isChecked()) { - $this->getElement('end') - ->setDescription(null) - ->setLabel($this->translate('Repeat Until')) - ->addValidators([new CallbackValidator(function ($value, $validator) { - $startTime = $this->getValue('start'); - if ($value < $startTime) { - $validator->addMessage( - $this->translate('The entry must occur at least once.') - ); - - return false; - } - - return true; - })]); - } - } - }; - - $this->addElement('hidden', 'timeperiod_id'); - - $this->addElement('textarea', 'description', [ - 'label' => $this->translate('Description'), - 'rows' => 8, - 'class' => 'autofocus' - ]); - - $termValidator = function (array $terms) { - $contactTerms = []; - $groupTerms = []; - foreach ($terms as $term) { - /** @var TermInput\Term $term */ - if (strpos($term->getSearchValue(), ':') === false) { - // TODO: Auto-correct this to a valid type:id pair, if possible - $term->setMessage($this->translate('Is not a contact nor a group of contacts')); - continue; - } - - list($type, $id) = explode(':', $term->getSearchValue(), 2); - if ($type === 'contact') { - $contactTerms[$id] = $term; - } elseif ($type === 'group') { - $groupTerms[$id] = $term; - } - } - - if (! empty($contactTerms)) { - $contacts = (Contact::on(Database::get())) - ->filter(Filter::equal('id', array_keys($contactTerms))); - foreach ($contacts as $contact) { - $contactTerms[$contact->id] - ->setLabel($contact->full_name) - ->setClass('contact'); - } - } - - if (! empty($groupTerms)) { - $groups = (Contactgroup::on(Database::get())) - ->filter(Filter::equal('id', array_keys($groupTerms))); - foreach ($groups as $group) { - $groupTerms[$group->id] - ->setLabel($group->name) - ->setClass('group'); - } - } - }; - - $termInput = (new TermInput('recipient')) - ->setRequired() - ->setVerticalTermDirection() - ->setLabel(t('Recipients')) - ->setSuggestionUrl($this->suggestionUrl->with(['showCompact' => true, '_disableLayout' => 1])) - ->on(TermInput::ON_ENRICH, $termValidator) - ->on(TermInput::ON_ADD, $termValidator) - ->on(TermInput::ON_SAVE, $termValidator) - ->on(TermInput::ON_PASTE, $termValidator); - - $this->addElement($termInput); - - $this->addElement($scheduleElement->setParent($this)); - - $this->addElement('submit', 'submit', [ - 'label' => $this->getSubmitLabel() - ]); - - $additionalButtons = []; - $cancelBtn = $this->createElement('submit', 'cancel', [ - 'label' => $this->translate('Cancel'), - 'class' => 'btn-cancel', - 'formnovalidate' => true - ]); - $this->registerElement($cancelBtn); - $additionalButtons[] = $cancelBtn; - - if ($this->showRemoveButton) { - $removeBtn = $this->createElement('submit', 'remove', [ - 'label' => $this->translate('Remove'), - 'class' => 'btn-remove', - 'formnovalidate' => true - ]); - $this->registerElement($removeBtn); - $additionalButtons[] = $removeBtn; - } - - $this->getElement('submit')->prependWrapper((new HtmlDocument())->setHtmlContent(...$additionalButtons)); - - $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); - } - - public function addEntry(int $scheduleId): void - { - $data = $this->formValuesToDb(); - $recipients = array_map(function ($recipient) { - return explode(':', $recipient, 2); - }, explode(',', $this->getValue('recipient'))); - - $db = Database::get(); - $db->beginTransaction(); - - $db->insert('timeperiod', ['owned_by_schedule_id' => $scheduleId]); - $timeperiodId = $db->lastInsertId(); - - $db->insert('timeperiod_entry', $data + ['timeperiod_id' => $timeperiodId]); - - foreach ($recipients as list($type, $id)) { - if ($type === 'contact') { - $db->insert('schedule_member', [ - 'schedule_id' => $scheduleId, - 'timeperiod_id' => $timeperiodId, - 'contact_id' => $id - ]); - } elseif ($type === 'group') { - $db->insert('schedule_member', [ - 'schedule_id' => $scheduleId, - 'timeperiod_id' => $timeperiodId, - 'contactgroup_id' => $id - ]); - } - } - - $db->commitTransaction(); - } - - public function editEntry(int $scheduleId, int $id): void - { - $data = $this->formValuesToDb(); - $timeperiodId = (int) $this->getValue('timeperiod_id'); - - $db = Database::get(); - $db->beginTransaction(); - - $db->update('timeperiod_entry', $data, ['id = ?' => $id]); - - $members = ScheduleMember::on($db) - ->filter(Filter::all( - Filter::equal('schedule_id', $scheduleId), - Filter::equal('timeperiod_id', $timeperiodId) - )); - - $recipients = explode(',', $this->getValue('recipient')); - - $users = []; - $groups = []; - foreach ($recipients as $recipient) { - list($type, $id) = explode(':', $recipient, 2); - - if ($type === 'contact') { - $users[$id] = $id; - } elseif ($type === 'group') { - $groups[$id] = $id; - } - } - - $usersToRemove = []; - $groupsToRemove = []; - foreach ($members as $member) { - if ($member->contact_id !== null) { - if (! isset($users[$member->contact_id])) { - $usersToRemove[] = $member->contact_id; - } else { - unset($users[$member->contact_id]); - } - } else { - if (! isset($groups[$member->contactgroup_id])) { - $groupsToRemove[] = $member->contactgroup_id; - } else { - unset($groups[$member->contactgroup_id]); - } - } - } - - if (! empty($usersToRemove)) { - $db->delete('schedule_member', [ - 'schedule_id = ?' => $scheduleId, - 'timeperiod_id = ?' => $timeperiodId, - 'contact_id IN (?)' => $usersToRemove - ]); - } - - if (! empty($groupsToRemove)) { - $db->delete('schedule_member', [ - 'schedule_id = ?' => $scheduleId, - 'timeperiod_id = ?' => $timeperiodId, - 'contactgroup_id IN (?)' => $groupsToRemove - ]); - } - - foreach ($users as $user) { - $db->insert('schedule_member', [ - 'schedule_id' => $scheduleId, - 'timeperiod_id' => $timeperiodId, - 'contact_id' => $user - ]); - } - - foreach ($groups as $group) { - $db->insert('schedule_member', [ - 'schedule_id' => $scheduleId, - 'timeperiod_id' => $timeperiodId, - 'contactgroup_id' => $group - ]); - } - - $db->commitTransaction(); - } - - public function removeEntry(int $scheduleId, int $id): void - { - $timeperiodId = (int) $this->getValue('timeperiod_id'); - - $db = Database::get(); - $db->beginTransaction(); - - $db->delete('timeperiod_entry', ['id = ?' => $id]); - $db->delete('schedule_member', ['timeperiod_id = ?' => $timeperiodId]); - $db->delete('timeperiod', [ - 'id = ?' => $timeperiodId, - 'owned_by_schedule_id = ?' => $scheduleId - ]); - - $db->commitTransaction(); - } - - protected function formValuesToDb(): array - { - /** @var Frequency $when */ - $when = $this->getValue('when'); - - // The final start may get synchronized with the recurrency rule, end_at is not and cannot be used directly - $duration = $this->getElement('when')->getValue('start') - ->diff($this->getValue('end_at')); - - $until = null; - $frequency = null; - $serializedFrequency = $when->jsonSerialize(); - if ($when instanceof RRule) { - if (($untilTime = $when->getUntil()) !== null) { - $until = $untilTime->format('U.u') * 1000.0; - } - - $rrule = $serializedFrequency['rrule']; - $frequency = $serializedFrequency['frequency']; - $start = DateTime::createFromFormat( - Frequency::SERIALIZED_DATETIME_FORMAT, - $serializedFrequency['start'] - ); - } else { - /** @var OneOff $when */ - $rrule = null; - $start = DateTime::createFromFormat( - Frequency::SERIALIZED_DATETIME_FORMAT, - $serializedFrequency - ); - } - - return [ - 'start_time' => $start->format('U.u') * 1000.0, - 'end_time' => (clone $start)->add($duration)->format('U.u') * 1000.0, - 'timezone' => $start->getTimezone()->getName(), - 'rrule' => $rrule, - 'until_time' => $until, - 'frequency' => $frequency, - 'description' => $this->getValue('description') - ]; - } -} diff --git a/application/forms/MoveRotationForm.php b/application/forms/MoveRotationForm.php new file mode 100644 index 00000000..e79b224b --- /dev/null +++ b/application/forms/MoveRotationForm.php @@ -0,0 +1,154 @@ + true]; + + protected $method = 'POST'; + + /** @var Connection */ + protected $db; + + /** @var int */ + protected $scheduleId; + + /** + * Create a new MoveRotationForm + * + * @param ?Connection $db + */ + public function __construct(Connection $db = null) + { + $this->db = $db; + } + + /** + * Get the schedule ID + * + * @return int + */ + public function getScheduleId(): int + { + if ($this->scheduleId === null) { + throw new LogicException('The form must be successfully submitted first'); + } + + return $this->scheduleId; + } + + public function getMessages() + { + $messages = parent::getMessages(); + foreach ($this->getElements() as $element) { + foreach ($element->getMessages() as $message) { + $messages[] = sprintf('%s: %s', $element->getName(), $message); + } + } + + return $messages; + } + + protected function assemble() + { + $this->addElement('hidden', 'rotation', ['required' => true]); + $this->addElement('hidden', 'priority', ['required' => true]); + $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); + } + + protected function onError() + { + $this->removeAttribute('hidden'); + + parent::onError(); + } + + protected function onSuccess() + { + $rotationId = $this->getValue('rotation'); + $newPriority = $this->getValue('priority'); + + /** @var ?Rotation $rotation */ + $rotation = Rotation::on($this->db) + ->columns(['schedule_id', 'priority']) + ->filter(Filter::equal('id', $rotationId)) + ->first(); + if ($rotation === null) { + throw new HttpNotFoundException('Rotation not found'); + } + + $transactionStarted = ! $this->db->inTransaction(); + if ($transactionStarted) { + $this->db->beginTransaction(); + } + + $this->scheduleId = $rotation->schedule_id; + + // Free up the current priority used by the rotation in question + $this->db->update('rotation', ['priority' => 9999], ['id = ?' => $rotationId]); + + // Update the priorities of the rotations that are affected by the move + if ($newPriority < $rotation->priority) { + $affectedRotations = $this->db->select( + (new Select()) + ->columns('id') + ->from('rotation') + ->where([ + 'schedule_id = ?' => $rotation->schedule_id, + 'priority >= ?' => $newPriority, + 'priority < ?' => $rotation->priority + ]) + ->orderBy('priority DESC') + ); + foreach ($affectedRotations as $rotation) { + $this->db->update( + 'rotation', + ['priority' => new Expression('priority + 1')], + ['id = ?' => $rotation->id] + ); + } + } elseif ($newPriority > $rotation->priority) { + $affectedRotations = $this->db->select( + (new Select()) + ->columns('id') + ->from('rotation') + ->where([ + 'schedule_id = ?' => $rotation->schedule_id, + 'priority > ?' => $rotation->priority, + 'priority <= ?' => $newPriority + ]) + ->orderBy('priority ASC') + ); + foreach ($affectedRotations as $rotation) { + $this->db->update( + 'rotation', + ['priority' => new Expression('priority - 1')], + ['id = ?' => $rotation->id] + ); + } + } + + // Now insert the rotation at the new priority + $this->db->update('rotation', ['priority' => $newPriority], ['id = ?' => $rotationId]); + + if ($transactionStarted) { + $this->db->commitTransaction(); + } + } +} diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php new file mode 100644 index 00000000..5ef25954 --- /dev/null +++ b/application/forms/RotationConfigForm.php @@ -0,0 +1,1546 @@ +submitLabel = $label; + + return $this; + } + + /** + * Get the label for the submit button + * + * @return string + */ + public function getSubmitLabel(): string + { + return $this->submitLabel ?? $this->translate('Add Rotation'); + } + + /** + * Set whether to render the remove button + * + * @param bool $state + * + * @return $this + */ + public function setShowRemoveButton(bool $state = true): self + { + $this->showRemoveButton = $state; + + return $this; + } + + /** + * Set the URL to fetch member suggestions from + * + * @param Url $url + * + * @return void + */ + public function setSuggestionUrl(Url $url): self + { + $this->suggestionUrl = $url; + + return $this; + } + + /** + * Disable the mode selection + * + * @return void + */ + public function disableModeSelection(): self + { + $this->disableModeSelection = true; + + return $this; + } + + /** + * Get multipart updates provided by this form's elements + * + * @return array + */ + public function getPartUpdates(): array + { + $this->ensureAssembled(); + + return $this->getElement('members')->prepareMultipartUpdate($this->getRequest()); + } + + /** + * Get whether the remove button was pressed + * + * @return bool + */ + public function hasBeenRemoved(): bool + { + $btn = $this->getPressedSubmitElement(); + $csrf = $this->getElement('CSRFToken'); + + return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'remove'; + } + + /** + * Get whether the remove_all button was pressed + * + * @return bool + */ + public function hasBeenWiped(): bool + { + $btn = $this->getPressedSubmitElement(); + $csrf = $this->getElement('CSRFToken'); + + return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'remove_all'; + } + + /** + * Create a new RotationConfigForm + * + * @param int $scheduleId + * @param Connection $db + */ + public function __construct(int $scheduleId, Connection $db) + { + $this->db = $db; + $this->scheduleId = $scheduleId; + } + + /** + * Load the rotation with the given ID from the database + * + * @param int $rotationId + * + * @return $this + * @throws HttpNotFoundException If the rotation with the given ID does not exist + */ + public function loadRotation(int $rotationId): self + { + /** @var ?Rotation $rotation */ + $rotation = Rotation::on($this->db) + ->filter(Filter::equal('id', $rotationId)) + ->first(); + if ($rotation === null) { + throw new HttpNotFoundException($this->translate('Rotation not found')); + } + + $formData = [ + 'mode' => $rotation->mode, + 'name' => $rotation->name, + 'priority' => $rotation->priority, + 'schedule' => $rotation->schedule_id, + 'options' => $rotation->options + ]; + if (! self::EXPERIMENTAL_OVERRIDES) { + $formData['first_handoff'] = $rotation->first_handoff; + } + + if (self::EXPERIMENTAL_OVERRIDES) { + $getHandoff = function (Rotation $rotation): DateTime { + switch ($rotation->mode) { + case '24-7': + $time = $rotation->options['at']; + + break; + case 'partial': + $time = $rotation->options['from']; + + break; + case 'multi': + $time = $rotation->options['from_at']; + + break; + default: + throw new LogicException('Invalid mode'); + } + + $handoff = DateTime::createFromFormat('Y-m-d H:i', $rotation->first_handoff . ' ' . $time); + if ($handoff === false) { + throw new ConfigurationError('Invalid date format'); + } + + return $handoff; + }; + + $this->previousHandoff = $getHandoff($rotation); + + /** @var ?TimeperiodEntry $previousShift */ + $previousShift = TimeperiodEntry::on($this->db) + ->columns('until_time') + ->filter(Filter::all( + Filter::equal('timeperiod.rotation.schedule_id', $rotation->schedule_id), + Filter::equal('timeperiod.rotation.priority', $rotation->priority), + Filter::unequal('timeperiod.owned_by_rotation_id', $rotation->id), + Filter::lessThanOrEqual('until_time', $this->previousHandoff), + Filter::like('until_time', '*') + )) + ->orderBy('until_time', SORT_DESC) + ->first(); + if ($previousShift !== null) { + $this->previousShift = $previousShift->until_time; + } + + /** @var ?Rotation $newerRotation */ + $newerRotation = Rotation::on($this->db) + ->columns(['first_handoff', 'options', 'mode']) + ->filter(Filter::all( + Filter::equal('schedule_id', $rotation->schedule_id), + Filter::equal('priority', $rotation->priority), + Filter::greaterThan('first_handoff', $rotation->first_handoff) + )) + ->orderBy('first_handoff', SORT_ASC) + ->first(); + if ($newerRotation !== null) { + $this->nextHandoff = $getHandoff($newerRotation); + } + } + + $members = []; + foreach ($rotation->member->orderBy('position', SORT_ASC) as $member) { + if ($member->contact_id !== null) { + $members[] = 'contact:' . $member->contact_id; + } else { + $members[] = 'group:' . $member->contactgroup_id; + } + } + + $formData['members'] = implode(',', $members); + + $this->populate($formData); + + return $this; + } + + /** + * Insert a new rotation in the database + * + * @param int $priority The priority of the rotation + * + * @return Generator The first handoff of the rotation, as value + */ + private function createRotation(int $priority): Generator + { + $data = $this->getValues(); + $data['options'] = Json::encode($data['options']); + $data['schedule_id'] = $this->scheduleId; + $data['priority'] = $priority; + + $members = array_map(function ($member) { + return explode(':', $member, 2); + }, explode(',', $this->getValue('members'))); + + $rules = $this->yieldRecurrenceRules(count($members)); + $firstHandoff = $rules->current()[0]->getStartDate(); + + // Only continue, once the caller is ready + if (! yield $firstHandoff) { + return; + } + + $now = new DateTime(); + if ($firstHandoff < $now) { + $data['actual_handoff'] = (int) $now->format('U.u') * 1000.0; + } else { + $data['actual_handoff'] = $firstHandoff->format('U.u') * 1000.0; + } + + $this->db->insert('rotation', $data); + $rotationId = $this->db->lastInsertId(); + + $this->db->insert('timeperiod', ['owned_by_rotation_id' => $rotationId]); + $timeperiodId = $this->db->lastInsertId(); + + $knownMembers = []; + foreach ($rules as $position => [$rrule, $shiftDuration]) { + /** @var Rule $rrule */ + /** @var DateInterval $shiftDuration */ + + if (isset($knownMembers[$position])) { + $memberId = $knownMembers[$position]; + } else { + [$type, $id] = $members[$position]; + + if ($type === 'contact') { + $this->db->insert('rotation_member', [ + 'rotation_id' => $rotationId, + 'contact_id' => $id, + 'position' => $position + ]); + } elseif ($type === 'group') { + $this->db->insert('rotation_member', [ + 'rotation_id' => $rotationId, + 'contactgroup_id' => $id, + 'position' => $position + ]); + } + + $memberId = $this->db->lastInsertId(); + $knownMembers[$position] = $memberId; + } + + $endTime = (clone $rrule->getStartDate())->add($shiftDuration)->format('U.u') * 1000.0; + + $untilTime = null; + if (! $rrule->repeatsIndefinitely()) { + // Our recurrence rules only repeat definitely due to a set until time + $untilTime = (clone $rrule->getUntil())->add($shiftDuration)->format('U.u') * 1000.0; + } + + $this->db->insert('timeperiod_entry', [ + 'timeperiod_id' => $timeperiodId, + 'rotation_member_id' => $memberId, + 'start_time' => $rrule->getStartDate()->format('U.u') * 1000.0, + 'end_time' => $endTime, + 'until_time' => $untilTime, + 'timezone' => $rrule->getStartDate()->getTimezone()->getName(), + 'rrule' => $rrule->getString(Rule::TZ_FIXED), + ]); + } + } + + /** + * Add a new rotation to the database + * + * @return void + */ + public function addRotation(): void + { + $transactionStarted = false; + if (! $this->db->inTransaction()) { + $transactionStarted = $this->db->beginTransaction(); + } + + $this->createRotation($this->db->fetchScalar( + (new Select()) + ->from('rotation') + ->columns(new Expression('MAX(priority) + 1')) + ->where(['schedule_id = ?' => $this->scheduleId]) + ) ?? 0)->send(true); + + if ($transactionStarted) { + $this->db->commitTransaction(); + } + } + + /** + * Update the rotation with the given ID in the database + * + * @param int $rotationId + * + * @return void + */ + public function editRotation(int $rotationId): void + { + $priority = $this->getValue('priority'); + if ($priority === null) { + throw new LogicException('The priority must be populated'); + } + + $transactionStarted = false; + if (! $this->db->inTransaction()) { + $transactionStarted = $this->db->beginTransaction(); + } + + // Delay the creation, avoids intermediate constraint failures + $createStmt = $this->createRotation((int) $priority); + + $allEntriesRemoved = true; + if (self::EXPERIMENTAL_OVERRIDES) { + // We only show a single name, even in case of multiple versions of a rotation. + // To avoid confusion, we update all versions upon change of the name + $this->db->update('rotation', ['name' => $this->getValue('name')], [ + 'schedule_id = ?' => $this->scheduleId, + 'priority = ?' => $priority + ]); + + $firstHandoff = $createStmt->current(); + $timeperiodEntries = TimeperiodEntry::on($this->db) + ->filter(Filter::equal('timeperiod.owned_by_rotation_id', $rotationId)); + + foreach ($timeperiodEntries as $timeperiodEntry) { + /** @var TimeperiodEntry $timeperiodEntry */ + $rrule = $timeperiodEntry->toRecurrenceRule(); + $shiftDuration = $timeperiodEntry->start_time->diff($timeperiodEntry->end_time); + $remainingHandoffs = $this->calculateRemainingHandoffs($rrule, $shiftDuration, $firstHandoff); + $lastHandoff = array_shift($remainingHandoffs); + + // If there is a gap between the last handoff and the next one, insert a single occurrence to fill it + if (! empty($remainingHandoffs)) { + [$gapStart, $gapEnd] = $remainingHandoffs[0]; + + $allEntriesRemoved = false; + $this->db->insert('timeperiod_entry', [ + 'timeperiod_id' => $timeperiodEntry->timeperiod_id, + 'rotation_member_id' => $timeperiodEntry->rotation_member_id, + 'start_time' => $gapStart->format('U.u') * 1000.0, + 'end_time' => $gapEnd->format('U.u') * 1000.0, + 'until_time' => $gapEnd->format('U.u') * 1000.0, + 'timezone' => $gapStart->getTimezone()->getName() + ]); + } + + $lastShiftEnd = null; + if ($lastHandoff !== null) { + $lastShiftEnd = (clone $lastHandoff)->add($shiftDuration); + } + + if ($lastHandoff === null) { + // If the handoff didn't happen at all, the entry can safely be removed + $this->db->delete('timeperiod_entry', ['id = ?' => $timeperiodEntry->id]); + } else { + $allEntriesRemoved = false; + $this->db->update('timeperiod_entry', [ + 'until_time' => $lastShiftEnd->format('U.u') * 1000.0, + 'rrule' => $rrule->setUntil($lastHandoff)->getString(Rule::TZ_FIXED) + ], ['id = ?' => $timeperiodEntry->id]); + } + } + } else { + $this->db->delete('timeperiod_entry', [ + 'timeperiod_id = ?' => (new Select()) + ->from('timeperiod') + ->columns('id') + ->where(['owned_by_rotation_id = ?' => $rotationId]) + ]); + } + + if ($allEntriesRemoved) { + $this->db->delete('timeperiod', ['owned_by_rotation_id = ?' => $rotationId]); + $this->db->delete('rotation_member', ['rotation_id = ?' => $rotationId]); + $this->db->delete('rotation', ['id = ?' => $rotationId]); + } + + // Once constraint failures are impossible, create the new version + $createStmt->send(true); + + if ($transactionStarted) { + $this->db->commitTransaction(); + } + } + + /** + * Remove the rotation's version with the given ID from the database + * + * @param int $id + * + * @return void + */ + public function removeRotation(int $id): void + { + $priority = $this->getValue('priority'); + if ($priority === null) { + throw new LogicException('The priority must be populated'); + } + + $transactionStarted = false; + if (! $this->db->inTransaction()) { + $transactionStarted = $this->db->beginTransaction(); + } + + $timeperiodId = $this->db->fetchScalar( + (new Select()) + ->from('timeperiod') + ->columns('id') + ->where(['owned_by_rotation_id = ?' => $id]) + ); + + $this->db->delete('timeperiod_entry', ['timeperiod_id = ?' => $timeperiodId]); + $this->db->delete('timeperiod', ['id = ?' => $timeperiodId]); + $this->db->delete('rotation_member', ['rotation_id = ?' => $id]); + $this->db->delete('rotation', ['id = ?' => $id]); + + $rotations = Rotation::on($this->db) + ->filter(Filter::equal('schedule_id', $this->scheduleId)) + ->filter(Filter::equal('priority', $priority)); + if ($rotations->count() === 0) { + $affectedRotations = $this->db->select( + (new Select()) + ->columns('id') + ->from('rotation') + ->where([ + 'schedule_id = ?' => $this->scheduleId, + 'priority > ?' => $priority + ]) + ->orderBy('priority ASC') + ); + foreach ($affectedRotations as $rotation) { + $this->db->update( + 'rotation', + ['priority' => new Expression('priority - 1')], + ['id = ?' => $rotation->id] + ); + } + } + + if ($transactionStarted) { + $this->db->commitTransaction(); + } + } + + /** + * Remove all versions of the rotation from the database + * + * @return void + */ + public function wipeRotation(int $priority = null): void + { + $priority = $priority ?? $this->getValue('priority'); + if ($priority === null) { + throw new LogicException('The priority must be populated'); + } + + $transactionStarted = false; + if (! $this->db->inTransaction()) { + $transactionStarted = $this->db->beginTransaction(); + } + + $rotations = Rotation::on($this->db) + ->columns('id') + ->filter(Filter::equal('schedule_id', $this->scheduleId)) + ->filter(Filter::equal('priority', $priority)); + foreach ($rotations as $rotation) { + $timeperiodId = $this->db->fetchScalar( + (new Select()) + ->from('timeperiod') + ->columns('id') + ->where(['owned_by_rotation_id = ?' => $rotation->id]) + ); + + $this->db->delete('timeperiod_entry', ['timeperiod_id = ?' => $timeperiodId]); + $this->db->delete('timeperiod', ['id = ?' => $timeperiodId]); + $this->db->delete('rotation_member', ['rotation_id = ?' => $rotation->id]); + $this->db->delete('rotation', ['id = ?' => $rotation->id]); + } + + $affectedRotations = $this->db->select( + (new Select()) + ->columns('id') + ->from('rotation') + ->where([ + 'schedule_id = ?' => $this->scheduleId, + 'priority > ?' => $priority + ]) + ->orderBy('priority ASC') + ); + foreach ($affectedRotations as $rotation) { + $this->db->update( + 'rotation', + ['priority' => new Expression('priority - 1')], + ['id = ?' => $rotation->id] + ); + } + + if ($transactionStarted) { + $this->db->commitTransaction(); + } + } + + protected function assembleModeSelection(): string + { + $value = $this->getPopulatedValue('mode'); + + $modes = [ + '24-7' => $this->translate('24/7'), + 'partial' => $this->translate('Partial Day'), + 'multi' => $this->translate('Multi Day') + ]; + + $modeList = new HtmlElement('ul'); + foreach ($modes as $mode => $label) { + $radio = $this->createElement('input', 'mode', [ + 'type' => 'radio', + 'value' => $mode, + 'disabled' => $this->disableModeSelection, + 'id' => 'rotation-mode-' . $mode, + 'class' => 'autosubmit' + ]); + if ($value === null || $mode === $value) { + $radio->getAttributes()->set('checked', true); + $this->registerElement($radio); + $value = $mode; + } + + $modeList->addHtml(new HtmlElement( + 'li', + null, + new HtmlElement( + 'label', + null, + $radio, + new HtmlElement('img', Attributes::create([ + 'src' => Url::fromPath(sprintf('img/notifications/pictogram/%s-gray.jpg', $mode)), + 'class' => 'unchecked' + ])), + new HtmlElement('img', Attributes::create([ + 'src' => Url::fromPath(sprintf('img/notifications/pictogram/%s-colored.jpg', $mode)), + 'class' => 'checked' + ])), + Text::create($label) + ) + )); + } + + $this->addHtml(new HtmlElement( + 'div', + Attributes::create(['class' => ['rotation-mode', $this->disableModeSelection ? 'disabled' : '']]), + new HtmlElement('h2', null, Text::create($this->translate('Mode'))), + $modeList + )); + + return $value; + } + + /** + * Assemble option elements for the 24/7 mode + * + * @param FieldsetElement $options + * + * @return DateTime The default first handoff + */ + protected function assembleTwentyFourSevenOptions(FieldsetElement $options): DateTime + { + $options->addElement('number', 'interval', [ + 'required' => true, + 'label' => $this->translate('Handoff every'), + 'step' => 1, + 'min' => 1, + 'value' => 1, + 'validators' => [new GreaterThanValidator()] + ]); + $interval = $options->getElement('interval'); + + $frequency = $options->createElement('select', 'frequency', [ + 'required' => true, + 'options' => [ + 'd' => $this->translate('Days'), + 'w' => $this->translate('Weeks') + ] + ]); + $options->registerElement($frequency); + + $at = $options->createElement('select', 'at', [ + 'class' => 'autosubmit', + 'required' => true, + 'options' => $this->getTimeOptions() + ]); + $options->registerElement($at); + + $interval->prependWrapper( + (new HtmlDocument())->addHtml( + $interval, + $frequency, + new HtmlElement( + 'span', + null, + Text::create($this->translate('at', 'handoff every at