Skip to content
Open
1 change: 1 addition & 0 deletions changelog.d/1502.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add INCIDENT_RESTART event type to allow source systems to reopen incidents
9 changes: 7 additions & 2 deletions docs/reference/api/v2.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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/<int:pk>/acks/`` endpoint.
Expand Down
30 changes: 30 additions & 0 deletions src/argus/incident/migrations/0002_alter_event_type.py
Original file line number Diff line number Diff line change
@@ -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"),
]
),
),
]
2 changes: 2 additions & 0 deletions src/argus/incident/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions src/argus/incident/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()

Expand Down
50 changes: 40 additions & 10 deletions tests/incident/test_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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()
Expand Down
Loading