firefighter-incident 0.0.13__py3-none-any.whl → 0.0.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. firefighter/_version.py +16 -3
  2. firefighter/api/serializers.py +17 -8
  3. firefighter/api/urls.py +8 -1
  4. firefighter/api/views/_base.py +1 -1
  5. firefighter/api/views/components.py +5 -5
  6. firefighter/api/views/incidents.py +9 -9
  7. firefighter/confluence/signals/incident_updated.py +2 -2
  8. firefighter/firefighter/settings/components/raid.py +3 -0
  9. firefighter/incidents/admin.py +24 -24
  10. firefighter/incidents/enums.py +22 -2
  11. firefighter/incidents/factories.py +14 -5
  12. firefighter/incidents/forms/close_incident.py +4 -4
  13. firefighter/incidents/forms/closure_reason.py +45 -0
  14. firefighter/incidents/forms/create_incident.py +4 -4
  15. firefighter/incidents/forms/unified_incident.py +406 -0
  16. firefighter/incidents/forms/update_status.py +91 -5
  17. firefighter/incidents/menus.py +2 -2
  18. firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
  19. firefighter/incidents/migrations/0009_update_sla.py +7 -5
  20. firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
  21. firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
  22. firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
  23. firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
  24. firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
  25. firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
  26. firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
  27. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  28. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  29. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  30. firefighter/incidents/models/__init__.py +1 -1
  31. firefighter/incidents/models/group.py +1 -1
  32. firefighter/incidents/models/incident.py +47 -20
  33. firefighter/incidents/models/{component.py → incident_category.py} +30 -29
  34. firefighter/incidents/models/incident_update.py +3 -3
  35. firefighter/incidents/static/css/main.min.css +1 -1
  36. firefighter/incidents/tables.py +9 -9
  37. firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
  38. firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
  39. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  40. firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
  41. firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
  42. firefighter/incidents/templates/pages/incident_detail.html +3 -3
  43. firefighter/incidents/urls.py +6 -6
  44. firefighter/incidents/views/components/details.py +9 -9
  45. firefighter/incidents/views/components/list.py +9 -9
  46. firefighter/incidents/views/reports.py +5 -5
  47. firefighter/incidents/views/users/details.py +2 -2
  48. firefighter/incidents/views/views.py +7 -7
  49. firefighter/jira_app/client.py +1 -1
  50. firefighter/logging/custom_json_formatter.py +2 -1
  51. firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
  52. firefighter/raid/admin.py +0 -11
  53. firefighter/raid/apps.py +9 -26
  54. firefighter/raid/client.py +5 -5
  55. firefighter/raid/forms.py +84 -213
  56. firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
  57. firefighter/raid/models.py +2 -21
  58. firefighter/raid/serializers.py +5 -4
  59. firefighter/raid/service.py +29 -27
  60. firefighter/raid/signals/incident_created.py +42 -15
  61. firefighter/raid/signals/incident_updated.py +3 -2
  62. firefighter/raid/utils.py +1 -1
  63. firefighter/raid/views/__init__.py +1 -1
  64. firefighter/slack/admin.py +8 -8
  65. firefighter/slack/management/commands/switch_test_users.py +272 -0
  66. firefighter/slack/messages/slack_messages.py +24 -9
  67. firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
  68. firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
  69. firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
  70. firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
  71. firefighter/slack/models/conversation.py +3 -3
  72. firefighter/slack/models/incident_channel.py +1 -1
  73. firefighter/slack/models/user.py +1 -1
  74. firefighter/slack/models/user_group.py +3 -3
  75. firefighter/slack/rules.py +2 -2
  76. firefighter/slack/signals/create_incident_conversation.py +6 -0
  77. firefighter/slack/signals/get_users.py +2 -2
  78. firefighter/slack/signals/incident_updated.py +8 -2
  79. firefighter/slack/utils.py +2 -2
  80. firefighter/slack/views/events/home.py +2 -2
  81. firefighter/slack/views/modals/__init__.py +4 -0
  82. firefighter/slack/views/modals/base_modal/form_utils.py +78 -0
  83. firefighter/slack/views/modals/close.py +18 -5
  84. firefighter/slack/views/modals/closure_reason.py +193 -0
  85. firefighter/slack/views/modals/open.py +83 -12
  86. firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
  87. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  88. firefighter/slack/views/modals/opening/select_impact.py +5 -2
  89. firefighter/slack/views/modals/opening/set_details.py +3 -2
  90. firefighter/slack/views/modals/postmortem.py +10 -2
  91. firefighter/slack/views/modals/update_status.py +32 -6
  92. firefighter/slack/views/modals/utils.py +51 -0
  93. firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
  94. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +2 -2
  95. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +133 -88
  96. firefighter_tests/conftest.py +4 -5
  97. firefighter_tests/test_api/test_api_landbot.py +1 -1
  98. firefighter_tests/test_firefighter/test_sso.py +146 -0
  99. firefighter_tests/test_incidents/test_enums.py +100 -0
  100. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  101. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  102. firefighter_tests/test_incidents/test_forms/test_form_utils.py +15 -15
  103. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  104. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  105. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  106. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  107. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  108. firefighter_tests/test_incidents/test_incident_urls.py +3 -3
  109. firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
  110. firefighter_tests/test_incidents/test_models/test_incident_model.py +70 -2
  111. firefighter_tests/test_raid/conftest.py +154 -0
  112. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  113. firefighter_tests/test_raid/test_priority_mapping.py +267 -0
  114. firefighter_tests/test_raid/test_raid_client.py +580 -0
  115. firefighter_tests/test_raid/test_raid_forms.py +552 -0
  116. firefighter_tests/test_raid/test_raid_models.py +185 -0
  117. firefighter_tests/test_raid/test_raid_serializers.py +507 -0
  118. firefighter_tests/test_raid/test_raid_service.py +442 -0
  119. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  120. firefighter_tests/test_raid/test_raid_views.py +196 -0
  121. firefighter_tests/test_slack/messages/__init__.py +0 -0
  122. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  123. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  124. firefighter_tests/test_slack/views/modals/test_close.py +71 -9
  125. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  126. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  127. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  128. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  129. firefighter_tests/test_slack/views/modals/test_update_status.py +331 -7
  130. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  131. firefighter/raid/views/open_normal.py +0 -139
  132. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  133. firefighter_fixtures/raid/area.json +0 -1
  134. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
  135. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
  136. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,343 @@
