diff --git a/changelog.d/1502.added.md b/changelog.d/1502.added.md new file mode 100644 index 000000000..bab58b9fd --- /dev/null +++ b/changelog.d/1502.added.md @@ -0,0 +1 @@ +Add INCIDENT_RESTART event type to allow source systems to reopen incidents diff --git a/docs/reference/api/v2.rst b/docs/reference/api/v2.rst index 91b563b1f..32bfc2b99 100644 --- a/docs/reference/api/v2.rst +++ b/docs/reference/api/v2.rst @@ -379,8 +379,7 @@ Incident endpoints - ``END`` - Incident end Only source systems can post an event of this type, which is - the standard way of closing an indicent. An incident cannot - have more than one event of this type. + the standard way of closing an incident. - ``CLO`` - Close @@ -393,6 +392,12 @@ Incident endpoints the incident if it has been closed (either manually or by a source system). + - ``RES`` - Incident restart + + Only source systems can post an event of this type, which reopens + the incident if it has been closed (either manually or by a + source system). + - ``ACK`` - Acknowledge Use the ``/api/v2/incidents//acks/`` endpoint. diff --git a/src/argus/incident/migrations/0002_alter_event_type.py b/src/argus/incident/migrations/0002_alter_event_type.py new file mode 100644 index 000000000..c12e7480d --- /dev/null +++ b/src/argus/incident/migrations/0002_alter_event_type.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2 on 2025-06-25 10:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("argus_incident", "0001_squashed_incident_20250514"), + ] + + operations = [ + migrations.AlterField( + model_name="event", + name="type", + field=models.TextField( + choices=[ + ("STA", "Incident start"), + ("END", "Incident end"), + ("CHI", "Incident change"), + ("RES", "Incident restart"), + ("CLO", "Close"), + ("REO", "Reopen"), + ("ACK", "Acknowledge"), + ("OTH", "Other"), + ("LES", "Stateless"), + ] + ), + ), + ] diff --git a/src/argus/incident/models.py b/src/argus/incident/models.py index 228166d0e..b3852bd17 100644 --- a/src/argus/incident/models.py +++ b/src/argus/incident/models.py @@ -247,6 +247,7 @@ class Type(models.TextChoices): INCIDENT_START = "STA", "Incident start" INCIDENT_END = "END", "Incident end" INCIDENT_CHANGE = "CHI", "Incident change" + INCIDENT_RESTART = "RES", "Incident restart" CLOSE = "CLO", "Close" REOPEN = "REO", "Reopen" ACKNOWLEDGE = "ACK", "Acknowledge" @@ -256,6 +257,7 @@ class Type(models.TextChoices): ALLOWED_TYPES_FOR_SOURCE_SYSTEMS = { Type.INCIDENT_START, Type.INCIDENT_END, + Type.INCIDENT_RESTART, Type.OTHER, Type.INCIDENT_CHANGE, Type.STATELESS, diff --git a/src/argus/incident/views.py b/src/argus/incident/views.py index a36d9861d..85f54fe2b 100644 --- a/src/argus/incident/views.py +++ b/src/argus/incident/views.py @@ -463,18 +463,23 @@ def validate_incident_has_no_relation_to_event_type(): self._raise_type_validation_error(f"The incident already has a related event of type '{event_type}'.") if incident.stateful: - if event_type in {Event.Type.INCIDENT_START, Event.Type.INCIDENT_END}: + if event_type in {Event.Type.INCIDENT_START}: validate_incident_has_no_relation_to_event_type() if event_type in {Event.Type.INCIDENT_END, Event.Type.CLOSE} and not incident.open: self._raise_type_validation_error("The incident is already closed.") - elif event_type == Event.Type.REOPEN and incident.open: + elif event_type in {Event.Type.REOPEN, Event.Type.INCIDENT_RESTART} and incident.open: self._raise_type_validation_error("The incident is already open.") else: if event_type == Event.Type.STATELESS: validate_incident_has_no_relation_to_event_type() elif event_type == Event.Type.INCIDENT_START: self._raise_type_validation_error("Stateless incident cannot have an INCIDENT_START event.") - elif event_type in {Event.Type.INCIDENT_END, Event.Type.CLOSE, Event.Type.REOPEN}: + elif event_type in { + Event.Type.INCIDENT_END, + Event.Type.CLOSE, + Event.Type.REOPEN, + Event.Type.INCIDENT_RESTART, + }: self._raise_type_validation_error("Cannot change the state of a stateless incident.") if event_type == Event.Type.ACKNOWLEDGE: @@ -489,7 +494,7 @@ def update_incident(self, validated_data: dict, incident: Incident): if event_type in {Event.Type.INCIDENT_END, Event.Type.CLOSE}: incident.end_time = timestamp incident.save() - elif event_type == Event.Type.REOPEN: + elif event_type in {Event.Type.REOPEN, Event.Type.INCIDENT_RESTART}: incident.end_time = INFINITY_REPR incident.save() diff --git a/tests/incident/test_event.py b/tests/incident/test_event.py index 91971a450..6c8253250 100644 --- a/tests/incident/test_event.py +++ b/tests/incident/test_event.py @@ -72,6 +72,18 @@ def _assert_posting_event_succeeds(self, post_data: dict, client: Client): self.assertTrue(self.stateful_incident1.events.filter(pk=response.data["pk"]).exists()) self.assertEqual(response.data["incident"], self.stateful_incident1.pk) + def _assert_incident_is_closed_at_timestamp(self, end_event_timestamp): + self.stateful_incident1.refresh_from_db() + self.assertFalse(self.stateful_incident1.open) + set_end_time = self.stateful_incident1.end_time + self.assertEqual(set_end_time, end_event_timestamp) + + def _assert_incident_is_open(self): + self.stateful_incident1.refresh_from_db() + self.assertTrue(self.stateful_incident1.open) + set_end_time = self.stateful_incident1.end_time + self.assertEqual(datetime_utils.make_naive(set_end_time), datetime.max) + def test_posting_close_and_reopen_events_properly_changes_stateful_incidents(self): self.assertTrue(self.stateful_incident1.stateful) self.assertTrue(self.stateful_incident1.open) @@ -81,30 +93,48 @@ def test_posting_close_and_reopen_events_properly_changes_stateful_incidents(sel event_timestamp = close_event_dict["timestamp"] response = self.user1_rest_client.post(self.events_url(self.stateful_incident1), close_event_dict) self.assertEqual(parse_datetime(response.data["timestamp"]), event_timestamp) - self.stateful_incident1.refresh_from_db() - self.assertFalse(self.stateful_incident1.open) - set_end_time = self.stateful_incident1.end_time - self.assertEqual(set_end_time, event_timestamp) + self._assert_incident_is_closed_at_timestamp(event_timestamp) # It's illegal to close an already closed incident self._assert_posting_event_is_rejected_and_does_not_change_end_time( - close_event_dict, set_end_time, self.user1_rest_client + close_event_dict, self.stateful_incident1.end_time, self.user1_rest_client ) # Test reopening incident reopen_event_dict = self._create_event_dict(Event.Type.REOPEN) response = self.user1_rest_client.post(self.events_url(self.stateful_incident1), reopen_event_dict) self.assertEqual(parse_datetime(response.data["timestamp"]), reopen_event_dict["timestamp"]) - self.stateful_incident1.refresh_from_db() - self.assertTrue(self.stateful_incident1.open) - set_end_time = self.stateful_incident1.end_time - self.assertEqual(datetime_utils.make_naive(set_end_time), datetime.max) + self._assert_incident_is_open() # It's illegal to reopen an already opened incident self._assert_posting_event_is_rejected_and_does_not_change_end_time( - reopen_event_dict, set_end_time, self.user1_rest_client + reopen_event_dict, self.stateful_incident1.end_time, self.user1_rest_client ) + def test_posting_end_and_restart_events_properly_changes_stateful_incidents(self): + self.assertTrue(self.stateful_incident1.stateful) + self.assertTrue(self.stateful_incident1.open) + + # Test ending incident + end_event_dict = self._create_event_dict(Event.Type.INCIDENT_END) + event_timestamp = end_event_dict["timestamp"] + response = self.source1_rest_client.post(self.events_url(self.stateful_incident1), end_event_dict) + self.assertEqual(parse_datetime(response.data["timestamp"]), event_timestamp) + self._assert_incident_is_closed_at_timestamp(event_timestamp) + + # Test restarting incident + reopen_event_dict = self._create_event_dict(Event.Type.INCIDENT_RESTART) + response = self.source1_rest_client.post(self.events_url(self.stateful_incident1), reopen_event_dict) + self.assertEqual(parse_datetime(response.data["timestamp"]), reopen_event_dict["timestamp"]) + self._assert_incident_is_open() + + # Test ending again + end_event_dict = self._create_event_dict(Event.Type.INCIDENT_END) + event_timestamp = end_event_dict["timestamp"] + response = self.source1_rest_client.post(self.events_url(self.stateful_incident1), end_event_dict) + self.assertEqual(parse_datetime(response.data["timestamp"]), event_timestamp) + self._assert_incident_is_closed_at_timestamp(event_timestamp) + def test_posting_close_and_reopen_events_does_not_change_stateless_incidents(self): def assert_incident_stateless(): self.stateless_incident1.refresh_from_db()