diff --git a/application/controllers/ChannelController.php b/application/controllers/ChannelController.php index 163fcc26..6dc48e6a 100644 --- a/application/controllers/ChannelController.php +++ b/application/controllers/ChannelController.php @@ -6,44 +6,30 @@ use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Forms\ChannelForm; -use Icinga\Module\Notifications\Model\Channel; use Icinga\Web\Notification; -use ipl\Sql\Connection; -use ipl\Stdlib\Filter; use ipl\Web\Compat\CompatController; class ChannelController extends CompatController { - /** @var Connection */ - private $db; - - public function init() + public function init(): void { $this->assertPermission('config/modules'); - - $this->db = Database::get(); } - public function indexAction() + public function indexAction(): void { - $channel = Channel::on($this->db); $channelId = $this->params->getRequired('id'); - - $channel->filter(Filter::equal('id', $channelId)); - - $channel = $channel->first(); - - $this->addTitleTab(sprintf(t('Channel: %s'), $channel->name)); - - $form = (new ChannelForm($this->db, $channelId)) - ->populate($channel) + $form = (new ChannelForm(Database::get())) + ->loadChannel($channelId) ->on(ChannelForm::ON_SUCCESS, function (ChannelForm $form) { if ($form->getPressedSubmitElement()->getName() === 'delete') { + $form->removeChannel(); Notification::success(sprintf( t('Deleted channel "%s" successfully'), $form->getValue('name') )); } else { + $form->editChannel(); Notification::success(sprintf( t('Channel "%s" has successfully been saved'), $form->getValue('name') @@ -53,6 +39,8 @@ public function indexAction() $this->redirectNow('__CLOSE__'); })->handleRequest($this->getServerRequest()); + $this->addTitleTab(sprintf(t('Channel: %s'), $form->getChannelName())); + $this->addContent($form); } } diff --git a/application/controllers/ChannelsController.php b/application/controllers/ChannelsController.php index b55d1eda..90127d36 100644 --- a/application/controllers/ChannelsController.php +++ b/application/controllers/ChannelsController.php @@ -52,8 +52,9 @@ public function indexAction() $sortControl = $this->createSortControl( $channels, [ - 'name' => t('Name'), - 'type' => t('Type') + 'name' => t('Name'), + 'type' => t('Type'), + 'changed_at' => t('Changed At') ] ); @@ -104,6 +105,7 @@ public function addAction() $this->addTitleTab(t('Add Channel')); $form = (new ChannelForm($this->db)) ->on(ChannelForm::ON_SUCCESS, function (ChannelForm $form) { + $form->addChannel(); Notification::success( sprintf( t('New channel %s has successfully been added'), diff --git a/application/controllers/ContactController.php b/application/controllers/ContactController.php index 13f511d7..0fc7b429 100644 --- a/application/controllers/ContactController.php +++ b/application/controllers/ContactController.php @@ -15,51 +15,37 @@ class ContactController extends CompatController { - /** @var Connection */ - private $db; - - public function init() + public function init(): void { $this->assertPermission('notifications/config/contacts'); - - $this->db = Database::get(); } - public function indexAction() + public function indexAction(): void { - $contact = Contact::on($this->db); $contactId = $this->params->getRequired('id'); - $contact->filter(Filter::equal('id', $contactId)); - - $contact = $contact->first(); - - $this->addTitleTab(sprintf(t('Contact: %s'), $contact->full_name)); - - $form = (new ContactForm($this->db, $contactId)) - ->populate($contact) + $form = (new ContactForm(Database::get())) + ->loadContact($contactId) ->on(ContactForm::ON_SUCCESS, function (ContactForm $form) { - $form->addOrUpdateContact(); - /** @var FieldsetElement $contactElement */ - $contactElement = $form->getElement('contact'); + $form->editContact(); Notification::success(sprintf( t('Contact "%s" has successfully been saved'), - $contactElement->getValue('full_name') + $form->getContactName() )); $this->redirectNow('__CLOSE__'); })->on(ContactForm::ON_REMOVE, function (ContactForm $form) { $form->removeContact(); - /** @var FieldsetElement $contactElement */ - $contactElement = $form->getElement('contact'); Notification::success(sprintf( t('Deleted contact "%s" successfully'), - $contactElement->getValue('full_name') + $form->getContactName() )); $this->redirectNow('__CLOSE__'); })->handleRequest($this->getServerRequest()); + $this->addTitleTab(sprintf(t('Contact: %s'), $form->getContactName())); + $this->addContent($form); } } diff --git a/application/controllers/ContactGroupController.php b/application/controllers/ContactGroupController.php index 8b760ab1..b1c31aa4 100644 --- a/application/controllers/ContactGroupController.php +++ b/application/controllers/ContactGroupController.php @@ -7,7 +7,9 @@ use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Forms\ContactGroupForm; +use Icinga\Module\Notifications\Model\Contact; use Icinga\Module\Notifications\Model\Contactgroup; +use Icinga\Module\Notifications\Model\ContactgroupMember; use Icinga\Module\Notifications\Widget\ItemList\ContactList; use Icinga\Web\Notification; use ipl\Html\Attributes; @@ -44,7 +46,13 @@ public function indexAction(): void $this->addControl(new HtmlElement('div', new Attributes(['class' => 'header']), Text::create($group->name))); - $this->addControl($this->createPaginationControl($group->contact)); + $contacts = Contact::on(Database::get()) + ->filter(Filter::all( + Filter::equal('contactgroup_member.contactgroup_id', $groupId), + Filter::equal('contactgroup_member.deleted', 'n') + )); + + $this->addControl($this->createPaginationControl($contacts)); $this->addControl($this->createLimitControl()); $this->addContent( @@ -56,7 +64,7 @@ public function indexAction(): void ))->openInModal() ); - $this->addContent(new ContactList($group->contact)); + $this->addContent(new ContactList($contacts)); $this->addTitleTab(t('Contact Group')); $this->setTitle(sprintf(t('Contact Group: %s'), $group->name)); @@ -90,12 +98,11 @@ public function editAction(): void } }) ->on(Form::ON_SUCCESS, function (ContactGroupForm $form) use ($groupId) { - if ($form->editGroup()) { - Notification::success(sprintf( - t('Successfully updated contact group %s'), - $form->getValue('group_name') - )); - } + $form->editGroup(); + Notification::success(sprintf( + t('Successfully updated contact group %s'), + $form->getValue('group_name') + )); $this->closeModalAndRefreshRemainingViews(Links::contactGroup($groupId)); }) diff --git a/application/controllers/ContactGroupsController.php b/application/controllers/ContactGroupsController.php index d37e7e29..feb22831 100644 --- a/application/controllers/ContactGroupsController.php +++ b/application/controllers/ContactGroupsController.php @@ -46,7 +46,8 @@ public function indexAction(): void $sortControl = $this->createSortControl( $groups, [ - 'name' => t('Group Name'), + 'name' => t('Group Name'), + 'changed_at' => t('Changed At') ] ); diff --git a/application/controllers/ContactsController.php b/application/controllers/ContactsController.php index 7a65f7be..c6c3408d 100644 --- a/application/controllers/ContactsController.php +++ b/application/controllers/ContactsController.php @@ -50,7 +50,8 @@ public function indexAction() $sortControl = $this->createSortControl( $contacts, [ - 'full_name' => t('Full Name'), + 'full_name' => t('Full Name'), + 'changed_at' => t('Changed At') ] ); @@ -101,15 +102,15 @@ public function indexAction() $this->getTabs()->activate('contacts'); } - public function addAction() + public function addAction(): void { $this->addTitleTab(t('Add Contact')); $form = (new ContactForm($this->db)) ->on(ContactForm::ON_SUCCESS, function (ContactForm $form) { - $form->addOrUpdateContact(); + $form->addContact(); Notification::success(t('New contact has successfully been added')); - $this->redirectNow(Url::fromPath('notifications/contacts')); + $this->redirectNow(Links::contacts()); })->handleRequest($this->getServerRequest()); $this->addContent($form); diff --git a/application/controllers/EventRuleController.php b/application/controllers/EventRuleController.php index f52b59ad..e331e3a2 100644 --- a/application/controllers/EventRuleController.php +++ b/application/controllers/EventRuleController.php @@ -10,7 +10,6 @@ use Icinga\Module\Notifications\Forms\EventRuleForm; use Icinga\Module\Notifications\Forms\SaveEventRuleForm; use Icinga\Module\Notifications\Model\Incident; -use Icinga\Module\Notifications\Model\ObjectExtraTag; use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\Web\Control\SearchBar\ExtraTagSuggestions; use Icinga\Module\Notifications\Widget\EventRuleConfig; @@ -62,21 +61,9 @@ public function indexAction(): void ); } - $disableRemoveButton = false; - if (ctype_digit($ruleId)) { - $incidents = Incident::on(Database::get()) - ->with('rule') - ->filter(Filter::equal('rule.id', $ruleId)); - - if ($incidents->count() > 0) { - $disableRemoveButton = true; - } - } - $saveForm = (new SaveEventRuleForm()) ->setShowRemoveButton() ->setShowDismissChangesButton($cache !== null) - ->setRemoveButtonDisabled($disableRemoveButton) ->setSubmitButtonDisabled($cache === null) ->setSubmitLabel($this->translate('Save Changes')) ->on(SaveEventRuleForm::ON_SUCCESS, function ($form) use ($ruleId, $eventRuleConfig) { @@ -151,7 +138,7 @@ public function indexAction(): void public function fromDb(int $ruleId): array { $query = Rule::on(Database::get()) - ->withoutColumns('timeperiod_id') + ->columns(['id', 'name', 'object_filter']) ->filter(Filter::equal('id', $ruleId)); $rule = $query->first(); @@ -161,12 +148,20 @@ public function fromDb(int $ruleId): array $config = iterator_to_array($rule); - foreach ($rule->rule_escalation as $re) { + $ruleEscalations = $rule + ->rule_escalation + ->withoutColumns(['changed_at', 'deleted']); + + foreach ($ruleEscalations as $re) { foreach ($re as $k => $v) { $config[$re->getTableName()][$re->position][$k] = $v; } - foreach ($re->rule_escalation_recipient as $recipient) { + $escalationRecipients = $re + ->rule_escalation_recipient + ->withoutColumns(['changed_at', 'deleted']); + + foreach ($escalationRecipients as $recipient) { $config[$re->getTableName()][$re->position]['recipient'][] = iterator_to_array($recipient); } } @@ -248,7 +243,6 @@ public function editAction(): void ->setAction(Url::fromRequest()->getAbsoluteUrl()) ->on(Form::ON_SUCCESS, function ($form) use ($ruleId, $cache, $config) { $config['name'] = $form->getValue('name'); - $config['is_active'] = $form->getValue('is_active'); if ($cache || $ruleId === '-1') { $this->sessionNamespace->set($ruleId, $config); diff --git a/application/controllers/EventRulesController.php b/application/controllers/EventRulesController.php index a04cbc55..8e1a0e6d 100644 --- a/application/controllers/EventRulesController.php +++ b/application/controllers/EventRulesController.php @@ -51,7 +51,8 @@ public function indexAction(): void $sortControl = $this->createSortControl( $eventRules, [ - 'name' => t('Name'), + 'name' => t('Name'), + 'changed_at' => t('Changed At') ] ); diff --git a/application/controllers/ScheduleController.php b/application/controllers/ScheduleController.php index 79fc1969..298a0159 100644 --- a/application/controllers/ScheduleController.php +++ b/application/controllers/ScheduleController.php @@ -68,7 +68,7 @@ public function settingsAction(): void $this->setTitle($this->translate('Edit Schedule')); $scheduleId = (int) $this->params->getRequired('id'); - $form = new ScheduleForm(); + $form = new ScheduleForm(Database::get()); $form->setShowRemoveButton(); $form->loadSchedule($scheduleId); $form->setSubmitLabel($this->translate('Save Changes')); @@ -95,7 +95,7 @@ public function settingsAction(): void public function addAction(): void { $this->setTitle($this->translate('New Schedule')); - $form = (new ScheduleForm()) + $form = (new ScheduleForm(Database::get())) ->setAction($this->getRequest()->getUrl()->getAbsoluteUrl()) ->on(Form::ON_SUCCESS, function (ScheduleForm $form) { $scheduleId = $form->addSchedule(); diff --git a/application/controllers/SchedulesController.php b/application/controllers/SchedulesController.php index a9af10dc..9ee77003 100644 --- a/application/controllers/SchedulesController.php +++ b/application/controllers/SchedulesController.php @@ -32,7 +32,10 @@ public function indexAction(): void $limitControl = $this->createLimitControl(); $sortControl = $this->createSortControl( $schedules, - ['schedule.name' => t('Name')] + [ + 'schedule.name' => t('Name'), + 'changed_at' => t('Changed At') + ] ); $paginationControl = $this->createPaginationControl($schedules); diff --git a/application/controllers/SourceController.php b/application/controllers/SourceController.php index a84e9298..3c442fcc 100644 --- a/application/controllers/SourceController.php +++ b/application/controllers/SourceController.php @@ -14,7 +14,7 @@ class SourceController extends CompatController { - public function init() + public function init(): void { $this->assertPermission('config/modules'); } @@ -23,38 +23,29 @@ public function indexAction(): void { $sourceId = (int) $this->params->getRequired('id'); - /** @var ?Source $source */ - $source = Source::on(Database::get()) - ->filter(Filter::equal('id', $sourceId)) - ->first(); - if ($source === null) { - $this->httpNotFound($this->translate('Source not found')); - } - - $form = (new SourceForm(Database::get(), $sourceId)) - ->populate($source) + $form = (new SourceForm(Database::get())) + ->loadSource($sourceId) ->on(SourceForm::ON_SUCCESS, function (SourceForm $form) { - /** @var string $sourceName */ - $sourceName = $form->getValue('name'); - /** @var FormSubmitElement $pressedButton */ $pressedButton = $form->getPressedSubmitElement(); if ($pressedButton->getName() === 'delete') { + $form->removeSource(); Notification::success(sprintf( $this->translate('Deleted source "%s" successfully'), - $sourceName + $form->getSourceName() )); } else { + $form->editSource(); Notification::success(sprintf( $this->translate('Updated source "%s" successfully'), - $sourceName + $form->getSourceName() )); } $this->switchToSingleColumnLayout(); })->handleRequest($this->getServerRequest()); - $this->addTitleTab(sprintf($this->translate('Source: %s'), $source->name)); + $this->addTitleTab(sprintf($this->translate('Source: %s'), $form->getSourceName())); $this->addContent($form); } } diff --git a/application/controllers/SourcesController.php b/application/controllers/SourcesController.php index c7fdac02..65b831fc 100644 --- a/application/controllers/SourcesController.php +++ b/application/controllers/SourcesController.php @@ -40,8 +40,9 @@ public function indexAction(): void $sortControl = $this->createSortControl( $sources, [ - 'name' => t('Name'), - 'type' => t('Type') + 'name' => t('Name'), + 'type' => t('Type'), + 'changed_at' => t('Changed At') ] ); @@ -93,9 +94,8 @@ public function addAction(): void { $form = (new SourceForm(Database::get())) ->on(SourceForm::ON_SUCCESS, function (SourceForm $form) { - /** @var string $sourceName */ - $sourceName = $form->getValue('name'); - Notification::success(sprintf(t('Added new source %s has successfully'), $sourceName)); + $form->addSource(); + Notification::success(sprintf(t('Added new source %s successfully'), $form->getSourceName())); $this->switchToSingleColumnLayout(); }) ->handleRequest($this->getServerRequest()); diff --git a/application/forms/ChannelForm.php b/application/forms/ChannelForm.php index d1ab6f0d..f710de4a 100644 --- a/application/forms/ChannelForm.php +++ b/application/forms/ChannelForm.php @@ -4,8 +4,11 @@ namespace Icinga\Module\Notifications\Forms; +use Icinga\Exception\Http\HttpNotFoundException; use Icinga\Module\Notifications\Model\Channel; use Icinga\Module\Notifications\Model\AvailableChannelType; +use Icinga\Module\Notifications\Model\Contact; +use Icinga\Module\Notifications\Model\RuleEscalationRecipient; use Icinga\Web\Session; use ipl\Html\Contract\FormSubmitElement; use ipl\Html\FormElement\BaseFormElement; @@ -13,6 +16,7 @@ use ipl\I18n\GettextTranslator; use ipl\I18n\StaticTranslator; use ipl\Sql\Connection; +use ipl\Stdlib\Filter; use ipl\Validator\EmailAddressValidator; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; @@ -43,14 +47,14 @@ class ChannelForm extends CompatForm /** @var array */ private $defaultChannelOptions = []; - public function __construct(Connection $db, ?int $channelId = null) + public function __construct(Connection $db) { $this->db = $db; - $this->channelId = $channelId; } protected function assemble() { + $this->addAttributes(['class' => 'channel-form']); $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); $this->addElement( @@ -110,6 +114,18 @@ protected function assemble() ); if ($this->channelId !== null) { + $isInUse = Contact::on($this->db) + ->columns('1') + ->filter(Filter::equal('default_channel_id', $this->channelId)) + ->first(); + + if ($isInUse === null) { + $isInUse = RuleEscalationRecipient::on($this->db) + ->columns('1') + ->filter(Filter::equal('channel_id', $this->channelId)) + ->first(); + } + /** @var FormSubmitElement $deleteButton */ $deleteButton = $this->createElement( 'submit', @@ -117,7 +133,14 @@ protected function assemble() [ 'label' => $this->translate('Delete'), 'class' => 'btn-remove', - 'formnovalidate' => true + 'formnovalidate' => true, + 'disabled' => $isInUse !== null, + 'title' => $isInUse + ? $this->translate( + "Channel is still referenced as a contact's default" + . " channel or in an event rule's escalation" + ) + : null ] ); @@ -152,48 +175,80 @@ public function hasBeenSubmitted() return parent::hasBeenSubmitted(); } - public function populate($values) + /** + * Load the channel with given id + * + * @param int $id + * + * @return $this + * + * @throws HttpNotFoundException + */ + public function loadChannel(int $id): self { - if ($values instanceof Channel) { - $values = [ - 'name' => $values->name, - 'type' => $values->type, - 'config' => json_decode($values->config, true) ?? [] - ]; - } - - parent::populate($values); + $this->channelId = $id; + $this->populate($this->fetchDbValues()); return $this; } - protected function onSuccess() + /** + * Add the new channel + */ + public function addChannel(): void { - if ($this->getPressedSubmitElement()->getName() === 'delete') { - $this->db->delete('channel', ['id = ?' => $this->channelId]); + $channel = $this->getValues(); - return; - } + $channel['config'] = json_encode($this->filterConfig($channel['config'])); + $channel['changed_at'] = time() * 1000; + + $this->db->insert('channel', $channel); + } + + /** + * Edit the channel + * + * @return void + */ + public function editChannel(): void + { + $this->db->beginTransaction(); $channel = $this->getValues(); - $config = array_filter( - $channel['config'], - function ($configItem, $key) { - if (isset($this->defaultChannelOptions[$key])) { - return $this->defaultChannelOptions[$key] !== $configItem; - } + $storedValues = $this->fetchDbValues(); - return $configItem !== null; - }, - ARRAY_FILTER_USE_BOTH - ); + $channel['config'] = json_encode($this->filterConfig($channel['config'])); + $storedValues['config'] = json_encode($this->filterConfig($storedValues['config'])); + + if (! empty(array_diff_assoc($channel, $storedValues))) { + $channel['changed_at'] = time() * 1000; - $channel['config'] = json_encode($config); - if ($this->channelId === null) { - $this->db->insert('channel', $channel); - } else { $this->db->update('channel', $channel, ['id = ?' => $this->channelId]); } + + $this->db->commitTransaction(); + } + + /** + * Remove the channel + */ + public function removeChannel(): void + { + $this->db->update( + 'channel', + ['changed_at' => time() * 1000, 'deleted' => 'y'], + ['id = ?' => $this->channelId] + ); + } + + /** + * Get the channel name + * + * @return string + */ + public function getChannelName(): string + { + return $this->getValue('name'); } /** @@ -332,4 +387,51 @@ protected function fromCurrentLocale(array $localeMap): ?string return $localeMap[$locale] ?? $localeMap[$default] ?? null; } + + /** + * Filter the config array + * + * @param array $config + * + * @return ChannelOptionConfig + */ + private function filterConfig(array $config): array + { + return array_filter( + $config, + function ($configItem, $key) { + if (isset($this->defaultChannelOptions[$key])) { + return $this->defaultChannelOptions[$key] !== $configItem; + } + + return $configItem !== null; + }, + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Fetch the values from the database + * + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(): array + { + /** @var Channel $channel */ + $channel = Channel::on($this->db) + ->filter(Filter::equal('id', $this->channelId)) + ->first(); + + if ($channel === null) { + throw new HttpNotFoundException($this->translate('Channel not found')); + } + + return [ + 'name' => $channel->name, + 'type' => $channel->type, + 'config' => json_decode($channel->config, true) ?? [] + ]; + } } diff --git a/application/forms/ContactGroupForm.php b/application/forms/ContactGroupForm.php index 575dfba3..d7d63ed8 100644 --- a/application/forms/ContactGroupForm.php +++ b/application/forms/ContactGroupForm.php @@ -8,10 +8,15 @@ use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Model\Contact; use Icinga\Module\Notifications\Model\Contactgroup; +use Icinga\Module\Notifications\Model\Rotation; +use Icinga\Module\Notifications\Model\RotationMember; +use Icinga\Module\Notifications\Model\RuleEscalation; +use Icinga\Module\Notifications\Model\RuleEscalationRecipient; use Icinga\Web\Session; use ipl\Html\FormElement\SubmitElement; use ipl\Html\HtmlDocument; use ipl\Sql\Connection; +use ipl\Sql\Select; use ipl\Stdlib\Filter; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; @@ -173,7 +178,8 @@ public function addGroup(): int $this->db->beginTransaction(); - $this->db->insert('contactgroup', ['name' => trim($data['group_name'])]); + $changedAt = time() * 1000; + $this->db->insert('contactgroup', ['name' => trim($data['group_name']), 'changed_at' => $changedAt]); $groupIdentifier = $this->db->lastInsertId(); @@ -186,8 +192,9 @@ public function addGroup(): int $this->db->insert( 'contactgroup_member', [ - 'contactgroup_id' => $groupIdentifier, - 'contact_id' => $contactId + 'contactgroup_id' => $groupIdentifier, + 'contact_id' => $contactId, + 'changed_at' => $changedAt ] ); } @@ -200,25 +207,23 @@ public function addGroup(): int /** * Edit the contact group * - * @return bool False if no changes found, true otherwise + * @return void */ - public function editGroup(): bool + public function editGroup(): void { - $isUpdated = false; $values = $this->getValues(); $this->db->beginTransaction(); $storedValues = $this->fetchDbValues(); + $changedAt = time() * 1000; if ($values['group_name'] !== $storedValues['group_name']) { $this->db->update( 'contactgroup', - ['name' => $values['group_name']], + ['name' => $values['group_name'], 'changed_at' => $changedAt], ['id = ?' => $this->contactgroupId] ); - - $isUpdated = true; } $storedContacts = []; @@ -235,34 +240,54 @@ public function editGroup(): bool $toAdd = array_diff($newContacts, $storedContacts); if (! empty($toDelete)) { - $this->db->delete( + $this->db->update( 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'y'], [ - 'contactgroup_id = ?' => $this->contactgroupId, - 'contact_id IN (?)' => $toDelete + 'contactgroup_id = ?' => $this->contactgroupId, + 'contact_id IN (?)' => $toDelete, + 'deleted = ?' => 'n' ] ); - - $isUpdated = true; } if (! empty($toAdd)) { + $contactsMarkedAsDeleted = $this->db->fetchCol( + (new Select()) + ->from('contactgroup_member') + ->columns(['contact_id']) + ->where([ + 'contactgroup_id = ?' => $this->contactgroupId, + 'deleted = ?' => 'y', + 'contact_id IN (?)' => $toAdd + ]) + ); + + $toAdd = array_diff($toAdd, $contactsMarkedAsDeleted); foreach ($toAdd as $contactId) { $this->db->insert( 'contactgroup_member', [ - 'contactgroup_id' => $this->contactgroupId, - 'contact_id' => $contactId + 'contactgroup_id' => $this->contactgroupId, + 'contact_id' => $contactId, + 'changed_at' => $changedAt ] ); } - $isUpdated = true; + if (! empty($contactsMarkedAsDeleted)) { + $this->db->update( + 'contactgroup_member', + ['changed_at' => $changedAt, 'deleted' => 'n'], + [ + 'contactgroup_id = ?' => $this->contactgroupId, + 'contact_id IN (?)' => $contactsMarkedAsDeleted + ] + ); + } } $this->db->commitTransaction(); - - return $isUpdated; } /** @@ -272,8 +297,90 @@ public function removeContactgroup(): void { $this->db->beginTransaction(); - $this->db->delete('contactgroup_member', ['contactgroup_id = ?' => $this->contactgroupId]); - $this->db->delete('contactgroup', ['id = ?' => $this->contactgroupId]); + $markAsDeleted = ['changed_at' => time() * 1000, 'deleted' => 'y']; + $updateCondition = ['contactgroup_id = ?' => $this->contactgroupId, 'deleted = ?' => 'n']; + + $rotationAndMemberIds = $this->db->fetchPairs( + RotationMember::on($this->db) + ->columns(['id', 'rotation_id']) + ->filter(Filter::equal('contactgroup_id', $this->contactgroupId)) + ->assembleSelect() + ); + + $rotationMemberIds = array_keys($rotationAndMemberIds); + $rotationIds = array_values($rotationAndMemberIds); + + $this->db->update('rotation_member', $markAsDeleted + ['position' => null], $updateCondition); + + if (! empty($rotationMemberIds)) { + $this->db->update( + 'timeperiod_entry', + $markAsDeleted, + ['rotation_member_id IN (?)' => $rotationMemberIds, 'deleted = ?' => 'n'] + ); + } + + if (! empty($rotationIds)) { + $rotationIdsWithOtherMembers = $this->db->fetchCol( + RotationMember::on($this->db) + ->columns('rotation_id') + ->filter(Filter::all( + Filter::equal('rotation_id', $rotationIds), + Filter::unequal('contactgroup_id', $this->contactgroupId) + ))->assembleSelect() + ); + + $toRemoveRotations = array_diff($rotationIds, $rotationIdsWithOtherMembers); + + if (! empty($toRemoveRotations)) { + $rotations = Rotation::on($this->db) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) + ->filter(Filter::equal('id', $toRemoveRotations)); + + /** @var Rotation $rotation */ + foreach ($rotations as $rotation) { + $rotation->delete(); + } + } + } + + $escalationIds = $this->db->fetchCol( + RuleEscalationRecipient::on($this->db) + ->columns('rule_escalation_id') + ->filter(Filter::equal('contactgroup_id', $this->contactgroupId)) + ->assembleSelect() + ); + + $this->db->update('rule_escalation_recipient', $markAsDeleted, $updateCondition); + + if (! empty($escalationIds)) { + $escalationIdsWithOtherRecipients = $this->db->fetchCol( + RuleEscalationRecipient::on($this->db) + ->columns('rule_escalation_id') + ->filter(Filter::all( + Filter::equal('rule_escalation_id', $escalationIds), + Filter::unequal('contactgroup_id', $this->contactgroupId) + ))->assembleSelect() + ); + + $toRemoveEscalations = array_diff($escalationIds, $escalationIdsWithOtherRecipients); + + if (! empty($toRemoveEscalations)) { + $this->db->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $toRemoveEscalations] + ); + } + } + + $this->db->update('contactgroup_member', $markAsDeleted, $updateCondition); + + $this->db->update( + 'contactgroup', + $markAsDeleted, + ['id = ?' => $this->contactgroupId, 'deleted = ?' => 'n'] + ); $this->db->commitTransaction(); } @@ -297,8 +404,8 @@ private function fetchDbValues(): array } $groupMembers = []; - foreach ($group->contact->columns('id') as $contact) { - $groupMembers[] = $contact->id; + foreach ($group->contactgroup_member as $contact) { + $groupMembers[] = $contact->contact_id; } return [ diff --git a/application/forms/EventRuleForm.php b/application/forms/EventRuleForm.php index 9ce497ef..2890098e 100644 --- a/application/forms/EventRuleForm.php +++ b/application/forms/EventRuleForm.php @@ -27,15 +27,6 @@ protected function assemble() ] ); - $this->addElement( - 'checkbox', - 'is_active', - [ - 'label' => $this->translate('Event Rule is active'), - 'value' => 'y' - ] - ); - $this->addElement('submit', 'btn_submit', [ 'label' => $this->translate('Save') ]); diff --git a/application/forms/MoveRotationForm.php b/application/forms/MoveRotationForm.php index e79b224b..24b9ae73 100644 --- a/application/forms/MoveRotationForm.php +++ b/application/forms/MoveRotationForm.php @@ -100,8 +100,9 @@ protected function onSuccess() $this->scheduleId = $rotation->schedule_id; + $changedAt = time() * 1000; // Free up the current priority used by the rotation in question - $this->db->update('rotation', ['priority' => 9999], ['id = ?' => $rotationId]); + $this->db->update('rotation', ['priority' => null, 'deleted' => 'y'], ['id = ?' => $rotationId]); // Update the priorities of the rotations that are affected by the move if ($newPriority < $rotation->priority) { @@ -119,7 +120,7 @@ protected function onSuccess() foreach ($affectedRotations as $rotation) { $this->db->update( 'rotation', - ['priority' => new Expression('priority + 1')], + ['priority' => new Expression('priority + 1'), 'changed_at' => $changedAt], ['id = ?' => $rotation->id] ); } @@ -138,14 +139,18 @@ protected function onSuccess() foreach ($affectedRotations as $rotation) { $this->db->update( 'rotation', - ['priority' => new Expression('priority - 1')], + ['priority' => new Expression('priority - 1'), 'changed_at' => $changedAt], ['id = ?' => $rotation->id] ); } } // Now insert the rotation at the new priority - $this->db->update('rotation', ['priority' => $newPriority], ['id = ?' => $rotationId]); + $this->db->update( + 'rotation', + ['priority' => $newPriority, 'changed_at' => $changedAt, 'deleted' => 'n'], + ['id = ?' => $rotationId] + ); if ($transactionStarted) { $this->db->commitTransaction(); diff --git a/application/forms/RotationConfigForm.php b/application/forms/RotationConfigForm.php index 5ef25954..8b1f2fb9 100644 --- a/application/forms/RotationConfigForm.php +++ b/application/forms/RotationConfigForm.php @@ -75,6 +75,9 @@ class RotationConfigForm extends CompatForm /** @var ?DateTime The first handoff of a newer version for this rotation */ protected $nextHandoff; + /** @var int The rotation id */ + protected $rotationId; + /** * Set the label for the submit button * @@ -199,24 +202,7 @@ public function __construct(int $scheduleId, Connection $db) */ 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; - } + $this->rotationId = $rotationId; if (self::EXPERIMENTAL_OVERRIDES) { $getHandoff = function (Rotation $rotation): DateTime { @@ -245,6 +231,14 @@ public function loadRotation(int $rotationId): self return $handoff; }; + /** @var ?Rotation $rotation */ + $rotation = Rotation::on($this->db) + ->filter(Filter::equal('id', $this->rotationId)) + ->first(); + if ($rotation === null) { + throw new HttpNotFoundException($this->translate('Rotation not found')); + } + $this->previousHandoff = $getHandoff($rotation); /** @var ?TimeperiodEntry $previousShift */ @@ -278,18 +272,7 @@ public function loadRotation(int $rotationId): self } } - $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); + $this->populate($this->fetchDbValues()); return $this; } @@ -327,10 +310,12 @@ private function createRotation(int $priority): Generator $data['actual_handoff'] = $firstHandoff->format('U.u') * 1000.0; } + $changedAt = time() * 1000; + $data['changed_at'] = $changedAt; $this->db->insert('rotation', $data); $rotationId = $this->db->lastInsertId(); - $this->db->insert('timeperiod', ['owned_by_rotation_id' => $rotationId]); + $this->db->insert('timeperiod', ['owned_by_rotation_id' => $rotationId, 'changed_at' => $changedAt]); $timeperiodId = $this->db->lastInsertId(); $knownMembers = []; @@ -347,13 +332,15 @@ private function createRotation(int $priority): Generator $this->db->insert('rotation_member', [ 'rotation_id' => $rotationId, 'contact_id' => $id, - 'position' => $position + 'position' => $position, + 'changed_at' => $changedAt ]); } elseif ($type === 'group') { $this->db->insert('rotation_member', [ 'rotation_id' => $rotationId, 'contactgroup_id' => $id, - 'position' => $position + 'position' => $position, + 'changed_at' => $changedAt ]); } @@ -377,6 +364,7 @@ private function createRotation(int $priority): Generator 'until_time' => $untilTime, 'timezone' => $rrule->getStartDate()->getTimezone()->getName(), 'rrule' => $rrule->getString(Rule::TZ_FIXED), + 'changed_at' => $changedAt ]); } } @@ -424,17 +412,24 @@ public function editRotation(int $rotationId): void $transactionStarted = $this->db->beginTransaction(); } + if (! $this->hasChanges()) { + return; + } + // Delay the creation, avoids intermediate constraint failures $createStmt = $this->createRotation((int) $priority); $allEntriesRemoved = true; + $changedAt = time() * 1000; + $markAsDeleted = ['changed_at' => $changedAt, 'deleted' => 'y']; 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 - ]); + $this->db->update( + 'rotation', + ['name' => $this->getValue('name'), 'changed_at' => $changedAt], + ['schedule_id = ?' => $this->scheduleId, 'priority = ?' => $priority] + ); $firstHandoff = $createStmt->current(); $timeperiodEntries = TimeperiodEntry::on($this->db) @@ -458,7 +453,8 @@ public function editRotation(int $rotationId): void '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() + 'timezone' => $gapStart->getTimezone()->getName(), + 'changed_at' => $changedAt ]); } @@ -469,28 +465,44 @@ public function editRotation(int $rotationId): void 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]); + $this->db->update('timeperiod_entry', $markAsDeleted, ['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) + 'until_time' => $lastShiftEnd->format('U.u') * 1000.0, + 'rrule' => $rrule->setUntil($lastHandoff)->getString(Rule::TZ_FIXED), + 'changed_at' => $changedAt ], ['id = ?' => $timeperiodEntry->id]); } } } else { - $this->db->delete('timeperiod_entry', [ - 'timeperiod_id = ?' => (new Select()) - ->from('timeperiod') - ->columns('id') - ->where(['owned_by_rotation_id = ?' => $rotationId]) - ]); + $this->db->update( + 'timeperiod_entry', + $markAsDeleted, + [ + 'deleted = ?' => 'n', + '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]); + $this->db->update('timeperiod', $markAsDeleted, ['owned_by_rotation_id = ?' => $rotationId]); + + $this->db->update( + 'rotation_member', + $markAsDeleted + ['position' => null], + ['rotation_id = ?' => $rotationId, 'deleted = ?' => 'n'] + ); + + $this->db->update( + 'rotation', + $markAsDeleted + ['priority' => null, 'first_handoff' => null], + ['id = ?' => $rotationId] + ); } // Once constraint failures are impossible, create the new version @@ -520,40 +532,13 @@ public function removeRotation(int $id): void $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]); + /** @var Rotation $rotation */ + $rotation = Rotation::on($this->db) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) + ->filter(Filter::equal('id', $id)) + ->first(); - $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] - ); - } - } + $rotation->delete(); if ($transactionStarted) { $this->db->commitTransaction(); @@ -578,39 +563,13 @@ public function wipeRotation(int $priority = null): void } $rotations = Rotation::on($this->db) - ->columns('id') + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.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] - ); + /** @var Rotation $rotation */ + foreach ($rotations as $rotation) { + $rotation->delete(); } if ($transactionStarted) { @@ -1543,4 +1502,70 @@ private function calculateRemainingHandoffs(Rule $rrule, DateInterval $shiftDura return $result; } + + /** + * Fetch the values from the database + * + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(): array + { + /** @var ?Rotation $rotation */ + $rotation = Rotation::on($this->db) + ->filter(Filter::equal('id', $this->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; + } + + $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); + + return $formData; + } + + /** + * Whether the form has changes + * + * @return bool + */ + public function hasChanges(): bool + { + $values = $this->getValues(); + $values['members'] = $this->getValue('members'); + + // only keys that are present in $values + $dbValuesToCompare = array_intersect_key($this->fetchDbValues(), $values); + + $checker = static function ($a, $b) use (&$checker) { + if (! is_array($a) || ! is_array($b)) { + return $a <=> $b; + } + + return empty(array_udiff_assoc($a, $b, $checker)) ? 0 : 1; + }; + + return ! empty(array_udiff_assoc($values, $dbValuesToCompare, $checker)); + } } diff --git a/application/forms/SaveEventRuleForm.php b/application/forms/SaveEventRuleForm.php index f7bd7aa0..f19f25b1 100644 --- a/application/forms/SaveEventRuleForm.php +++ b/application/forms/SaveEventRuleForm.php @@ -5,7 +5,9 @@ namespace Icinga\Module\Notifications\Forms; use Exception; +use Icinga\Exception\Http\HttpNotFoundException; use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Model\Rule; use Icinga\Module\Notifications\Model\RuleEscalation; use Icinga\Module\Notifications\Model\RuleEscalationRecipient; use Icinga\Web\Notification; @@ -44,8 +46,8 @@ class SaveEventRuleForm extends Form /** @var bool Whether to show a button to dismiss cached changes */ protected $showDismissChangesButton = false; - /** @var bool Whether to disable the remove button */ - protected $disableRemoveButton = false; + /** @var int The rule id */ + protected $ruleId; /** * Create a new SaveEventRuleForm @@ -78,20 +80,6 @@ public function setSubmitButtonDisabled(bool $state = true): self return $this; } - /** - * Set whether to enable or disable the remove button - * - * @param bool $state - * - * @return $this - */ - public function setRemoveButtonDisabled(bool $state = true): self - { - $this->disableRemoveButton = $state; - - return $this; - } - /** * Set the submit label * @@ -191,19 +179,6 @@ protected function assemble() ]); $this->registerElement($removeBtn); - $this->getElement('remove') - ->getAttributes() - ->registerAttributeCallback('disabled', function () { - return $this->disableRemoveButton; - }) - ->registerAttributeCallback('title', function () { - if ($this->disableRemoveButton) { - return $this->translate( - 'There exist active incidents for this event rule and hence cannot be deleted' - ); - } - }); - $additionalButtons[] = $removeBtn; } @@ -237,11 +212,12 @@ public function addRule(array $config): int $db->beginTransaction(); + $changedAt = time() * 1000; $db->insert('rule', [ 'name' => $config['name'], 'timeperiod_id' => $config['timeperiod_id'] ?? null, 'object_filter' => $config['object_filter'] ?? null, - 'is_active' => $config['is_active'] ?? 'n' + 'changed_at' => $changedAt ]); $ruleId = $db->lastInsertId(); @@ -251,14 +227,16 @@ public function addRule(array $config): int 'position' => $position, 'condition' => $escalationConfig['condition'] ?? null, 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null + 'fallback_for' => $escalationConfig['fallback_for'] ?? null, + 'changed_at' => $changedAt ]); $escalationId = $db->lastInsertId(); foreach ($escalationConfig['recipient'] ?? [] as $recipientConfig) { $data = [ 'rule_escalation_id' => $escalationId, - 'channel_id' => $recipientConfig['channel_id'] + 'channel_id' => $recipientConfig['channel_id'], + 'changed_at' => $changedAt ]; switch (true) { @@ -294,6 +272,7 @@ public function addRule(array $config): int */ private function insertOrUpdateEscalations($ruleId, array $escalations, Connection $db, bool $insert = false): void { + $changedAt = time() * 1000; foreach ($escalations as $position => $escalationConfig) { if ($insert) { $db->insert('rule_escalation', [ @@ -301,18 +280,19 @@ private function insertOrUpdateEscalations($ruleId, array $escalations, Connecti 'position' => $position, 'condition' => $escalationConfig['condition'] ?? null, 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null + 'fallback_for' => $escalationConfig['fallback_for'] ?? null, + 'changed_at' => $changedAt ]); $escalationId = $db->lastInsertId(); } else { $escalationId = $escalationConfig['id']; - $db->update('rule_escalation', [ 'position' => $position, 'condition' => $escalationConfig['condition'] ?? null, 'name' => $escalationConfig['name'] ?? null, - 'fallback_for' => $escalationConfig['fallback_for'] ?? null + 'fallback_for' => $escalationConfig['fallback_for'] ?? null, + 'changed_at' => $changedAt ], ['id = ?' => $escalationId, 'rule_id = ?' => $ruleId]); $recipientsToRemove = []; @@ -336,14 +316,19 @@ function (array $element) use ($recipientId) { } if (! empty($recipientsToRemove)) { - $db->delete('rule_escalation_recipient', ['id IN (?)' => $recipientsToRemove]); + $db->update( + 'rule_escalation_recipient', + ['changed_at' => $changedAt, 'deleted' => 'y'], + ['id IN (?)' => $recipientsToRemove, 'deleted = ?' => 'n'] + ); } } foreach ($escalationConfig['recipient'] ?? [] as $recipientConfig) { $data = [ 'rule_escalation_id' => $escalationId, - 'channel_id' => $recipientConfig['channel_id'] + 'channel_id' => $recipientConfig['channel_id'], + 'changed_at' => $changedAt ]; switch (true) { @@ -367,7 +352,11 @@ function (array $element) use ($recipientId) { if (! isset($recipientConfig['id'])) { $db->insert('rule_escalation_recipient', $data); } else { - $db->update('rule_escalation_recipient', $data, ['id = ?' => $recipientConfig['id']]); + $db->update( + 'rule_escalation_recipient', + $data + ['changed_at' => $changedAt], + ['id = ?' => $recipientConfig['id']] + ); } } } @@ -383,27 +372,42 @@ function (array $element) use ($recipientId) { */ public function editRule(int $id, array $config): void { + $this->ruleId = $id; + $db = Database::get(); $db->beginTransaction(); - $db->update('rule', [ - 'name' => $config['name'], - 'timeperiod_id' => $config['timeperiod_id'] ?? null, - 'object_filter' => $config['object_filter'] ?? null, - 'is_active' => $config['is_active'] ?? 'n' - ], ['id = ?' => $id]); + $storedValues = $this->fetchDbValues(); + + $values = $this->getChanges($storedValues, $config); + + $data = array_filter([ + 'name' => $values['name'] ?? null + ]); + + if (array_key_exists('object_filter', $values)) { + $data['object_filter'] = $values['object_filter']; + } + + $changedAt = time() * 1000; + if (! empty($data)) { + $db->update('rule', $data + ['changed_at' => $changedAt], ['id = ?' => $id]); + } - $escalationsFromDb = RuleEscalation::on($db) - ->filter(Filter::equal('rule_id', $id)); + if (! isset($values['rule_escalation'])) { + $db->commitTransaction(); + + return; + } $escalationsInCache = $config['rule_escalation']; $escalationsToUpdate = []; $escalationsToRemove = []; - foreach ($escalationsFromDb as $escalationInDB) { - $escalationId = $escalationInDB->id; + foreach ($storedValues['rule_escalation'] as $escalationInDB) { + $escalationId = $escalationInDB['id']; $escalationInCache = array_filter($escalationsInCache, function (array $element) use ($escalationId) { return (int) $element['id'] === $escalationId; }); @@ -422,9 +426,19 @@ public function editRule(int $id, array $config): void // Escalations to add $escalationsToAdd = $escalationsInCache; + $markAsDeleted = ['changed_at' => $changedAt, 'deleted' => 'y']; if (! empty($escalationsToRemove)) { - $db->delete('rule_escalation_recipient', ['rule_escalation_id IN (?)' => $escalationsToRemove]); - $db->delete('rule_escalation', ['id IN (?)' => $escalationsToRemove]); + $db->update( + 'rule_escalation_recipient', + $markAsDeleted, + ['rule_escalation_id IN (?)' => $escalationsToRemove, 'deleted = ?' => 'n'] + ); + + $db->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $escalationsToRemove] + ); } if (! empty($escalationsToAdd)) { @@ -451,21 +465,24 @@ public function removeRule(int $id): void $db->beginTransaction(); - $escalations = RuleEscalation::on($db) - ->columns('id') - ->filter(Filter::equal('rule_id', $id)); - - $escalationsToRemove = []; - foreach ($escalations as $escalation) { - $escalationsToRemove[] = $escalation->id; - } + $escalationsToRemove = $db->fetchCol( + RuleEscalation::on($db) + ->columns('id') + ->filter(Filter::equal('rule_id', $id)) + ->assembleSelect() + ); + $markAsDeleted = ['changed_at' => time() * 1000, 'deleted' => 'y']; if (! empty($escalationsToRemove)) { - $db->delete('rule_escalation_recipient', ['rule_escalation_id IN (?)' => $escalationsToRemove]); + $db->update( + 'rule_escalation_recipient', + $markAsDeleted, + ['rule_escalation_id IN (?)' => $escalationsToRemove, 'deleted = ?' => 'n'] + ); } - $db->delete('rule_escalation', ['rule_id = ?' => $id]); - $db->delete('rule', ['id = ?' => $id]); + $db->update('rule_escalation', $markAsDeleted + ['position' => null], ['rule_id = ?' => $id]); + $db->update('rule', $markAsDeleted, ['id = ?' => $id]); $db->commitTransaction(); } @@ -478,4 +495,85 @@ protected function onError() } } } + + /** + * Fetch the values from the database + * + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(): array + { + $query = Rule::on(Database::get()) + ->columns(['id', 'name', 'object_filter']) + ->filter(Filter::equal('id', $this->ruleId)); + + $rule = $query->first(); + if ($rule === null) { + throw new HttpNotFoundException($this->translate('Rule not found')); + } + + $config = iterator_to_array($rule); + + $ruleEscalations = $rule + ->rule_escalation + ->withoutColumns(['changed_at', 'deleted']); + + foreach ($ruleEscalations as $re) { + foreach ($re as $k => $v) { + $config[$re->getTableName()][$re->position][$k] = $v; + } + + $escalationRecipients = $re + ->rule_escalation_recipient + ->withoutColumns(['changed_at', 'deleted']); + + foreach ($escalationRecipients as $recipient) { + $config[$re->getTableName()][$re->position]['recipient'][] = iterator_to_array($recipient); + } + } + + if (! isset($config['rule_escalation'])) { + $config['rule_escalation'] = []; + } + + $config['showSearchbar'] = ! empty($config['object_filter']); + + return $config; + } + + /** + * Get the newly made changes + * + * @return array + */ + public function getChanges(array $storedValues, array $formValues): array + { + unset($formValues['conditionPlusButtonPosition']); + $dbValuesToCompare = array_intersect_key($storedValues, $formValues); + + if (count($formValues, COUNT_RECURSIVE) < count($dbValuesToCompare, COUNT_RECURSIVE)) { + // fewer values in the form than in the db, escalation(s) has been removed + if ($formValues['name'] === $dbValuesToCompare['name']) { + unset($formValues['name']); + } + + if ($formValues['object_filter'] === $dbValuesToCompare['object_filter']) { + unset($formValues['object_filter']); + } + + return $formValues; + } + + $checker = static function ($a, $b) use (&$checker) { + if (! is_array($a) || ! is_array($b)) { + return $a <=> $b; + } + + return empty(array_udiff_assoc($a, $b, $checker)) ? 0 : 1; + }; + + return array_udiff_assoc($formValues, $dbValuesToCompare, $checker); + } } diff --git a/application/forms/ScheduleForm.php b/application/forms/ScheduleForm.php index d74e0d01..5d9b1f4b 100644 --- a/application/forms/ScheduleForm.php +++ b/application/forms/ScheduleForm.php @@ -5,12 +5,12 @@ namespace Icinga\Module\Notifications\Forms; use Icinga\Exception\Http\HttpNotFoundException; -use Icinga\Module\Notifications\Common\Database; use Icinga\Module\Notifications\Model\Rotation; +use Icinga\Module\Notifications\Model\RuleEscalationRecipient; use Icinga\Module\Notifications\Model\Schedule; -use Icinga\Module\Notifications\Model\Timeperiod; use Icinga\Web\Session; use ipl\Html\HtmlDocument; +use ipl\Sql\Connection; use ipl\Stdlib\Filter; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Compat\CompatForm; @@ -25,6 +25,17 @@ class ScheduleForm extends CompatForm /** @var bool */ protected $showRemoveButton = false; + /** @var Connection */ + private $db; + + /** @var ?int */ + private $scheduleId; + + public function __construct(Connection $db) + { + $this->db = $db; + } + public function setSubmitLabel(string $label): self { $this->submitLabel = $label; @@ -54,57 +65,88 @@ public function hasBeenRemoved(): bool public function loadSchedule(int $id): void { - $db = Database::get(); - - $schedule = Schedule::on($db) - ->filter(Filter::equal('id', $id)) - ->first(); - if ($schedule === null) { - throw new HttpNotFoundException($this->translate('Schedule not found')); - } - - $this->populate(['name' => $schedule->name]); + $this->scheduleId = $id; + $this->populate($this->fetchDbValues()); } public function addSchedule(): int { - $db = Database::get(); - - $db->insert('schedule', [ - 'name' => $this->getValue('name') + $this->db->insert('schedule', [ + 'name' => $this->getValue('name'), + 'changed_at' => time() * 1000 ]); - return $db->lastInsertId(); + return $this->db->lastInsertId(); } public function editSchedule(int $id): void { - $db = Database::get(); + $this->db->beginTransaction(); + + $values = $this->getValues(); + $storedValues = $this->fetchDbValues(); + + if ($values === $storedValues) { + return; + } - $db->update('schedule', [ - 'name' => $this->getValue('name') + $this->db->update('schedule', [ + 'name' => $values['name'], + 'changed_at' => time() * 1000 ], ['id = ?' => $id]); + + $this->db->commitTransaction(); } public function removeSchedule(int $id): void { - $db = Database::get(); - $db->beginTransaction(); + $this->db->beginTransaction(); - $rotations = Rotation::on($db) - ->columns('priority') + $rotations = Rotation::on($this->db) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) ->filter(Filter::equal('schedule_id', $id)) ->orderBy('priority', SORT_DESC); - $rotationConfigForm = new RotationConfigForm($id, $db); - + /** @var Rotation $rotation */ foreach ($rotations as $rotation) { - $rotationConfigForm->wipeRotation($rotation->priority); + $rotation->delete(); } - $db->delete('schedule', ['id = ?' => $id]); + $markAsDeleted = ['changed_at' => time() * 1000, 'deleted' => 'y']; + + $escalationIds = $this->db->fetchCol( + RuleEscalationRecipient::on($this->db) + ->columns('rule_escalation_id') + ->filter(Filter::equal('schedule_id', $id)) + ->assembleSelect() + ); + + $this->db->update('rule_escalation_recipient', $markAsDeleted, ['schedule_id = ?' => $id]); + + if (! empty($escalationIds)) { + $escalationIdsWithOtherRecipients = $this->db->fetchCol( + RuleEscalationRecipient::on($this->db) + ->columns('rule_escalation_id') + ->filter(Filter::all( + Filter::equal('rule_escalation_id', $escalationIds), + Filter::unequal('schedule_id', $id) + ))->assembleSelect() + ); + + $toRemoveEscalations = array_diff($escalationIds, $escalationIdsWithOtherRecipients); + + if (! empty($toRemoveEscalations)) { + $this->db->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $toRemoveEscalations] + ); + } + } - $db->commitTransaction(); + $this->db->update('schedule', $markAsDeleted, ['id = ?' => $id]); + + $this->db->commitTransaction(); } protected function assemble() @@ -131,4 +173,26 @@ protected function assemble() $this->addElement($this->createCsrfCounterMeasure(Session::getSession()->getId())); } + + /** + * Fetch the values from the database + * + * @return string[] + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(): array + { + /** @var ?Schedule $schedule */ + $schedule = Schedule::on($this->db) + ->columns('name') + ->filter(Filter::equal('id', $this->scheduleId)) + ->first(); + + if ($schedule === null) { + throw new HttpNotFoundException($this->translate('Schedule not found')); + } + + return ['name' => $schedule->name]; + } } diff --git a/application/forms/SourceForm.php b/application/forms/SourceForm.php index d83f5564..ca8f9ee0 100644 --- a/application/forms/SourceForm.php +++ b/application/forms/SourceForm.php @@ -4,12 +4,14 @@ namespace Icinga\Module\Notifications\Forms; +use Icinga\Exception\Http\HttpNotFoundException; use Icinga\Module\Notifications\Model\Source; use Icinga\Web\Session; use ipl\Html\BaseHtmlElement; use ipl\Html\Contract\FormSubmitElement; use ipl\Html\FormElement\CheckboxElement; use ipl\Sql\Connection; +use ipl\Stdlib\Filter; use ipl\Validator\CallbackValidator; use ipl\Validator\X509CertValidator; use ipl\Web\Common\CsrfCounterMeasure; @@ -28,10 +30,9 @@ class SourceForm extends CompatForm /** @var ?int */ private $sourceId; - public function __construct(Connection $db, ?int $sourceId = null) + public function __construct(Connection $db) { $this->db = $db; - $this->sourceId = $sourceId; } protected function assemble(): void @@ -263,38 +264,56 @@ public function hasBeenSubmitted() return parent::hasBeenSubmitted(); } - /** @param iterable|Source $values */ - public function populate($values) + /** + * Load the source with given id + * + * @param int $id + * + * @return $this + */ + public function loadSource(int $id): self { - if ($values instanceof Source) { - $values = [ - 'name' => $values->name, - 'type' => $values->type, - 'icinga2_base_url' => $values->icinga2_base_url, - 'icinga2_auth_user' => $values->icinga2_auth_user, - 'icinga2_auth_pass' => $values->icinga2_auth_pass, - 'icinga2_ca_pem' => $values->icinga2_ca_pem, - 'icinga2_common_name' => $values->icinga2_common_name, - 'icinga2_insecure_tls' => $values->icinga2_insecure_tls - ]; - } + $this->sourceId = $id; - parent::populate($values); + $this->populate($this->fetchDbValues()); return $this; } - protected function onSuccess(): void + /** + * Add the new source + */ + public function addSource(): void { - $pressedButton = $this->getPressedSubmitElement(); - if ($pressedButton && $pressedButton->getName() === 'delete') { - $this->db->delete('source', ['id = ?' => $this->sourceId]); + $source = $this->getValues(); - return; - } + // Not using PASSWORD_DEFAULT, as the used algorithm should + // be kept in sync with what the daemon understands + $source['listener_password_hash'] = password_hash( + $this->getValue('listener_password'), + self::HASH_ALGORITHM + ); + + $source['changed_at'] = time() * 1000; + + $this->db->insert('source', $source); + } + + /** + * Edit the source + * + * @return void + */ + public function editSource(): void + { + $this->db->beginTransaction(); $source = $this->getValues(); + if (empty(array_diff_assoc($source, $this->fetchDbValues()))) { + return; + } + /** @var ?string $listenerPassword */ $listenerPassword = $this->getValue('listener_password'); if ($listenerPassword) { @@ -303,10 +322,61 @@ protected function onSuccess(): void $source['listener_password_hash'] = password_hash($listenerPassword, self::HASH_ALGORITHM); } - if ($this->sourceId === null) { - $this->db->insert('source', $source); - } else { - $this->db->update('source', $source, ['id = ?' => $this->sourceId]); + $source['changed_at'] = time() * 1000; + $this->db->update('source', $source, ['id = ?' => $this->sourceId]); + + $this->db->commitTransaction(); + } + + /** + * Remove the source + */ + public function removeSource(): void + { + $this->db->update( + 'source', + ['changed_at' => time() * 1000, 'deleted' => 'y'], + ['id = ?' => $this->sourceId] + ); + } + + /** + * Get the source name + * + * @return string + */ + public function getSourceName(): string + { + return $this->getValue('name'); + } + + /** + * Fetch the values from the database + * + * @return array + * + * @throws HttpNotFoundException + */ + private function fetchDbValues(): array + { + /** @var ?Source $source */ + $source = Source::on($this->db) + ->filter(Filter::equal('id', $this->sourceId)) + ->first(); + + if ($source === null) { + throw new HttpNotFoundException($this->translate('Source not found')); } + + return [ + 'name' => $source->name, + 'type' => $source->type, + 'icinga2_base_url' => $source->icinga2_base_url, + 'icinga2_auth_user' => $source->icinga2_auth_user, + 'icinga2_auth_pass' => $source->icinga2_auth_pass, + 'icinga2_ca_pem' => $source->icinga2_ca_pem, + 'icinga2_common_name' => $source->icinga2_common_name, + 'icinga2_insecure_tls' => $source->icinga2_insecure_tls + ]; } } diff --git a/library/Notifications/Common/Database.php b/library/Notifications/Common/Database.php index a067d7a7..abfefa50 100644 --- a/library/Notifications/Common/Database.php +++ b/library/Notifications/Common/Database.php @@ -18,6 +18,28 @@ final class Database { + /** + * @var string[] Tables with a deleted flag + * + * The filter `deleted=n` is automatically added to these tables. + */ + private const TABLES_WITH_DELETED_FLAG = [ + 'channel', + 'contact', + 'contact_address', + 'contactgroup', + 'contactgroup_member', + 'rotation', + 'rotation_member', + 'rule', + 'rule_escalation', + 'rule_escalation_recipient', + 'schedule', + 'source', + 'timeperiod', + 'timeperiod_entry', + ]; + /** @var Connection Database connection */ private static $instance; @@ -104,6 +126,30 @@ private static function getConnection(): Connection }); } + $db->getQueryBuilder() + ->on(QueryBuilder::ON_ASSEMBLE_SELECT, function (Select $select) { + $from = $select->getFrom(); + $baseTableName = reset($from); + + if (! in_array($baseTableName, self::TABLES_WITH_DELETED_FLAG, true)) { + return; + } + + $baseTableAlias = key($from); + if (! is_string($baseTableAlias)) { + $baseTableAlias = $baseTableName; + } + + $condition = 'deleted = ?'; + $where = $select->getWhere(); + + if ($where && self::hasCondition($baseTableAlias, $condition, $where)) { + return; + } + + $select->where([$baseTableAlias . '.' . $condition => 'n']); + }); + return $db; } @@ -144,4 +190,31 @@ public static function registerGroupBy(Query $query, Select $select): void $select->groupBy($groupBy); } + + /** + * Check if the given condition is part of the where clause with value 'y' + * + * @param string $conditionToFind + * @param array $where + * + * @return bool + */ + private static function hasCondition(string $baseTable, string $conditionToFind, array $where): bool + { + foreach ($where as $condition => $value) { + if (is_array($value)) { + $found = self::hasCondition($baseTable, $conditionToFind, $value); + } else { + $found = ( + $condition === $conditionToFind || $condition === $baseTable . '.' . $conditionToFind + ) && $value === 'y'; + } + + if ($found) { + return true; + } + } + + return false; + } } diff --git a/library/Notifications/Model/AvailableChannelType.php b/library/Notifications/Model/AvailableChannelType.php index 84fa77df..0cec05cb 100644 --- a/library/Notifications/Model/AvailableChannelType.php +++ b/library/Notifications/Model/AvailableChannelType.php @@ -5,8 +5,18 @@ namespace Icinga\Module\Notifications\Model; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; +/** + * @property string $type + * @property string $name + * @property string $version + * @property string $author + * @property string $config_attrs + * + * @property Query|Channel $channel + */ class AvailableChannelType extends Model { public function getTableName(): string @@ -14,7 +24,7 @@ public function getTableName(): string return 'available_channel_type'; } - public function getKeyName() + public function getKeyName(): string { return 'type'; } @@ -24,7 +34,7 @@ public function getColumns(): array return [ 'name', 'version', - 'author_name', + 'author', 'config_attrs', ]; } diff --git a/library/Notifications/Model/Channel.php b/library/Notifications/Model/Channel.php index e857e277..cc4f8103 100644 --- a/library/Notifications/Model/Channel.php +++ b/library/Notifications/Model/Channel.php @@ -4,11 +4,29 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; use ipl\Sql\Connection; use ipl\Web\Widget\Icon; +/** + * @property int $id + * @property string $name + * @property string $type + * @property ?string $config + * @property DateTime $changed_at + * @property bool $deleted + * + * @property Query|IncidentHistory $incident_history + * @property Query|RuleEscalationRecipient $rule_escalation_recipient + * @property Query|Contact $contact + * @property Query|AvailableChannelType $available_channel_type + */ class Channel extends Model { public function getTableName(): string @@ -26,30 +44,39 @@ public function getColumns(): array return [ 'name', 'type', - 'config' + 'config', + 'changed_at', + 'deleted' ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ - 'name' => t('Name'), - 'type' => t('Type'), + 'name' => t('Name'), + 'type' => t('Type'), + 'changed_at' => t('Changed At') ]; } - public function getSearchColumns() + public function getSearchColumns(): array { return ['name']; } - public function getDefaultSort() + public function getDefaultSort(): array { return ['name']; } - public function createRelations(Relations $relations) + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->hasMany('incident_history', IncidentHistory::class)->setJoinType('LEFT'); $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class)->setJoinType('LEFT'); @@ -91,9 +118,9 @@ public function getIcon(): Icon public static function fetchChannelNames(Connection $conn): array { $channels = []; + $query = Channel::on($conn); /** @var Channel $channel */ - foreach (Channel::on($conn) as $channel) { - /** @var string $name */ + foreach ($query as $channel) { $name = $channel->name; $channels[$channel->id] = $name; } diff --git a/library/Notifications/Model/Contact.php b/library/Notifications/Model/Contact.php index 539e1728..10f17b01 100644 --- a/library/Notifications/Model/Contact.php +++ b/library/Notifications/Model/Contact.php @@ -4,13 +4,32 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; use Icinga\Module\Notifications\Model\Behavior\HasAddress; +use ipl\Orm\Behavior\MillisecondTimestamp; use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; /** * @property int $id + * @property string $full_name + * @property ?string $username + * @property int $default_channel_id + * @property DateTime $changed_at + * @property bool $deleted + * + * @property Query|Channel $channel + * @property Query|Incident $incident + * @property Query|IncidentContact $incident_contact + * @property Query|IncidentHistory $incident_history + * @property Query|RotationMember $rotation_member + * @property Query|ContactAddress $contact_address + * @property Query|RuleEscalationRecipient $rule_escalation_recipient + * @property Query|ContactgroupMember $contactgroup_member + * @property Query|Contactgroup $contactgroup */ class Contact extends Model { @@ -29,34 +48,39 @@ public function getColumns(): array return [ 'full_name', 'username', - 'default_channel_id' + 'default_channel_id', + 'changed_at', + 'deleted' ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ - 'full_name' => t('Full Name'), - 'username' => t('Username') + 'full_name' => t('Full Name'), + 'username' => t('Username'), + 'changed_at' => t('Changed At') ]; } - public function getSearchColumns() + public function getSearchColumns(): array { return ['full_name']; } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new HasAddress()); + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); } - public function getDefaultSort() + public function getDefaultSort(): array { return ['full_name']; } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('channel', Channel::class) ->setCandidateKey('default_channel_id'); @@ -73,6 +97,8 @@ public function createRelations(Relations $relations) $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) ->setJoinType('LEFT'); + $relations->hasMany('contactgroup_member', ContactgroupMember::class); + $relations->belongsToMany('contactgroup', Contactgroup::class) ->through('contactgroup_member') ->setJoinType('LEFT'); diff --git a/library/Notifications/Model/ContactAddress.php b/library/Notifications/Model/ContactAddress.php index 14ebd705..712a2afd 100644 --- a/library/Notifications/Model/ContactAddress.php +++ b/library/Notifications/Model/ContactAddress.php @@ -4,9 +4,24 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; +/** + * @property int $id + * @property int $contact_id + * @property string $type + * @property string $address + * @property DateTime $changed_at + * @property bool $deleted + * + * @property Query|Contact $contact + */ class ContactAddress extends Model { public function getTableName(): string @@ -24,11 +39,19 @@ public function getColumns(): array return [ 'contact_id', 'type', - 'address' + 'address', + 'changed_at', + 'deleted' ]; } - public function createRelations(Relations $relations) + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->belongsTo('contact', Contact::class); } diff --git a/library/Notifications/Model/Contactgroup.php b/library/Notifications/Model/Contactgroup.php index aac6ab6d..3dc79481 100644 --- a/library/Notifications/Model/Contactgroup.php +++ b/library/Notifications/Model/Contactgroup.php @@ -4,6 +4,10 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; use ipl\Orm\Query; use ipl\Orm\Relations; @@ -11,12 +15,15 @@ /** * Contact group * - * @param int $id - * @param string $name + * @property int $id + * @property string $name + * @property DateTime $changed_at + * @property bool $deleted * - * @property Query | Contact $contact - * @property Query | RuleEscalationRecipient $rule_escalation_recipient - * @property Query | IncidentHistory $incident_history + * @property Query|Contact $contact + * @property Query|ContactgroupMember $contactgroup_member + * @property Query|RuleEscalationRecipient $rule_escalation_recipient + * @property Query|IncidentHistory $incident_history */ class Contactgroup extends Model { @@ -33,7 +40,9 @@ public function getKeyName(): string public function getColumns(): array { return [ - 'name' + 'name', + 'changed_at', + 'deleted' ]; } @@ -47,11 +56,18 @@ public function getSearchColumns(): array return ['name']; } + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + public function createRelations(Relations $relations): void { $relations->hasMany('rule_escalation_recipient', RuleEscalationRecipient::class) ->setJoinType('LEFT'); $relations->hasMany('incident_history', IncidentHistory::class); + $relations->hasMany('contactgroup_member', ContactgroupMember::class); $relations ->belongsToMany('contact', Contact::class) ->through('contactgroup_member') diff --git a/library/Notifications/Model/ContactgroupMember.php b/library/Notifications/Model/ContactgroupMember.php new file mode 100644 index 00000000..97741c8c --- /dev/null +++ b/library/Notifications/Model/ContactgroupMember.php @@ -0,0 +1,59 @@ +add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void + { + $relations->belongsTo('contactgroup', Contactgroup::class); + $relations->belongsTo('contact', Contact::class); + } +} diff --git a/library/Notifications/Model/Event.php b/library/Notifications/Model/Event.php index 56aeacb4..6aea53bc 100644 --- a/library/Notifications/Model/Event.php +++ b/library/Notifications/Model/Event.php @@ -31,23 +31,23 @@ * @property ?bool $mute * @property ?string $mute_reason * - * @property Query | Objects $object - * @property Query | IncidentHistory $incident_history - * @property Query | Incident $incident + * @property Query|Objects $object + * @property Query|IncidentHistory $incident_history + * @property Query|Incident $incident */ class Event extends Model { - public function getTableName() + public function getTableName(): string { return 'event'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'time', @@ -61,7 +61,7 @@ public function getColumns() ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ 'time' => t('Received On'), @@ -75,17 +75,17 @@ public function getColumnDefinitions() ]; } - public function getSearchColumns() + public function getSearchColumns(): array { return ['object.name']; } - public function getDefaultSort() + public function getDefaultSort(): string { return 'event.time'; } - public static function on(Connection $db) + public static function on(Connection $db): Query { $query = parent::on($db); @@ -98,14 +98,14 @@ public static function on(Connection $db) return $query; } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new MillisecondTimestamp(['time'])); $behaviors->add(new Binary(['object_id'])); $behaviors->add(new BoolCast(['mute'])); } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('object', Objects::class)->setJoinType('LEFT'); diff --git a/library/Notifications/Model/Incident.php b/library/Notifications/Model/Incident.php index 408786b2..4f86e1d7 100644 --- a/library/Notifications/Model/Incident.php +++ b/library/Notifications/Model/Incident.php @@ -24,27 +24,27 @@ * @property ?DateTime $recovered_at * @property string $severity * - * @property Query | Objects $object - * @property Query | Event $event - * @property Query | Contact $contact - * @property Query | IncidentContact $incident_contact - * @property Query | IncidentHistory $incident_history - * @property Query | Rule $rule - * @property Query | RuleEscalation $rule_escalation + * @property Query|Objects $object + * @property Query|Event $event + * @property Query|Contact $contact + * @property Query|IncidentContact $incident_contact + * @property Query|IncidentHistory $incident_history + * @property Query|Rule $rule + * @property Query|RuleEscalation $rule_escalation */ class Incident extends Model { - public function getTableName() + public function getTableName(): string { return 'incident'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'object_id', @@ -54,7 +54,7 @@ public function getColumns() ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ 'object_id' => t('Object Id'), @@ -64,17 +64,17 @@ public function getColumnDefinitions() ]; } - public function getSearchColumns() + public function getSearchColumns(): array { return ['object.name']; } - public function getDefaultSort() + public function getDefaultSort(): array { return ['incident.severity desc, incident.started_at']; } - public static function on(Connection $db) + public static function on(Connection $db): Query { $query = parent::on($db); @@ -87,7 +87,7 @@ public static function on(Connection $db) return $query; } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new Binary(['object_id'])); $behaviors->add(new MillisecondTimestamp([ @@ -96,7 +96,7 @@ public function createBehaviors(Behaviors $behaviors) ])); } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('object', Objects::class); diff --git a/library/Notifications/Model/IncidentContact.php b/library/Notifications/Model/IncidentContact.php index 22b63ca4..c1dfc35f 100644 --- a/library/Notifications/Model/IncidentContact.php +++ b/library/Notifications/Model/IncidentContact.php @@ -5,21 +5,30 @@ namespace Icinga\Module\Notifications\Model; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; +/** + * @property int $incident_id + * @property ?int $contact_id + * @property string $role + * + * @property Query|Incident $incident + * @property Query|Contact $contact + */ class IncidentContact extends Model { - public function getTableName() + public function getTableName(): string { return 'incident_contact'; } - public function getKeyName() + public function getKeyName(): array { return ['incident_id', 'contact_id']; } - public function getColumns() + public function getColumns(): array { return [ 'incident_id', @@ -28,7 +37,7 @@ public function getColumns() ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ 'incident_id' => t('Incident Id'), @@ -37,7 +46,7 @@ public function getColumnDefinitions() ]; } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('incident', Incident::class); $relations->belongsTo('contact', Contact::class); diff --git a/library/Notifications/Model/IncidentHistory.php b/library/Notifications/Model/IncidentHistory.php index 320eb7cd..c8bcec66 100644 --- a/library/Notifications/Model/IncidentHistory.php +++ b/library/Notifications/Model/IncidentHistory.php @@ -25,37 +25,39 @@ * @property DateTime $time * @property string $type * @property ?int $contact_id + * @property ?int $schedule_id + * @property ?int $contactgroup_id * @property ?int $channel_id * @property ?string $new_severity * @property ?string $old_severity * @property ?string $new_recipient_role * @property ?string $old_recipient_role * @property ?string $message - * @property string $notification_state - * @property DateTime $sent_at + * @property ?string $notification_state + * @property ?DateTime $sent_at * - * @property Query | Incident $incident - * @property Query | Event $event - * @property Query | Contact $contact - * @property Query | Contactgroup $contactgroup - * @property Query | Schedule $schedule - * @property Query | Rule $rule - * @property Query | RuleEscalation $rule_escalation - * @property Query | Channel $channel + * @property Query|Incident $incident + * @property Query|Event $event + * @property Query|Contact $contact + * @property Query|Contactgroup $contactgroup + * @property Query|Schedule $schedule + * @property Query|Rule $rule + * @property Query|RuleEscalation $rule_escalation + * @property Query|Channel $channel */ class IncidentHistory extends Model { - public function getTableName() + public function getTableName(): string { return 'incident_history'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'incident_id', @@ -78,7 +80,7 @@ public function getColumns() ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ 'incident_id' => t('Incident Id'), @@ -98,17 +100,17 @@ public function getColumnDefinitions() ]; } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new MillisecondTimestamp(['time', 'sent_at'])); } - public function getDefaultSort() + public function getDefaultSort(): array { return ['incident_history.time desc, incident_history.type desc']; } - public static function on(Connection $db) + public static function on(Connection $db): Query { $query = parent::on($db); @@ -121,7 +123,7 @@ public static function on(Connection $db) return $query; } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('incident', Incident::class); diff --git a/library/Notifications/Model/ObjectExtraTag.php b/library/Notifications/Model/ObjectExtraTag.php index fc172ab6..df7beb73 100644 --- a/library/Notifications/Model/ObjectExtraTag.php +++ b/library/Notifications/Model/ObjectExtraTag.php @@ -7,28 +7,31 @@ use ipl\Orm\Behavior\Binary; use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; /** * ObjectExtraTag database model * - * @property int $object_id + * @property string $object_id * @property string $tag * @property string $value + * + * @property Query|Objects $object */ class ObjectExtraTag extends Model { - public function getTableName() + public function getTableName(): string { return 'object_extra_tag'; } - public function getKeyName() + public function getKeyName(): array { return ['object_id', 'tag']; } - public function getColumns() + public function getColumns(): array { return [ 'object_id', @@ -37,12 +40,12 @@ public function getColumns() ]; } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new Binary(['object_id'])); } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('object', Objects::class); } diff --git a/library/Notifications/Model/ObjectIdTag.php b/library/Notifications/Model/ObjectIdTag.php index b0d33621..8a26ea79 100644 --- a/library/Notifications/Model/ObjectIdTag.php +++ b/library/Notifications/Model/ObjectIdTag.php @@ -12,7 +12,7 @@ /** * ObjectIdTag database model * - * @property int $object_id + * @property string $object_id * @property string $tag * @property string $value */ @@ -23,7 +23,7 @@ public function getTableName(): string return 'object_id_tag'; } - public function getKeyName() + public function getKeyName(): array { return ['object_id', 'tag']; } diff --git a/library/Notifications/Model/Objects.php b/library/Notifications/Model/Objects.php index 68d43e3b..c6210565 100644 --- a/library/Notifications/Model/Objects.php +++ b/library/Notifications/Model/Objects.php @@ -19,32 +19,30 @@ * @property string $id * @property int $source_id * @property string $name - * @property string $host - * @property ?string $service * @property ?string $url * @property ?string $mute_reason * - * @property Query | Event $event - * @property Query | Incident $incident - * @property Query | Tag $tag - * @property Query | ObjectExtraTag $object_extra_tag - * @property Query | ExtraTag $extra_tag - * @property Query | Source $source + * @property Query|Event $event + * @property Query|Incident $incident + * @property Query|Tag $tag + * @property Query|ObjectExtraTag $object_extra_tag + * @property Query|ExtraTag $extra_tag + * @property Query|Source $source * @property array $id_tags */ class Objects extends Model { - public function getTableName() + public function getTableName(): string { return 'object'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'source_id', @@ -57,7 +55,7 @@ public function getColumns() /** * @return string[] */ - public function getSearchColumns() + public function getSearchColumns(): array { return ['object_id_tag.tag', 'object_id_tag.value']; } @@ -65,18 +63,18 @@ public function getSearchColumns() /** * @return string */ - public function getDefaultSort() + public function getDefaultSort(): string { return 'object.name'; } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new Binary(['id'])); $behaviors->add(new IdTagAggregator()); } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->hasMany('event', Event::class); $relations->hasMany('incident', Incident::class); diff --git a/library/Notifications/Model/Rotation.php b/library/Notifications/Model/Rotation.php index 9c0c4d82..46a3901b 100644 --- a/library/Notifications/Model/Rotation.php +++ b/library/Notifications/Model/Rotation.php @@ -5,25 +5,32 @@ namespace Icinga\Module\Notifications\Model; use DateTime; +use Icinga\Module\Notifications\Common\Database; +use Icinga\Module\Notifications\Forms\RotationConfigForm; use Icinga\Util\Json; +use ipl\Orm\Behavior\BoolCast; use ipl\Orm\Behavior\MillisecondTimestamp; use ipl\Orm\Behaviors; use ipl\Orm\Contract\RetrieveBehavior; use ipl\Orm\Model; use ipl\Orm\Query; use ipl\Orm\Relations; +use ipl\Sql\Expression; +use ipl\Stdlib\Filter; /** * Rotation * * @property int $id * @property int $schedule_id - * @property int $priority + * @property ?int $priority * @property string $name * @property string $mode * @property string|array $options * @property string $first_handoff * @property DateTime $actual_handoff + * @property DateTime $changed_at + * @property bool $deleted * * @property Query|Schedule $schedule * @property Query|RotationMember $member @@ -31,17 +38,17 @@ */ class Rotation extends Model { - public function getTableName() + public function getTableName(): string { return 'rotation'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'schedule_id', @@ -50,23 +57,26 @@ public function getColumns() 'mode', 'options', 'first_handoff', - 'actual_handoff' + 'actual_handoff', + 'changed_at', + 'deleted' ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ - 'schedule_id' => t('Schedule'), - 'priority' => t('Priority'), - 'name' => t('Name'), - 'mode' => t('Mode'), - 'first_handoff' => t('First Handoff'), - 'actual_handoff' => t('Actual Handoff') + 'schedule_id' => t('Schedule'), + 'priority' => t('Priority'), + 'name' => t('Name'), + 'mode' => t('Mode'), + 'first_handoff' => t('First Handoff'), + 'actual_handoff' => t('Actual Handoff'), + 'changed_at' => t('Changed At') ]; } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('schedule', Schedule::class); @@ -76,11 +86,13 @@ public function createRelations(Relations $relations) ->setForeignKey('owned_by_rotation_id'); } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new MillisecondTimestamp([ - 'actual_handoff' + 'actual_handoff', + 'changed_at' ])); + $behaviors->add(new BoolCast(['deleted'])); $behaviors->add(new class implements RetrieveBehavior { public function retrieve(Model $model): void { @@ -91,4 +103,74 @@ public function retrieve(Model $model): void } }); } + + /** + * Delete rotation and all related references + * + * @return void + */ + public function delete(): void + { + $db = Database::get(); + $transactionStarted = false; + if (! $db->inTransaction()) { + $transactionStarted = true; + $db->beginTransaction(); + } + + if ($this->timeperiod instanceof Timeperiod) { + $timeperiodId = $this->timeperiod->id; + } else { + $timeperiodId = $this->timeperiod->columns('id')->first()->id; + } + + $changedAt = time() * 1000; + $markAsDeleted = ['changed_at' => $changedAt, 'deleted' => 'y']; + + $db->update('timeperiod_entry', $markAsDeleted, ['timeperiod_id = ?' => $timeperiodId, 'deleted = ?' => 'n']); + $db->update('timeperiod', $markAsDeleted, ['id = ?' => $timeperiodId]); + + $db->update( + 'rotation_member', + $markAsDeleted + ['position' => null], + ['rotation_id = ?' => $this->id, 'deleted = ?' => 'n'] + ); + + $db->update( + 'rotation', + $markAsDeleted + ['priority' => null, 'first_handoff' => null], + ['id = ?' => $this->id] + ); + + $requirePriorityUpdate = true; + if (RotationConfigForm::EXPERIMENTAL_OVERRIDES) { + $rotations = self::on($db) + ->columns('1') + ->filter(Filter::equal('schedule_id', $this->schedule_id)) + ->filter(Filter::equal('priority', $this->priority)) + ->first(); + + $requirePriorityUpdate = $rotations === null; + } + + if ($requirePriorityUpdate) { + $affectedRotations = self::on($db) + ->columns('id') + ->filter(Filter::equal('schedule_id', $this->schedule_id)) + ->filter(Filter::greaterThan('priority', $this->priority)) + ->orderBy('priority', SORT_ASC); + + foreach ($affectedRotations as $rotation) { + $db->update( + 'rotation', + ['priority' => new Expression('priority - 1'), 'changed_at' => $changedAt], + ['id = ?' => $rotation->id] + ); + } + } + + if ($transactionStarted) { + $db->commitTransaction(); + } + } } diff --git a/library/Notifications/Model/RotationMember.php b/library/Notifications/Model/RotationMember.php index 5f5212a5..69573b35 100644 --- a/library/Notifications/Model/RotationMember.php +++ b/library/Notifications/Model/RotationMember.php @@ -4,6 +4,10 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; use ipl\Orm\Query; use ipl\Orm\Relations; @@ -15,7 +19,9 @@ * @property int $rotation_id * @property ?int $contact_id * @property ?int $contactgroup_id - * @property int $position + * @property ?int $position + * @property DateTime $changed_at + * @property bool $deleted * * @property Query|Rotation $rotation * @property Query|Contact $contact @@ -24,27 +30,35 @@ */ class RotationMember extends Model { - public function getTableName() + public function getTableName(): string { return 'rotation_member'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'rotation_id', 'contact_id', 'contactgroup_id', - 'position' + 'position', + 'changed_at', + 'deleted' ]; } - public function createRelations(Relations $relations) + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->belongsTo('rotation', Rotation::class); $relations->belongsTo('contact', Contact::class) diff --git a/library/Notifications/Model/Rule.php b/library/Notifications/Model/Rule.php index 158b2228..8e69a014 100644 --- a/library/Notifications/Model/Rule.php +++ b/library/Notifications/Model/Rule.php @@ -4,52 +4,76 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; +/** + * @property int $id + * @property string $name + * @property ?int $timeperiod_id + * @property ?string $object_filter + * @property DateTime $changed_at + * @property bool $deleted + * + * @property Query|RuleEscalation $rule_escalation + * @property Query|Incident $incident + * @property Query|IncidentHistory $incident_history + */ class Rule extends Model { - public function getTableName() + public function getTableName(): string { return 'rule'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'name', 'timeperiod_id', 'object_filter', - 'is_active' + 'changed_at', + 'deleted' ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ 'name' => t('Name'), 'timeperiod_id' => t('Timeperiod ID'), 'object_filter' => t('Object Filter'), - 'is_active' => t('Is Active') + 'changed_at' => t('Changed At') ]; } - public function getSearchColumns() + public function getSearchColumns(): array { return ['name']; } - public function getDefaultSort() + public function getDefaultSort(): array { return ['name']; } - public function createRelations(Relations $relations) + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->hasMany('rule_escalation', RuleEscalation::class); diff --git a/library/Notifications/Model/RuleEscalation.php b/library/Notifications/Model/RuleEscalation.php index 78064eb6..5276308e 100644 --- a/library/Notifications/Model/RuleEscalation.php +++ b/library/Notifications/Model/RuleEscalation.php @@ -4,54 +4,85 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; +/** + * @property int $id + * @property int $rule_id + * @property ?int $position + * @property ?string $condition + * @property ?string $name + * @property ?string $fallback_for + * @property DateTime $changed_at + * @property bool $deleted + * + * @property Query|Rule $rule + * @property Query|Incident $incident + * @property Query|Contact $contact + * @property Query|RuleEscalationRecipient $rule_escalation_recipient + * @property Query|IncidentHistory $incident_history + */ class RuleEscalation extends Model { - public function getTableName() + public function getTableName(): string { return 'rule_escalation'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'rule_id', 'position', 'condition', 'name', - 'fallback_for' + 'fallback_for', + 'changed_at', + 'deleted' ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ 'rule_id' => t('Rule ID'), 'position' => t('Position'), 'condition' => t('Condition'), 'name' => t('Name'), - 'fallback_for' => t('Fallback For') + 'fallback_for' => t('Fallback For'), + 'changed_at' => t('Changed At') ]; } - public function getSearchColumns() + public function getSearchColumns(): array { return ['name']; } - public function getDefaultSort() + public function getDefaultSort(): array { return ['position']; } - public function createRelations(Relations $relations) + + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->belongsTo('rule', Rule::class); diff --git a/library/Notifications/Model/RuleEscalationRecipient.php b/library/Notifications/Model/RuleEscalationRecipient.php index a1f4bdcb..a3cc2660 100644 --- a/library/Notifications/Model/RuleEscalationRecipient.php +++ b/library/Notifications/Model/RuleEscalationRecipient.php @@ -4,49 +4,80 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; +/** + * @property int $id + * @property int $rule_escalation_id + * @property ?int $contact_id + * @property ?int $contactgroup_id + * @property ?int $schedule_id + * @property ?int $channel_id + * @property DateTime $changed_at + * @property bool $deleted + * + * @property Query|RuleEscalation $rule_escalation + * @property Query|Contact $contact + * @property Query|Schedule $schedule + * @property Query|Contactgroup $contactgroup + * @property Query|Channel $channel + */ + class RuleEscalationRecipient extends Model { - public function getTableName() + public function getTableName(): string { return 'rule_escalation_recipient'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'rule_escalation_id', 'contact_id', 'contactgroup_id', 'schedule_id', - 'channel_id' + 'channel_id', + 'changed_at', + 'deleted' ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ 'rule_escalation_id' => t('Rule Escalation ID'), 'contact_id' => t('Contact ID'), 'contactgroup_id' => t('Contactgroup ID'), 'schedule_id' => t('Schedule ID'), - 'channel_id' => t('Channel ID') + 'channel_id' => t('Channel ID'), + 'changed_at' => t('Changed At') ]; } - public function getDefaultSort() + public function getDefaultSort(): array { return ['rule_escalation_id']; } - public function createRelations(Relations $relations) + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->belongsTo('rule_escalation', RuleEscalation::class); $relations->belongsTo('contact', Contact::class); @@ -58,9 +89,9 @@ public function createRelations(Relations $relations) /** * Get the recipient model * - * @return ?Model + * @return Contact|Contactgroup|Schedule|null */ - public function getRecipient() + public function getRecipient(): ?Model { $recipientModel = null; if ($this->contact_id) { diff --git a/library/Notifications/Model/Schedule.php b/library/Notifications/Model/Schedule.php index ebad9c84..c3f82339 100644 --- a/library/Notifications/Model/Schedule.php +++ b/library/Notifications/Model/Schedule.php @@ -4,6 +4,10 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; use ipl\Orm\Query; use ipl\Orm\Relations; @@ -11,10 +15,12 @@ /** * @property int $id * @property string $name + * @property DateTime $changed_at + * @property bool $deleted * - * @property Rotation|Query $rotation - * @property RuleEscalationRecipient|Query $rule_escalation_recipient - * @property IncidentHistory|Query $incident_history + * @property Query|Rotation $rotation + * @property Query|RuleEscalationRecipient $rule_escalation_recipient + * @property Query|IncidentHistory $incident_history */ class Schedule extends Model { @@ -31,13 +37,18 @@ public function getKeyName(): string public function getColumns(): array { return [ - 'name' + 'name', + 'changed_at', + 'deleted' ]; } public function getColumnDefinitions(): array { - return ['name' => t('Name')]; + return [ + 'name' => t('Name'), + 'changed_at' => t('Changed At') + ]; } public function getSearchColumns(): array @@ -50,6 +61,12 @@ public function getDefaultSort(): string return 'name'; } + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + public function createRelations(Relations $relations): void { $relations->hasMany('rotation', Rotation::class); diff --git a/library/Notifications/Model/Source.php b/library/Notifications/Model/Source.php index 823c6715..973bbb76 100644 --- a/library/Notifications/Model/Source.php +++ b/library/Notifications/Model/Source.php @@ -4,7 +4,12 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; use ipl\Web\Widget\IcingaIcon; use ipl\Web\Widget\Icon; @@ -20,23 +25,27 @@ * @property ?string $icinga2_ca_pem * @property ?string $icinga2_common_name * @property string $icinga2_insecure_tls + * @property DateTime $changed_at + * @property bool $deleted + * + * @property Query|Objects $object */ class Source extends Model { /** @var string The type name used by Icinga sources */ public const ICINGA_TYPE_NAME = 'icinga2'; - public function getTableName() + public function getTableName(): string { return 'source'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'type', @@ -47,29 +56,38 @@ public function getColumns() 'icinga2_auth_pass', 'icinga2_ca_pem', 'icinga2_common_name', - 'icinga2_insecure_tls' + 'icinga2_insecure_tls', + 'changed_at', + 'deleted' ]; } - public function getColumnDefinitions() + public function getColumnDefinitions(): array { return [ - 'type' => t('Type'), - 'name' => t('Name') + 'type' => t('Type'), + 'name' => t('Name'), + 'changed_at' => t('Changed At') ]; } - public function getSearchColumns() + public function getSearchColumns(): array { return ['type']; } - public function getDefaultSort() + public function getDefaultSort(): string { return 'source.name'; } - public function createRelations(Relations $relations) + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->hasMany('object', Objects::class); } diff --git a/library/Notifications/Model/Timeperiod.php b/library/Notifications/Model/Timeperiod.php index 28be50ca..c270341a 100644 --- a/library/Notifications/Model/Timeperiod.php +++ b/library/Notifications/Model/Timeperiod.php @@ -4,6 +4,10 @@ namespace Icinga\Module\Notifications\Model; +use DateTime; +use ipl\Orm\Behavior\BoolCast; +use ipl\Orm\Behavior\MillisecondTimestamp; +use ipl\Orm\Behaviors; use ipl\Orm\Model; use ipl\Orm\Query; use ipl\Orm\Relations; @@ -13,30 +17,40 @@ * * @property int $id * @property ?int $owned_by_rotation_id + * @property DateTime $changed_at + * @property bool $deleted * * @property Query|Rotation $rotation * @property Query|TimeperiodEntry $timeperiod_entry */ class Timeperiod extends Model { - public function getTableName() + public function getTableName(): string { return 'timeperiod'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ - 'owned_by_rotation_id' + 'owned_by_rotation_id', + 'changed_at', + 'deleted' ]; } - public function createRelations(Relations $relations) + public function createBehaviors(Behaviors $behaviors): void + { + $behaviors->add(new MillisecondTimestamp(['changed_at'])); + $behaviors->add(new BoolCast(['deleted'])); + } + + public function createRelations(Relations $relations): void { $relations->belongsTo('rotation', Rotation::class) ->setCandidateKey('owned_by_rotation_id') diff --git a/library/Notifications/Model/TimeperiodEntry.php b/library/Notifications/Model/TimeperiodEntry.php index 36c262d8..ee8d037e 100644 --- a/library/Notifications/Model/TimeperiodEntry.php +++ b/library/Notifications/Model/TimeperiodEntry.php @@ -5,9 +5,11 @@ namespace Icinga\Module\Notifications\Model; use DateTime; +use ipl\Orm\Behavior\BoolCast; use ipl\Orm\Behavior\MillisecondTimestamp; use ipl\Orm\Behaviors; use ipl\Orm\Model; +use ipl\Orm\Query; use ipl\Orm\Relations; use Recurr\Frequency; use Recurr\Rule; @@ -24,22 +26,22 @@ * @property string $timezone * @property ?string $rrule * - * @property Timeperiod $timeperiod - * @property RotationMember $member + * @property Query|Timeperiod $timeperiod + * @property Query|RotationMember $member */ class TimeperiodEntry extends Model { - public function getTableName() + public function getTableName(): string { return 'timeperiod_entry'; } - public function getKeyName() + public function getKeyName(): string { return 'id'; } - public function getColumns() + public function getColumns(): array { return [ 'timeperiod_id', @@ -48,25 +50,29 @@ public function getColumns() 'end_time', 'until_time', 'timezone', - 'rrule' + 'rrule', + 'changed_at', + 'deleted' ]; } - public function getDefaultSort() + public function getDefaultSort(): array { return ['start_time asc', 'end_time asc']; } - public function createBehaviors(Behaviors $behaviors) + public function createBehaviors(Behaviors $behaviors): void { $behaviors->add(new MillisecondTimestamp([ 'start_time', 'end_time', - 'until_time' + 'until_time', + 'changed_at' ])); + $behaviors->add(new BoolCast(['deleted'])); } - public function createRelations(Relations $relations) + public function createRelations(Relations $relations): void { $relations->belongsTo('timeperiod', Timeperiod::class); $relations->belongsTo('member', RotationMember::class); diff --git a/library/Notifications/Web/Form/ContactForm.php b/library/Notifications/Web/Form/ContactForm.php index 17121f95..246f015a 100644 --- a/library/Notifications/Web/Form/ContactForm.php +++ b/library/Notifications/Web/Form/ContactForm.php @@ -4,11 +4,13 @@ namespace Icinga\Module\Notifications\Web\Form; -use Icinga\Module\Notifications\Common\Database; +use Icinga\Exception\Http\HttpNotFoundException; use Icinga\Module\Notifications\Model\AvailableChannelType; use Icinga\Module\Notifications\Model\Channel; use Icinga\Module\Notifications\Model\Contact; -use Icinga\Module\Notifications\Model\ContactAddress; +use Icinga\Module\Notifications\Model\Rotation; +use Icinga\Module\Notifications\Model\RotationMember; +use Icinga\Module\Notifications\Model\RuleEscalationRecipient; use Icinga\Web\Session; use ipl\Html\Contract\FormSubmitElement; use ipl\Html\FormElement\FieldsetElement; @@ -33,10 +35,9 @@ class ContactForm extends CompatForm /** @var ?string Contact ID*/ private $contactId; - public function __construct(Connection $db, $contactId = null) + public function __construct(Connection $db) { $this->db = $db; - $this->contactId = $contactId; $this->on(self::ON_SENT, function () { if ($this->hasBeenRemoved()) { @@ -82,7 +83,7 @@ protected function assemble() $this->addElement($contact); $channelOptions = ['' => sprintf(' - %s - ', $this->translate('Please choose'))]; - $channelOptions += Channel::fetchChannelNames(Database::get()); + $channelOptions += Channel::fetchChannelNames($this->db); $contact->addElement( 'text', @@ -99,7 +100,8 @@ protected function assemble() 'validators' => [ new StringLengthValidator(['max' => 254]), new CallbackValidator(function ($value, $validator) { - $contact = Contact::on($this->db)->filter(Filter::equal('username', $value)); + $contact = Contact::on($this->db) + ->filter(Filter::equal('username', $value)); if ($this->contactId) { $contact->filter(Filter::unequal('id', $this->contactId)); } @@ -157,115 +159,240 @@ protected function assemble() } } - public function populate($values) + /** + * Load the contact with given id + * + * @param int $id + * + * @return $this + * + * @throws HttpNotFoundException + */ + public function loadContact(int $id): self { - if ($values instanceof Contact) { - $formValues = [ - 'contact' => [ - 'full_name' => $values->full_name, - 'username' => $values->username, - 'default_channel_id' => $values->default_channel_id - ] - ]; + $this->contactId = $id; - foreach ($values->contact_address as $contactInfo) { - $formValues['contact_address'][$contactInfo->type] = $contactInfo->address; - } + $this->populate($this->fetchDbValues()); - $values = $formValues; - } + return $this; + } - parent::populate($values); + /** + * Add the new contact + */ + public function addContact(): void + { + $contactInfo = $this->getValues(); + $changedAt = time() * 1000; + $this->db->beginTransaction(); + $this->db->insert('contact', $contactInfo['contact'] + ['changed_at' => $changedAt]); + $this->contactId = $this->db->lastInsertId(); + + foreach (array_filter($contactInfo['contact_address']) as $type => $address) { + $address = [ + 'contact_id' => $this->contactId, + 'type' => $type, + 'address' => $address, + 'changed_at' => $changedAt + ]; - return $this; + $this->db->insert('contact_address', $address); + } + + $this->db->commitTransaction(); } /** - * Add or update the contact and its corresponding contact addresses + * Edit the contact * * @return void */ - public function addOrUpdateContact(): void + public function editContact(): void { - $contactInfo = $this->getValues(); + $this->db->beginTransaction(); - $contact = $contactInfo['contact']; - $addressFromForm = $contactInfo['contact_address']; + $values = $this->getValues(); + $storedValues = $this->fetchDbValues(); + $changedAt = time() * 1000; + if ($storedValues['contact'] !== $values['contact']) { + $this->db->update( + 'contact', + $values['contact'] + ['changed_at' => $changedAt], + ['id = ?' => $this->contactId] + ); + } + + $storedAddresses = $storedValues['contact_address_with_id']; + foreach ($values['contact_address'] as $type => $address) { + if ($address === null) { + if (isset($storedAddresses[$type])) { + $this->db->update( + 'contact_address', + ['changed_at' => $changedAt, 'deleted' => 'y'], + ['id = ?' => $storedAddresses[$type][0], 'deleted = ?' => 'n'] + ); + } + } elseif (! isset($storedAddresses[$type])) { + $address = [ + 'contact_id' => $this->contactId, + 'type' => $type, + 'address' => $address, + 'changed_at' => $changedAt + ]; + + $this->db->insert('contact_address', $address); + } elseif ($storedAddresses[$type][1] !== $address) { + $this->db->update( + 'contact_address', + ['address' => $address, 'changed_at' => $changedAt], + [ + 'id = ?' => $storedAddresses[$type][0], + 'contact_id = ?' => $this->contactId + ] + ); + } + } + + $this->db->commitTransaction(); + } + + /** + * Remove the contact + */ + public function removeContact(): void + { $this->db->beginTransaction(); - $addressFromDb = []; - if ($this->contactId === null) { - $this->db->insert('contact', $contact); - $this->contactId = $this->db->lastInsertId(); - } else { - $contactFromDb = (array) $this->db->fetchOne( - Contact::on($this->db)->withoutColumns(['id']) - ->filter(Filter::equal('id', $this->contactId)) - ->assembleSelect() + $markAsDeleted = ['changed_at' => time() * 1000, 'deleted' => 'y']; + $updateCondition = ['contact_id = ?' => $this->contactId, 'deleted = ?' => 'n']; + + $rotationAndMemberIds = $this->db->fetchPairs( + RotationMember::on($this->db) + ->columns(['id', 'rotation_id']) + ->filter(Filter::equal('contact_id', $this->contactId)) + ->assembleSelect() + ); + + $rotationMemberIds = array_keys($rotationAndMemberIds); + $rotationIds = array_values($rotationAndMemberIds); + + $this->db->update('rotation_member', $markAsDeleted + ['position' => null], $updateCondition); + + if (! empty($rotationMemberIds)) { + $this->db->update( + 'timeperiod_entry', + $markAsDeleted, + ['rotation_member_id IN (?)' => $rotationMemberIds, 'deleted = ?' => 'n'] + ); + } + + if (! empty($rotationIds)) { + $rotationIdsWithOtherMembers = $this->db->fetchCol( + RotationMember::on($this->db) + ->columns('rotation_id') + ->filter( + Filter::all( + Filter::equal('rotation_id', $rotationIds), + Filter::unequal('contact_id', $this->contactId) + ) + )->assembleSelect() ); - if (! empty(array_diff_assoc($contact, $contactFromDb))) { - $this->db->update('contact', $contact, ['id = ?' => $this->contactId]); - } + $toRemoveRotations = array_diff($rotationIds, $rotationIdsWithOtherMembers); - $addressObjects = (ContactAddress::on($this->db)) - ->filter(Filter::equal('contact_id', $this->contactId)); + if (! empty($toRemoveRotations)) { + $rotations = Rotation::on($this->db) + ->columns(['id', 'schedule_id', 'priority', 'timeperiod.id']) + ->filter(Filter::equal('id', $toRemoveRotations)); - foreach ($addressObjects as $addressRow) { - $addressFromDb[$addressRow->type] = [$addressRow->id, $addressRow->address]; + /** @var Rotation $rotation */ + foreach ($rotations as $rotation) { + $rotation->delete(); + } } } - foreach ($addressFromForm as $type => $value) { - $this->insertOrUpdateAddress($type, $addressFromForm, $addressFromDb); + $escalationIds = $this->db->fetchCol( + RuleEscalationRecipient::on($this->db) + ->columns('rule_escalation_id') + ->filter(Filter::equal('contact_id', $this->contactId)) + ->assembleSelect() + ); + + $this->db->update('rule_escalation_recipient', $markAsDeleted, $updateCondition); + + if (! empty($escalationIds)) { + $escalationIdsWithOtherRecipients = $this->db->fetchCol( + RuleEscalationRecipient::on($this->db) + ->columns('rule_escalation_id') + ->filter(Filter::all( + Filter::equal('rule_escalation_id', $escalationIds), + Filter::unequal('contact_id', $this->contactId) + ))->assembleSelect() + ); + + $toRemoveEscalations = array_diff($escalationIds, $escalationIdsWithOtherRecipients); + + if (! empty($toRemoveEscalations)) { + $this->db->update( + 'rule_escalation', + $markAsDeleted + ['position' => null], + ['id IN (?)' => $toRemoveEscalations] + ); + } } + $this->db->update('contactgroup_member', $markAsDeleted, $updateCondition); + $this->db->update('contact_address', $markAsDeleted, $updateCondition); + + $this->db->update('contact', $markAsDeleted + ['username' => null], ['id = ?' => $this->contactId]); + $this->db->commitTransaction(); } - public function removeContact() + /** + * Get the contact name + * + * @return string + */ + public function getContactName(): string { - $this->db->beginTransaction(); - $this->db->delete('contactgroup_member', ['contact_id = ?' => $this->contactId]); - $this->db->delete('contact_address', ['contact_id = ?' => $this->contactId]); - $this->db->delete('contact', ['id = ?' => $this->contactId]); - $this->db->commitTransaction(); + return $this->getElement('contact')->getValue('full_name'); } /** - * Insert / Update contact address for a given contact + * Fetch the values from the database * - * @param string $type - * @param array $addressFromForm - * @param array $addressFromDb [id, address] from `contact_adrress` table + * @return array * - * @return void + * @throws HttpNotFoundException */ - private function insertOrUpdateAddress(string $type, array $addressFromForm, array $addressFromDb): void + private function fetchDbValues(): array { - if ($addressFromForm[$type] !== null) { - if (! isset($addressFromDb[$type])) { - $address = [ - 'contact_id' => $this->contactId, - 'type' => $type, - 'address' => $addressFromForm[$type] - ]; + /** @var ?Contact $contact */ + $contact = Contact::on($this->db) + ->filter(Filter::equal('id', $this->contactId)) + ->first(); - $this->db->insert('contact_address', $address); - } elseif ($addressFromDb[$type][1] !== $addressFromForm[$type]) { - $this->db->update( - 'contact_address', - ['address' => $addressFromForm[$type]], - [ - 'id = ?' => $addressFromDb[$type][0], - 'contact_id = ?' => $this->contactId - ] - ); - } - } elseif (isset($addressFromDb[$type])) { - $this->db->delete('contact_address', ['id = ?' => $addressFromDb[$type][0]]); + if ($contact === null) { + throw new HttpNotFoundException(t('Contact not found')); } + + $values['contact'] = [ + 'full_name' => $contact->full_name, + 'username' => $contact->username, + 'default_channel_id' => (string) $contact->default_channel_id + ]; + + $values['contact_address'] = []; + $values['contact_address_with_id'] = []; //TODO: only used in editContact(), find better solution + foreach ($contact->contact_address as $contactInfo) { + $values['contact_address'][$contactInfo->type] = $contactInfo->address; + $values['contact_address_with_id'][$contactInfo->type] = [$contactInfo->id, $contactInfo->address]; + } + + return $values; } /** diff --git a/library/Notifications/Widget/CheckboxIcon.php b/library/Notifications/Widget/CheckboxIcon.php deleted file mode 100644 index 16c5845c..00000000 --- a/library/Notifications/Widget/CheckboxIcon.php +++ /dev/null @@ -1,40 +0,0 @@ - 'checkbox-icon']; - - /** - * Create the checkbox icon - * - * @param bool $isChecked - */ - public function __construct(bool $isChecked = false) - { - $this->isChecked = $isChecked; - } - - protected function assemble() - { - $this->add(Html::tag('span', ['class' => 'inner-slider'])); - - if ($this->isChecked) { - $this->addAttributes(['class' => 'checked']); - } - } -} diff --git a/library/Notifications/Widget/ItemList/EventRuleListItem.php b/library/Notifications/Widget/ItemList/EventRuleListItem.php index cb4127a0..0e8afd14 100644 --- a/library/Notifications/Widget/ItemList/EventRuleListItem.php +++ b/library/Notifications/Widget/ItemList/EventRuleListItem.php @@ -6,7 +6,6 @@ use Icinga\Module\Notifications\Common\Links; use Icinga\Module\Notifications\Model\Rule; -use Icinga\Module\Notifications\Widget\CheckboxIcon; use Icinga\Module\Notifications\Widget\RuleEscalationRecipientBadge; use ipl\Html\BaseHtmlElement; use ipl\Html\Html; @@ -31,11 +30,6 @@ protected function init(): void ->set('data-action-item', true); } - protected function assembleVisual(BaseHtmlElement $visual): void - { - $visual->add(new CheckboxIcon($this->item->is_active === 'y')); - } - protected function assembleFooter(BaseHtmlElement $footer): void { $meta = Html::tag('span', ['class' => 'meta']); diff --git a/library/Notifications/Widget/RuleEscalationRecipientBadge.php b/library/Notifications/Widget/RuleEscalationRecipientBadge.php index 698b10b1..ba76c53f 100644 --- a/library/Notifications/Widget/RuleEscalationRecipientBadge.php +++ b/library/Notifications/Widget/RuleEscalationRecipientBadge.php @@ -37,6 +37,10 @@ public function __construct(RuleEscalationRecipient $recipient, ?int $moreCount public function createBadge() { $recipientModel = $this->recipient->getRecipient(); + if ($recipientModel === null) { + return; + } + $nameColumn = 'name'; $icon = 'users'; diff --git a/public/css/form.less b/public/css/form.less index ca2a2406..c9626b66 100644 --- a/public/css/form.less +++ b/public/css/form.less @@ -108,3 +108,11 @@ .source-form textarea { .monospace-font(); } + +.channel-form { + .btn-remove:disabled { + background: @gray-light; + color: @disabled-gray; + border-color: transparent; + } +}