1
+ """Test the status workflow logic for update status form."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+ from django.apps import apps
6
+ from django.test import TestCase
7
+
8
+ from firefighter.incidents.enums import IncidentStatus
9
+ from firefighter.incidents.factories import IncidentFactory
10
+ from firefighter.incidents.forms.update_status import UpdateStatusForm
11
+ from firefighter.incidents.models import Environment, Priority
12
+
13
+
14
+ @pytest.mark.django_db
15
+ class TestUpdateStatusWorkflow(TestCase):
16
+ """Test that status choices respect workflow rules."""
17
+
18
+ def test_p1_p2_cannot_skip_postmortem(self):
19
+ """P1/P2 incidents cannot go directly from Mitigated to Closed."""
20
+
21
+ # Get existing P1 or P2 priority that needs post-mortem
22
+ p1_priority = Priority.objects.filter(
23
+ value=1, needs_postmortem=True
24
+ ).first()
25
+
26
+ if not p1_priority:
27
+ # If P1 doesn't exist, try P2
28
+ p1_priority = Priority.objects.filter(
29
+ value=2, needs_postmortem=True
30
+ ).first()
31
+
32
+ if not p1_priority:
33
+ # Skip test if no priority needing postmortem exists
34
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
35
+
36
+ # Get PRD environment (required for needs_postmortem to be True)
37
+ prd_env = Environment.objects.filter(value="PRD").first()
38
+ if not prd_env:
39
+ pytest.skip("No PRD environment found in database")
40
+
41
+ # Create an incident with P1/P2 priority in Mitigated status with PRD env
42
+ incident = IncidentFactory.create(
43
+ priority=p1_priority,
44
+ environment=prd_env,
45
+ _status=IncidentStatus.MITIGATED,
46
+ )
47
+
48
+ # Note: incident.needs_postmortem also requires firefighter.confluence to be installed
49
+ # For testing, we'll check the underlying conditions directly
50
+ if apps.is_installed("firefighter.confluence"):
51
+ assert incident.needs_postmortem, "Incident should need postmortem (P1/P2 + PRD)"
52
+
53
+ # Create form with the incident
54
+ form = UpdateStatusForm(incident=incident)
55
+
56
+ # Check that CLOSED is not in the choices
57
+ status_choices = dict(form.fields["status"].choices)
58
+ assert IncidentStatus.CLOSED not in status_choices
59
+ assert IncidentStatus.POST_MORTEM in status_choices
60
+
61
+ def test_p1_p2_can_close_from_postmortem(self):
62
+ """P1/P2 incidents can go from Post-mortem to Closed."""
63
+
64
+ # Get existing P1 or P2 priority that needs post-mortem
65
+ p2_priority = Priority.objects.filter(
66
+ value__in=[1, 2], needs_postmortem=True
67
+ ).first()
68
+
69
+ if not p2_priority:
70
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
71
+
72
+ # Get PRD environment
73
+ prd_env = Environment.objects.filter(value="PRD").first()
74
+ if not prd_env:
75
+ pytest.skip("No PRD environment found in database")
76
+
77
+ # Create an incident with P1/P2 priority in Post-mortem status with PRD env
78
+ incident = IncidentFactory.create(
79
+ priority=p2_priority,
80
+ environment=prd_env,
81
+ _status=IncidentStatus.POST_MORTEM,
82
+ )
83
+
84
+ # Create form with the incident
85
+ form = UpdateStatusForm(incident=incident)
86
+
87
+ # Check that CLOSED is in the choices
88
+ status_choices = dict(form.fields["status"].choices)
89
+ assert IncidentStatus.CLOSED in status_choices
90
+
91
+ def test_p3_plus_can_skip_postmortem(self):
92
+ """P3+ incidents can go directly from Mitigated to Closed and should not have post-mortem."""
93
+ # Get existing P3+ priority that doesn't need post-mortem
94
+ p3_priority = Priority.objects.filter(
95
+ value__gte=3, needs_postmortem=False
96
+ ).first()
97
+
98
+ if not p3_priority:
99
+ # If no P3+ without postmortem, skip the test
100
+ pytest.skip("No P3+ priority with needs_postmortem=False found in database")
101
+
102
+ # Create an incident with P3+ priority in Mitigated status
103
+ incident = IncidentFactory.create(
104
+ priority=p3_priority,
105
+ _status=IncidentStatus.MITIGATED,
106
+ )
107
+
108
+ # Create form with the incident
109
+ form = UpdateStatusForm(incident=incident)
110
+
111
+ # Check that CLOSED is in the choices for P3+ but POST_MORTEM is NOT
112
+ status_choices = dict(form.fields["status"].choices)
113
+ assert IncidentStatus.CLOSED in status_choices
114
+ assert IncidentStatus.POST_MORTEM not in status_choices, "P3+ incidents should not have post-mortem status available"
115
+
116
+ def test_p1_p2_cannot_skip_postmortem_from_mitigating(self):
117
+ """P1/P2 incidents cannot go directly from Mitigating to Closed."""
118
+
119
+ # Get existing P1 or P2 priority that needs post-mortem
120
+ p1_priority = Priority.objects.filter(
121
+ value__in=[1, 2], needs_postmortem=True
122
+ ).first()
123
+
124
+ if not p1_priority:
125
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
126
+
127
+ # Get PRD environment (required for needs_postmortem to be True)
128
+ prd_env = Environment.objects.filter(value="PRD").first()
129
+ if not prd_env:
130
+ pytest.skip("No PRD environment found in database")
131
+
132
+ # Create an incident with P1/P2 priority in MITIGATING (Mitigating) status with PRD env
133
+ incident = IncidentFactory.create(
134
+ priority=p1_priority,
135
+ environment=prd_env,
136
+ _status=IncidentStatus.MITIGATING, # Test from MITIGATING (Mitigating), not MITIGATED
137
+ )
138
+
139
+ # Create form with the incident
140
+ form = UpdateStatusForm(incident=incident)
141
+
142
+ # Check that CLOSED and POST_MORTEM are not in the choices from MITIGATING status
143
+ status_choices = dict(form.fields["status"].choices)
144
+ assert IncidentStatus.CLOSED not in status_choices
145
+ assert IncidentStatus.POST_MORTEM not in status_choices, "P1/P2 should not be able to go to post-mortem from Mitigating"
146
+
147
+ def test_p3_can_skip_from_investigating_to_closed(self):
148
+ """P3+ incidents can go directly from Investigating to Closed."""
149
+ # Get existing P3+ priority that doesn't need post-mortem
150
+ p3_priority = Priority.objects.filter(
151
+ value__gte=3, needs_postmortem=False
152
+ ).first()
153
+
154
+ if not p3_priority:
155
+ pytest.skip("No P3+ priority with needs_postmortem=False found in database")
156
+
157
+ # Create an incident with P3+ priority in INVESTIGATING status
158
+ incident = IncidentFactory.create(
159
+ priority=p3_priority,
160
+ _status=IncidentStatus.INVESTIGATING, # Test from INVESTIGATING
161
+ )
162
+
163
+ # Create form with the incident
164
+ form = UpdateStatusForm(incident=incident)
165
+
166
+ # Check that CLOSED is in the choices from INVESTIGATING status for P3+ but POST_MORTEM is NOT
167
+ status_choices = dict(form.fields["status"].choices)
168
+ assert IncidentStatus.CLOSED in status_choices
169
+ assert IncidentStatus.POST_MORTEM not in status_choices, "P3+ incidents should not have post-mortem status available"
170
+
171
+ def test_p1_p2_can_go_from_mitigated_to_postmortem(self):
172
+ """P1/P2 incidents can go from Mitigated to Post-mortem."""
173
+
174
+ # Get existing P1 or P2 priority that needs post-mortem
175
+ p1_priority = Priority.objects.filter(
176
+ value__in=[1, 2], needs_postmortem=True
177
+ ).first()
178
+
179
+ if not p1_priority:
180
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
181
+
182
+ # Get PRD environment
183
+ prd_env = Environment.objects.filter(value="PRD").first()
184
+ if not prd_env:
185
+ pytest.skip("No PRD environment found in database")
186
+
187
+ # Create an incident with P1/P2 priority in Mitigated status with PRD env
188
+ incident = IncidentFactory.create(
189
+ priority=p1_priority,
190
+ environment=prd_env,
191
+ _status=IncidentStatus.MITIGATED, # MITIGATED = "Mitigated"
192
+ )
193
+
194
+ # Create form with the incident
195
+ form = UpdateStatusForm(incident=incident)
196
+
197
+ # Check that POST_MORTEM is available but CLOSED is not
198
+ status_choices = dict(form.fields["status"].choices)
199
+ assert IncidentStatus.POST_MORTEM in status_choices, "P1/P2 should be able to go to Post-mortem from Mitigated"
200
+ assert IncidentStatus.CLOSED not in status_choices, "P1/P2 should NOT be able to skip Post-mortem"
201
+
202
+ def test_complete_workflow_transitions_p3_plus(self):
203
+ """Test the correct workflow for P3+: can close with reason from early statuses, normal close from Mitigated."""
204
+ # Get P3+ priority
205
+ p3_priority = Priority.objects.filter(
206
+ value__gte=3, needs_postmortem=False
207
+ ).first()
208
+
209
+ if not p3_priority:
210
+ pytest.skip("No P3+ priority found")
211
+
212
+ # Test workflow for P3+
213
+ test_cases = [
214
+ (IncidentStatus.OPEN, True), # Can close with reason
215
+ (IncidentStatus.INVESTIGATING, True), # Can close with reason
216
+ (IncidentStatus.MITIGATING, False), # Cannot close from Mitigating (must go to Mitigated first)
217
+ (IncidentStatus.MITIGATED, True), # Can close normally from Mitigated
218
+ ]
219
+
220
+ for current_status, should_have_closed in test_cases:
221
+ incident = IncidentFactory.create(
222
+ priority=p3_priority,
223
+ _status=current_status,
224
+ )
225
+
226
+ form = UpdateStatusForm(incident=incident)
227
+ status_choices = dict(form.fields["status"].choices)
228
+
229
+ if should_have_closed:
230
+ assert IncidentStatus.CLOSED in status_choices, f"P3+ should be able to go to Closed from {current_status.label}"
231
+ else:
232
+ assert IncidentStatus.CLOSED not in status_choices, f"P3+ should NOT be able to go to Closed from {current_status.label}"
233
+
234
+ # P3+ should NEVER have post-mortem available
235
+ assert IncidentStatus.POST_MORTEM not in status_choices, f"P3+ should NEVER have post-mortem available from {current_status.label}"
236
+
237
+ def test_closure_reason_required_from_early_statuses(self):
238
+ """Test that closure reason is required when closing from Opened or Investigating."""
239
+ # Test for both P1/P2 and P3+ priorities
240
+ priorities = [
241
+ Priority.objects.filter(value__in=[1, 2]).first(), # P1/P2
242
+ Priority.objects.filter(value__gte=3).first(), # P3+
243
+ ]
244
+
245
+ for priority in priorities:
246
+ if not priority:
247
+ continue
248
+
249
+ # Test from Opened and Investigating
250
+ for status in [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING]:
251
+ incident = IncidentFactory.create(
252
+ priority=priority,
253
+ _status=status,
254
+ )
255
+
256
+ # Check that requires_closure_reason returns True
257
+ assert UpdateStatusForm.requires_closure_reason(
258
+ incident, IncidentStatus.CLOSED
259
+ ), f"Should require closure reason from {status.label} for {priority.name}"
260
+
261
+ # Test from other statuses - should NOT require reason
262
+ for status in [IncidentStatus.MITIGATING, IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM]:
263
+ incident = IncidentFactory.create(
264
+ priority=priority,
265
+ _status=status,
266
+ )
267
+
268
+ # Check that requires_closure_reason returns False
269
+ assert not UpdateStatusForm.requires_closure_reason(
270
+ incident, IncidentStatus.CLOSED
271
+ ), f"Should NOT require closure reason from {status.label} for {priority.name}"
272
+
273
+ def test_cannot_close_from_mitigating(self):
274
+ """Test that incidents cannot be closed directly from Mitigating status."""
275
+ # Test for all priority levels
276
+ priorities = Priority.objects.all()
277
+
278
+ for priority in priorities:
279
+ incident = IncidentFactory.create(
280
+ priority=priority,
281
+ _status=IncidentStatus.MITIGATING, # Mitigating status
282
+ )
283
+
284
+ form = UpdateStatusForm(incident=incident)
285
+ status_choices = dict(form.fields["status"].choices)
286
+
287
+ # Should NOT have Closed option from Mitigating
288
+ assert IncidentStatus.CLOSED not in status_choices, f"Should NOT be able to close from Mitigating for {priority.name}"
289
+
290
+ # Should have Post-mortem option only for P1/P2 priorities that need postmortem
291
+ if priority.needs_postmortem:
292
+ # For P1/P2, should still NOT have post-mortem from MITIGATING (Mitigating)
293
+ assert IncidentStatus.POST_MORTEM not in status_choices, f"P1/P2 should NOT be able to go to Post-mortem from Mitigating for {priority.name}"
294
+ else:
295
+ # For P3+, should not have post-mortem at all
296
+ assert IncidentStatus.POST_MORTEM not in status_choices, f"P3+ should not have post-mortem available for {priority.name}"
297
+
298
+ def test_no_incident_shows_all_statuses(self):
299
+ """When no incident is provided, all statuses should be available."""
300
+ # Create form without incident
301
+ form = UpdateStatusForm()
302
+
303
+ # Check that all statuses including CLOSED are in the choices
304
+ status_choices = dict(form.fields["status"].choices)
305
+ assert IncidentStatus.CLOSED in status_choices
306
+ assert IncidentStatus.POST_MORTEM in status_choices
307
+
308
+ def test_requires_closure_reason_non_closed_status(self):
309
+ """Test requires_closure_reason with non-CLOSED target status."""
310
+ incident = IncidentFactory.create(_status=IncidentStatus.OPEN)
311
+
312
+ # Should not require reason for non-CLOSED statuses
313
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.INVESTIGATING)
314
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.MITIGATING)
315
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.MITIGATED)
316
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.POST_MORTEM)
317
+
318
+ def test_incident_status_edge_cases(self):
319
+ """Test edge cases for incident status transitions."""
320
+ # Test with incident in default fallback case
321
+ p1_priority = Priority.objects.filter(
322
+ value__in=[1, 2], needs_postmortem=True
323
+ ).first()
324
+
325
+ if not p1_priority:
326
+ pytest.skip("No P1/P2 priority found")
327
+
328
+ prd_env = Environment.objects.filter(value="PRD").first()
329
+ if not prd_env:
330
+ pytest.skip("No PRD environment found")
331
+
332
+ # Create incident with an undefined status (should hit default case)
333
+ incident = IncidentFactory.create(
334
+ priority=p1_priority,
335
+ environment=prd_env,
336
+ _status=IncidentStatus.CLOSED # Already closed
337
+ )
338
+
339
+ form = UpdateStatusForm(incident=incident)
340
+ status_choices = dict(form.fields["status"].choices)
341
+
342
+ # Should use default choices_lte for unknown/closed status
343
+ assert IncidentStatus.CLOSED in status_choices
@@ -0,0 +1,167 @@
1
+ """Test complete workflow transitions according to the diagram.
2
+
3
+ Workflow transitions:
4
+
5
+ P1/P2:
6
+ - OPEN → [INVESTIGATING, CLOSED (avec reason form)]
7
+ - INVESTIGATING → [MITIGATING, CLOSED (avec reason form)]
8
+ - MITIGATING → [MITIGATED]
9
+ - MITIGATED → [POST_MORTEM]
10
+ - POST_MORTEM → [CLOSED]
11
+
12
+ P3/P4/P5:
13
+ - OPEN → [INVESTIGATING, CLOSED (avec reason form)]
14
+ - INVESTIGATING → [MITIGATING, CLOSED (avec reason form)]
15
+ - MITIGATING → [MITIGATED]
16
+ - MITIGATED → [CLOSED]
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import pytest
21
+ from django.test import TestCase
22
+
23
+ from firefighter.incidents.enums import IncidentStatus
24
+ from firefighter.incidents.factories import IncidentFactory
25
+ from firefighter.incidents.forms.update_status import UpdateStatusForm
26
+ from firefighter.incidents.models import Environment, Priority
27
+
28
+
29
+ @pytest.mark.django_db
30
+ class TestCompleteWorkflowTransitions(TestCase):
31
+ """Test that all workflow transitions are implemented correctly."""
32
+
33
+ def test_p1_p2_complete_workflow_transitions(self):
34
+ """Test all P1/P2 workflow transitions according to the diagram."""
35
+ # Get P1/P2 priority
36
+ p1_priority = Priority.objects.filter(
37
+ value__in=[1, 2], needs_postmortem=True
38
+ ).first()
39
+
40
+ if not p1_priority:
41
+ pytest.skip("No P1/P2 priority found")
42
+
43
+ prd_env = Environment.objects.filter(value="PRD").first()
44
+ if not prd_env:
45
+ pytest.skip("No PRD environment found")
46
+
47
+ # Test all transitions
48
+ transitions = [
49
+ # Test transitions from each status
50
+ (IncidentStatus.OPEN, [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]),
51
+ (IncidentStatus.INVESTIGATING, [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]),
52
+ (IncidentStatus.MITIGATING, [IncidentStatus.MITIGATED]),
53
+ (IncidentStatus.MITIGATED, [IncidentStatus.POST_MORTEM]),
54
+ (IncidentStatus.POST_MORTEM, [IncidentStatus.CLOSED]),
55
+ ]
56
+
57
+ for current_status, expected_statuses in transitions:
58
+ incident = IncidentFactory.create(
59
+ priority=p1_priority,
60
+ environment=prd_env,
61
+ _status=current_status,
62
+ )
63
+
64
+ form = UpdateStatusForm(incident=incident)
65
+ status_choices = dict(form.fields["status"].choices)
66
+ available_statuses = list(status_choices.keys())
67
+
68
+ # Remove current status from available (can't transition to same status)
69
+ if current_status in available_statuses:
70
+ available_statuses.remove(current_status)
71
+
72
+ for expected_status in expected_statuses:
73
+ assert expected_status in available_statuses, (
74
+ f"P1/P2: From {current_status.label}, should be able to go to {expected_status.label}. "
75
+ f"Available: {[IncidentStatus(s).label for s in available_statuses]}"
76
+ )
77
+
78
+ # Verify no unexpected statuses are available
79
+ unexpected_statuses = set(available_statuses) - set(expected_statuses)
80
+ assert not unexpected_statuses, (
81
+ f"P1/P2: From {current_status.label}, unexpected statuses available: "
82
+ f"{[IncidentStatus(s).label for s in unexpected_statuses]}"
83
+ )
84
+
85
+ def test_p3_plus_complete_workflow_transitions(self):
86
+ """Test all P3+ workflow transitions according to the diagram."""
87
+ # Get P3+ priority
88
+ p3_priority = Priority.objects.filter(
89
+ value__gte=3, needs_postmortem=False
90
+ ).first()
91
+
92
+ if not p3_priority:
93
+ pytest.skip("No P3+ priority found")
94
+
95
+ # Test all transitions
96
+ transitions = [
97
+ # Test transitions from each status
98
+ (IncidentStatus.OPEN, [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]),
99
+ (IncidentStatus.INVESTIGATING, [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]),
100
+ (IncidentStatus.MITIGATING, [IncidentStatus.MITIGATED]),
101
+ (IncidentStatus.MITIGATED, [IncidentStatus.CLOSED]),
102
+ ]
103
+
104
+ for current_status, expected_statuses in transitions:
105
+ incident = IncidentFactory.create(
106
+ priority=p3_priority,
107
+ _status=current_status,
108
+ )
109
+
110
+ form = UpdateStatusForm(incident=incident)
111
+ status_choices = dict(form.fields["status"].choices)
112
+ available_statuses = list(status_choices.keys())
113
+
114
+ # Remove current status from available (can't transition to same status)
115
+ if current_status in available_statuses:
116
+ available_statuses.remove(current_status)
117
+
118
+ for expected_status in expected_statuses:
119
+ assert expected_status in available_statuses, (
120
+ f"P3+: From {current_status.label}, should be able to go to {expected_status.label}. "
121
+ f"Available: {[IncidentStatus(s).label for s in available_statuses]}"
122
+ )
123
+
124
+ # Verify no unexpected statuses are available (especially POST_MORTEM)
125
+ unexpected_statuses = set(available_statuses) - set(expected_statuses)
126
+ assert not unexpected_statuses, (
127
+ f"P3+: From {current_status.label}, unexpected statuses available: "
128
+ f"{[IncidentStatus(s).label for s in unexpected_statuses]}"
129
+ )
130
+
131
+ # Specifically verify POST_MORTEM is never available for P3+
132
+ assert IncidentStatus.POST_MORTEM not in available_statuses, (
133
+ f"P3+: POST_MORTEM should never be available, but found from {current_status.label}"
134
+ )
135
+
136
+ def test_closure_reason_requirements(self):
137
+ """Test that closure reason is required only from OPEN and INVESTIGATING."""
138
+ priorities = [
139
+ Priority.objects.filter(value__in=[1, 2]).first(), # P1/P2
140
+ Priority.objects.filter(value__gte=3).first(), # P3+
141
+ ]
142
+
143
+ for priority in priorities:
144
+ if not priority:
145
+ continue
146
+
147
+ # Should require reason from OPEN and INVESTIGATING
148
+ for status in [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING]:
149
+ incident = IncidentFactory.create(
150
+ priority=priority,
151
+ _status=status,
152
+ )
153
+
154
+ assert UpdateStatusForm.requires_closure_reason(
155
+ incident, IncidentStatus.CLOSED
156
+ ), f"Should require closure reason from {status.label} for {priority.name}"
157
+
158
+ # Should NOT require reason from other statuses
159
+ for status in [IncidentStatus.MITIGATING, IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM]:
160
+ incident = IncidentFactory.create(
161
+ priority=priority,
162
+ _status=status,
163
+ )
164
+
165
+ assert not UpdateStatusForm.requires_closure_reason(
166
+ incident, IncidentStatus.CLOSED
167
+ ), f"Should NOT require closure reason from {status.label} for {priority.name}"
@@ -100,9 +100,9 @@ def test_incident_create_unauthorized(client: Client) -> None:
100
100
 
