From cf8c52d0759dd05686cc54c9d91f3b3bb8f502b1 Mon Sep 17 00:00:00 2001 From: Johannes Meyer Date: Tue, 7 May 2024 13:06:59 +0200 Subject: [PATCH] RotationConfigForm: Implement methods to persist changes in database --- application/forms/RotationConfigForm.php | 411 +++++++++++++++++++++++ 1 file changed, 411 insertions(+) diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index 17fed0978..d12456df6 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -6,15 +6,24 @@ use DateInterval; use DateTime; +use Generator; +use Icinga\Exception\Http\HttpNotFoundException; +use Icinga\Exception\NotImplementedError; use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Model\Contact; use Icinga\Module\Notifications\Model\Contactgroup; +use Icinga\Module\Notifications\Model\Rotation; +use Icinga\Module\Notifications\Model\TimeperiodEntry; +use Icinga\Util\Json; use Icinga\Web\Session; use ipl\Html\Attributes; use ipl\Html\FormElement\FieldsetElement; use ipl\Html\HtmlDocument; use ipl\Html\HtmlElement; use ipl\Html\Text; +use ipl\Sql\Connection; +use ipl\Sql\Expression; +use ipl\Sql\Select; use ipl\Stdlib\Filter; use ipl\Validator\CallbackValidator; use ipl\Validator\GreaterThanValidator; @@ -22,11 +31,16 @@ use ipl\Web\Compat\CompatForm; use ipl\Web\FormElement\TermInput; use ipl\Web\Url; +use LogicException; +use Recurr; class RotationConfigForm extends CompatForm { use CsrfCounterMeasure; + /** @var Connection The database connection */ + protected $db; + /** @var string The label shown on the submit button */ protected $submitLabel; @@ -128,6 +142,240 @@ public function hasBeenRemoved(): bool return $csrf !== null && $csrf->isValid() && $btn !== null && $btn->getName() === 'remove'; } + /** + * Create a new RotationConfigForm + * + * @param Connection $db + */ + public function __construct(Connection $db) + { + $this->db = $db; + } + + /** + * 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, + 'options' => Json::decode($rotation->options, true) + ]; + + $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; + } + + /** + * Add a new rotation to the database + * + * @param int $scheduleId The ID of the schedule the rotation belongs to + * + * @return int The ID of the newly created rotation + */ + public function addRotation(int $scheduleId): int + { + $data = $this->getValues(); + $data['options'] = Json::encode($data['options']); + $data['schedule_id'] = $scheduleId; + + $members = array_map(function ($member) { + return explode(':', $member, 2); + }, explode(',', $this->getValue('members'))); + + $transactionStarted = false; + if (! $this->db->inTransaction()) { + $transactionStarted = $this->db->beginTransaction(); + } + + $data['priority'] = $this->db->fetchScalar( + (new Select()) + ->from('rotation') + ->columns(new Expression('MAX(priority) + 1')) + ->where(['schedule_id = ?' => $scheduleId]) + ) ?? 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 ($this->yieldRecurrenceRules(count($members)) as $position => $rrule) { + 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; + } + + $this->db->insert('timeperiod_entry', [ + 'timeperiod_id' => $timeperiodId, + 'rotation_member_id' => $memberId, + 'start_time' => $rrule->getStartDate()->format('U.u') * 1000.0, + 'end_time' => $rrule->getEndDate()->format('U.u') * 1000.0, + 'timezone' => $rrule->getStartDate()->getTimezone()->getName(), + 'rrule' => $rrule->getString(Recurr\Rule::TZ_FIXED), + ]); + } + + if ($transactionStarted) { + $this->db->commitTransaction(); + } + + return $rotationId; + } + + /** + * Update the rotation with the given ID in the database + * + * @param int $rotationId + * + * @return void + */ + public function editRotation(int $rotationId): void + { + $data = $this->getValues(); + $data['options'] = Json::encode($data['options']); + + $members = array_map(function ($member) { + return explode(':', $member, 2); + }, explode(',', $this->getValue('members'))); + + $transactionStarted = false; + if (! $this->db->inTransaction()) { + $transactionStarted = $this->db->beginTransaction(); + } + + $this->db->update('rotation', $data, ['id = ?' => $rotationId]); + $this->db->delete('rotation_member', ['rotation_id = ?' => $rotationId]); + + $rules = $this->yieldRecurrenceRules(count($members)); + $firstHandoff = $rules->current()->getStartDate(); + + $timeperiodEntries = TimeperiodEntry::on($this->db) + ->filter(Filter::all( + Filter::equal('timeperiod.owned_by_rotation_id', $rotationId), + Filter::unlike('until_time', '*') + )); + $now = new DateTime(); + foreach ($timeperiodEntries as $timeperiodEntry) { + /** @var TimeperiodEntry $timeperiodEntry */ + $rrule = $timeperiodEntry->toRecurrenceRule(); + $lastHandoff = $this->getLastPossibleHandoff($rrule, $firstHandoff); + if ($lastHandoff === null || $lastHandoff < $now) { + // If the last handoff has already happened or didn't happen at all, the entry can safely be removed + $this->db->delete('timeperiod_entry', ['id = ?' => $timeperiodEntry->id]); + } else { + $this->db->update('timeperiod_entry', [ + 'until_time' => $lastHandoff->format('U.u') * 1000.0, + 'rrule' => $rrule->setUntil($lastHandoff)->getString(Recurr\Rule::TZ_FIXED) + ], ['id = ?' => $timeperiodEntry->id]); + } + } + + $timeperiodId = $this->db->fetchScalar( + (new Select()) + ->from('timeperiod') + ->columns('id') + ->where('owned_by_rotation_id = ?', $rotationId) + ); + + $knownMembers = []; + foreach ($rules as $position => $rrule) { + 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; + } + + $this->db->insert('timeperiod_entry', [ + 'timeperiod_id' => $timeperiodId, + 'rotation_member_id' => $memberId, + 'start_time' => $rrule->getStartDate()->format('U.u') * 1000.0, + 'end_time' => $rrule->getEndDate()->format('U.u') * 1000.0, + 'timezone' => $rrule->getStartDate()->getTimezone()->getName(), + 'rrule' => $rrule->getString(Recurr\Rule::TZ_FIXED), + ]); + } + + if ($transactionStarted) { + $this->db->commitTransaction(); + } + } + + /** + * Remove the rotation with the given ID from the database + * + * @param int $id + * + * @return void + */ + public function removeRotation(int $id): void + { + throw new NotImplementedError('Not implemented'); + } + protected function assembleModeSelection(): string { $value = $this->getPopulatedValue('mode'); @@ -643,4 +891,167 @@ private function parseDateAndTime(?string $date = null, ?string $time = null): D return $datetime; } + + /** + * Yield recurrence rules based on the form's values + * + * @param int $count The number of rules to yield + * + * @return Generator + */ + private function yieldRecurrenceRules(int $count): Generator + { + $rule = new Recurr\Rule(); + + $options = $this->getValue('options'); + switch ($this->getValue('mode')) { + case '24-7': + $interval = (int) $options['interval']; + $nextHandoff = $this->parseDateAndTime($this->getValue('next_handoff'), $options['at']); + + if ($options['frequency'] === 'd') { + $frequency = Recurr\Frequency::DAILY; + $shiftDuration = new DateInterval(sprintf('P%dD', $interval)); + } else { + $frequency = Recurr\Frequency::WEEKLY; + $shiftDuration = new DateInterval(sprintf('P%dW', $interval)); + } + + $rule->setFreq($frequency); + $rule->setInterval($interval * $count); + + $ruleSeq = range(0, $count - 1); + $rotationOffset = $shiftDuration; + + break; + case 'partial': + $days = $options['days']; + $interval = (int) $options['interval']; + + $rule->setFreq(Recurr\Frequency::WEEKLY); + $rule->setInterval($interval * $count); + $rule->setByDay(array_intersect_key(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], $days)); + + $nextHandoff = $this->parseDateAndTime($this->getValue('next_handoff'), $options['from']); + $shiftDuration = $nextHandoff->diff( + $this->parseDateAndTime($this->getValue('next_handoff'), $options['to']) + ); + + $rotationOffset = new DateInterval('P1W'); + + $ruleSeq = []; + for ($i = 0; $i < $count; $i++) { + array_push($ruleSeq, ...array_fill(0, $interval, $i)); + } + + break; + case 'multi': + $fromDay = (int) $options['from_day']; + $toDay = (int) $options['to_day']; + $interval = (int) $options['interval']; + + $rule->setFreq(Recurr\Frequency::WEEKLY); + $rule->setInterval($interval * $count); + + $nextHandoff = $this->parseDateAndTime($this->getValue('next_handoff'), $options['from_at']); + $shiftDuration = $nextHandoff->diff($this->parseDateAndTime( // returns the first end datetime + (clone $nextHandoff) + ->add(new DateInterval(sprintf( + 'P%dD', + $toDay > $fromDay + ? $toDay - $fromDay + : $fromDay - $toDay + )))->format('Y-m-d'), + $options['to_at'] + )); + + $rotationOffset = new DateInterval('P1W'); + + $ruleSeq = []; + for ($i = 0; $i < $count; $i++) { + array_push($ruleSeq, ...array_fill(0, $interval, $i)); + } + + break; + default: + throw new LogicException('Unknown mode'); + } + + foreach ($ruleSeq as $position) { + $rule->setStartDate($nextHandoff); + $rule->setEndDate((clone $nextHandoff)->add($shiftDuration)); + $nextHandoff = (clone $nextHandoff)->add($rotationOffset); + + yield $position => $rule; + } + } + + /** + * Get the last possible handoff before the given date + * + * @param Recurr\Rule $rrule + * @param DateTime $before + * + * @return ?DateTime Returns NULL in case no handoff has happened yet + */ + private function getLastPossibleHandoff(Recurr\Rule $rrule, DateTime $before): ?DateTime + { + // $before is based on new changes, so it's required to synchronize it with the given RRULE + $before = (clone $before)->setTime( + (int) $rrule->getStartDate()->format('H'), + (int) $rrule->getStartDate()->format('i') + ); + + if ($rrule->getStartDate() >= $before) { + // No time passed yet, the first occurrence is in the future + return null; + } + + if ($rrule->getFreq() === Recurr\Frequency::DAILY) { + $interval = $rrule->getInterval(); + } elseif ($rrule->getFreq() === Recurr\Frequency::WEEKLY) { + $interval = $rrule->getInterval() * 7; + } else { + throw new LogicException('Unsupported frequency'); + } + + $daysSinceLatestHandoff = $rrule->getStartDate()->diff($before)->days % $interval; + $lastHandoff = (clone $before)->sub(new DateInterval(sprintf('P%dD', $daysSinceLatestHandoff))); + + $byDay = $rrule->getByDay(); + if (empty($byDay)) { + $shiftLength = $rrule->getStartDate()->diff($rrule->getEndDate()); + $lastShiftEnd = (clone $lastHandoff)->add($shiftLength); + if ($lastShiftEnd > $before) { + // Return the occurrence before the last, as it overlaps with the given date otherwise + $lastHandoff->sub(new DateInterval(sprintf('P%dD', $interval))); + } + } else { + // If this RRULE is based on a partial day configuration, forward to very last possible shift + $byDay = array_intersect([ + 1 => 'MO', + 2 => 'TU', + 3 => 'WE', + 4 => 'TH', + 5 => 'FR', + 6 => 'SA', + 7 => 'SU' + ], $byDay); + + $shiftLength = $rrule->getStartDate()->diff($rrule->getEndDate()); + $lastHandoff->add(new DateInterval('P6D')); + for ($i = 0; $i < 6; $i++) { + if (isset($byDay[$lastHandoff->format('N')]) && $lastHandoff < $before) { + $lastShiftEnd = (clone $lastHandoff)->add($shiftLength); + if ($lastShiftEnd < $before) { + break; + } + } + + $lastHandoff->sub(new DateInterval('P1D')); + } + } + + return $lastHandoff; + } }