101
101
  @pytest.mark.django_db
102
102
  @pytest.mark.usefixtures("_debug")
103
- def test_component_list(client: Client) -> None:
104
- """This test ensures that the component list is accessible."""
105
- response = client.get(reverse("incidents:component-list"))
103
+ def test_incident_category_list(client: Client) -> None:
104
+ """This test ensures that the incident category list is accessible."""
105
+ response = client.get(reverse("incidents:incident-category-list"))
106
106
 
107
107
  assert response.status_code == 302
108
108
 
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ import pytest
6
+ from django.utils import timezone
7
+
8
+ from firefighter.incidents.factories import GroupFactory, IncidentCategoryFactory
9
+ from firefighter.incidents.models.incident_category import (
10
+ IncidentCategory,
11
+ IncidentCategoryFilterSet,
12
+ IncidentCategoryManager,
13
+ )
14
+
15
+
16
+ @pytest.mark.django_db
17
+ class TestIncidentCategoryManager:
18
+ def test_queryset_with_mtbf_date_to_in_future(self):
19
+ """Test that date_to is clamped to now() when it's in the future."""
20
+ # Given
21
+ IncidentCategoryFactory()
22
+ manager = IncidentCategoryManager()
23
+ manager.model = IncidentCategory
24
+
25
+ # Create dates where date_to is in the future
26
+ now = timezone.now()
27
+ date_from = now - timedelta(days=10)
28
+ date_to = now + timedelta(days=5) # Future date
29
+
30
+ # When
31
+ result = manager.queryset_with_mtbf(date_from, date_to)
32
+
33
+ # Then
34
+ assert result is not None
35
+ assert list(result) == list(result) # Should not fail to execute
36
+
37
+ def test_queryset_with_mtbf_with_custom_queryset(self):
38
+ """Test that custom queryset parameter is used."""
39
+ # Given
40
+ category1 = IncidentCategoryFactory()
41
+ category2 = IncidentCategoryFactory()
42
+
43
+ manager = IncidentCategoryManager()
44
+ manager.model = IncidentCategory
45
+
46
+ # Create a custom queryset that filters only category1
47
+ custom_queryset = IncidentCategory.objects.filter(id=category1.id)
48
+
49
+ date_from = timezone.now() - timedelta(days=10)
50
+ date_to = timezone.now() - timedelta(days=1)
51
+
52
+ # When
53
+ result = manager.queryset_with_mtbf(date_from, date_to, queryset=custom_queryset)
54
+
55
+ # Then
56
+ assert category1 in result
57
+ assert category2 not in result
58
+
59
+ def test_search_with_none_queryset(self):
60
+ """Test search method when queryset is None."""
61
+ # Given
62
+ category = IncidentCategoryFactory(name="Test Category")
63
+
64
+ # When
65
+ result, is_empty = IncidentCategoryManager.search(None, "Test")
66
+
67
+ # Then
68
+ assert is_empty is False
69
+ assert category in result
70
+
71
+ def test_search_with_empty_search_term(self):
72
+ """Test search method with empty/None search term."""
73
+ # Given
74
+ IncidentCategoryFactory(name="Category One")
75
+ IncidentCategoryFactory(name="Category Two")
76
+ queryset = IncidentCategory.objects.all()
77
+
78
+ # When - Test with None
79
+ result_none, is_empty_none = IncidentCategoryManager.search(queryset, None)
80
+
81
+ # When - Test with empty string
82
+ result_empty, is_empty_empty = IncidentCategoryManager.search(queryset, "")
83
+
84
+ # When - Test with whitespace
85
+ result_spaces, is_empty_spaces = IncidentCategoryManager.search(queryset, " ")
86
+
87
+ # Then - All should return the original queryset
88
+ assert is_empty_none is False
89
+ assert is_empty_empty is False
90
+ assert is_empty_spaces is False
91
+
92
+ original_ids = set(queryset.values_list("id", flat=True))
93
+ assert set(result_none.values_list("id", flat=True)) == original_ids
94
+ assert set(result_empty.values_list("id", flat=True)) == original_ids
95
+ assert set(result_spaces.values_list("id", flat=True)) == original_ids
96
+
97
+ def test_search_with_valid_search_term(self):
98
+ """Test search method with valid search term."""
99
+ # Given
100
+ group = GroupFactory(name="Test Group", description="Group for testing")
101
+ category = IncidentCategoryFactory(
102
+ name="Infrastructure Issue",
103
+ description="Issues with infrastructure",
104
+ group=group
105
+ )
106
+ other_category = IncidentCategoryFactory(
107
+ name="Database Problem",
108
+ description="Database related issues"
109
+ )
110
+
111
+ # When - Search for "infrastructure"
112
+ result, is_empty = IncidentCategoryManager.search(None, "infrastructure")
113
+
114
+ # Then
115
+ assert is_empty is False
116
+ result_list = list(result)
117
+ assert category in result_list
118
+ # Note: other_category might also appear if search is broad
119
+
120
+ # When - Search for "database"
121
+ result_db, is_empty_db = IncidentCategoryManager.search(None, "database")
122
+
123
+ # Then
124
+ assert is_empty_db is False
125
+ result_db_list = list(result_db)
126
+ assert other_category in result_db_list
127
+
128
+
129
+ @pytest.mark.django_db
130
+ class TestIncidentCategoryFilterSet:
131
+ def test_incident_category_search(self):
132
+ """Test the incident_category_search filter method."""
133
+ # Given
134
+ category = IncidentCategoryFactory(name="Network Issues")
135
+ queryset = IncidentCategory.objects.all()
136
+
137
+ # When
138
+ result = IncidentCategoryFilterSet.incident_category_search(queryset, "search", "network")
139
+
140
+ # Then
141
+ assert category in result
142
+
143
+ def test_metrics_period_filter(self):
144
+ """Test the metrics period filter method."""
145
+ # Given
146
+ category = IncidentCategoryFactory()
147
+ queryset = IncidentCategory.objects.all()
148
+
149
+ # Create date range
150
+ now = timezone.now()
151
+ date_from = now - timedelta(days=30)
152
+ date_to = now
153
+ value = (date_from, date_to, None, None)
154
+
155
+ # When
156
+ result = IncidentCategoryFilterSet.metrics_period_filter(queryset, "metrics", value)
157
+
158
+ # Then
159
+ # Should return a queryset with mtbf annotation
160
+ assert result is not None
161
+ assert category in result
162
+
163
+ # Check that the mtbf annotation is present (will be None if no metrics)
164
+ category_with_mtbf = result.get(id=category.id)
165
+ assert hasattr(category_with_mtbf, "mtbf")