firefighter-incident 0.0.14__py3-none-any.whl → 0.0.16__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 (64) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/api/serializers.py +9 -0
  3. firefighter/confluence/signals/incident_updated.py +2 -2
  4. firefighter/incidents/enums.py +22 -2
  5. firefighter/incidents/forms/closure_reason.py +45 -0
  6. firefighter/incidents/forms/unified_incident.py +406 -0
  7. firefighter/incidents/forms/update_status.py +87 -1
  8. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  9. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  10. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  11. firefighter/incidents/models/incident.py +32 -5
  12. firefighter/incidents/static/css/main.min.css +1 -1
  13. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  14. firefighter/incidents/views/reports.py +3 -3
  15. firefighter/raid/apps.py +9 -26
  16. firefighter/raid/client.py +2 -2
  17. firefighter/raid/forms.py +75 -238
  18. firefighter/raid/signals/incident_created.py +38 -13
  19. firefighter/raid/signals/incident_updated.py +3 -2
  20. firefighter/slack/messages/slack_messages.py +19 -4
  21. firefighter/slack/rules.py +1 -1
  22. firefighter/slack/signals/create_incident_conversation.py +6 -0
  23. firefighter/slack/signals/incident_updated.py +7 -1
  24. firefighter/slack/views/modals/__init__.py +4 -0
  25. firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
  26. firefighter/slack/views/modals/close.py +15 -2
  27. firefighter/slack/views/modals/closure_reason.py +193 -0
  28. firefighter/slack/views/modals/open.py +60 -13
  29. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  30. firefighter/slack/views/modals/opening/select_impact.py +1 -1
  31. firefighter/slack/views/modals/opening/set_details.py +3 -2
  32. firefighter/slack/views/modals/postmortem.py +10 -2
  33. firefighter/slack/views/modals/update_status.py +28 -2
  34. firefighter/slack/views/modals/utils.py +51 -0
  35. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/METADATA +1 -1
  36. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/RECORD +62 -38
  37. firefighter_tests/test_incidents/test_enums.py +100 -0
  38. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  39. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  40. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  41. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  42. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  43. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  44. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  45. firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
  46. firefighter_tests/test_raid/conftest.py +154 -0
  47. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  48. firefighter_tests/test_raid/test_raid_forms.py +10 -253
  49. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  50. firefighter_tests/test_slack/messages/__init__.py +0 -0
  51. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  52. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  53. firefighter_tests/test_slack/views/modals/test_close.py +65 -3
  54. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  55. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  56. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  57. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  58. firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
  59. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  60. firefighter/raid/views/open_normal.py +0 -139
  61. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  62. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/WHEEL +0 -0
  63. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/entry_points.txt +0 -0
  64. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,367 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from firefighter.incidents.enums import IncidentStatus
6
+ from firefighter.incidents.factories import IncidentFactory, UserFactory
7
+ from firefighter.incidents.models import IncidentUpdate
8
+ from firefighter.slack.factories import IncidentChannelFactory
9
+ from firefighter.slack.messages.slack_messages import (
10
+ SlackMessageDeployWarning,
11
+ SlackMessageIncidentDeclaredAnnouncement,
12
+ SlackMessageIncidentStatusUpdated,
13
+ )
14
+
15
+
16
+ @pytest.mark.django_db
17
+ class TestSlackMessageIncidentStatusUpdated:
18
+ """Test SlackMessageIncidentStatusUpdated message generation."""
19
+
20
+ def test_title_when_status_mitigated_and_changed(self) -> None:
21
+ """Test that title mentions MITIGATED when incident reaches MITIGATED status.
22
+
23
+ Covers line 445: elif incident.status == IncidentStatus.MITIGATED and status_changed:
24
+ """
25
+ # Create an incident in MITIGATED status
26
+ incident = IncidentFactory.create(_status=IncidentStatus.MITIGATED)
27
+
28
+ # Create an IncidentChannel for slack_channel_name
29
+ IncidentChannelFactory.create(incident=incident)
30
+
31
+ # Create an IncidentUpdate
32
+ user = UserFactory.create()
33
+ incident_update = IncidentUpdate.objects.create(
34
+ incident=incident,
35
+ status=IncidentStatus.MITIGATED,
36
+ created_by=user
37
+ )
38
+
39
+ # Create the message with status_changed=True and in_channel=False
40
+ message = SlackMessageIncidentStatusUpdated(
41
+ incident=incident,
42
+ incident_update=incident_update,
43
+ in_channel=False,
44
+ status_changed=True
45
+ )
46
+
47
+ # Verify the title contains "Mitigated" (the status label)
48
+ assert message.title_text is not None
49
+ assert "Mitigated" in message.title_text
50
+ assert ":large_green_circle:" in message.title_text
51
+ assert incident.slack_channel_name in message.title_text
52
+
53
+ def test_title_when_status_not_mitigated(self) -> None:
54
+ """Test that title does not use MITIGATED-specific format when status is different."""
55
+ # Create an incident in INVESTIGATING status
56
+ incident = IncidentFactory.create(_status=IncidentStatus.INVESTIGATING)
57
+
58
+ # Create an IncidentChannel
59
+ IncidentChannelFactory.create(incident=incident)
60
+
61
+ # Create an IncidentUpdate
62
+ user = UserFactory.create()
63
+ incident_update = IncidentUpdate.objects.create(
64
+ incident=incident,
65
+ status=IncidentStatus.INVESTIGATING,
66
+ created_by=user
67
+ )
68
+
69
+ # Create the message with status_changed=True and in_channel=False
70
+ message = SlackMessageIncidentStatusUpdated(
71
+ incident=incident,
72
+ incident_update=incident_update,
73
+ in_channel=False,
74
+ status_changed=True
75
+ )
76
+
77
+ # Verify the title does NOT contain the MITIGATED-specific format
78
+ assert message.title_text is not None
79
+ assert ":large_green_circle:" not in message.title_text
80
+ assert "has received an update" in message.title_text
81
+
82
+ def test_no_update_button_when_incident_closed(self) -> None:
83
+ """Test that Update Status button is NOT displayed when incident is CLOSED.
84
+
85
+ This prevents showing an action button in an archived channel.
86
+ """
87
+ # Create an incident in CLOSED status
88
+ incident = IncidentFactory.create(_status=IncidentStatus.CLOSED)
89
+
90
+ # Create an IncidentChannel
91
+ IncidentChannelFactory.create(incident=incident)
92
+
93
+ # Create an IncidentUpdate with status change
94
+ user = UserFactory.create()
95
+ incident_update = IncidentUpdate.objects.create(
96
+ incident=incident,
97
+ status=IncidentStatus.CLOSED,
98
+ created_by=user
99
+ )
100
+
101
+ # Create the message with in_channel=True (normal case for in-channel messages)
102
+ message = SlackMessageIncidentStatusUpdated(
103
+ incident=incident,
104
+ incident_update=incident_update,
105
+ in_channel=True,
106
+ status_changed=True
107
+ )
108
+
109
+ # Get the blocks
110
+ blocks = message.get_blocks()
111
+
112
+ # Find any SectionBlock with the "message_status_update" block_id
113
+ status_update_block = None
114
+ for block in blocks:
115
+ if hasattr(block, "block_id") and block.block_id == "message_status_update":
116
+ status_update_block = block
117
+ break
118
+
119
+ # If there is a status update block, verify it has NO accessory (button)
120
+ if status_update_block:
121
+ assert status_update_block.accessory is None, "Update button should not be present when incident is CLOSED"
122
+
123
+ def test_update_button_shown_when_incident_not_closed(self) -> None:
124
+ """Test that Update Status button IS displayed when incident is NOT CLOSED."""
125
+ # Create an incident in INVESTIGATING status (not closed)
126
+ incident = IncidentFactory.create(_status=IncidentStatus.INVESTIGATING)
127
+
128
+ # Create an IncidentChannel
129
+ IncidentChannelFactory.create(incident=incident)
130
+
131
+ # Create an IncidentUpdate
132
+ user = UserFactory.create()
133
+ incident_update = IncidentUpdate.objects.create(
134
+ incident=incident,
135
+ status=IncidentStatus.INVESTIGATING,
136
+ created_by=user
137
+ )
138
+
139
+ # Create the message with in_channel=True
140
+ message = SlackMessageIncidentStatusUpdated(
141
+ incident=incident,
142
+ incident_update=incident_update,
143
+ in_channel=True,
144
+ status_changed=True
145
+ )
146
+
147
+ # Get the blocks
148
+ blocks = message.get_blocks()
149
+
150
+ # Find the SectionBlock with "message_status_update" block_id
151
+ status_update_block = None
152
+ for block in blocks:
153
+ if hasattr(block, "block_id") and block.block_id == "message_status_update":
154
+ status_update_block = block
155
+ break
156
+
157
+ # Verify the block has an accessory (Update button)
158
+ assert status_update_block is not None, "Should have a status update block"
159
+ assert status_update_block.accessory is not None, "Update button should be present when incident is not CLOSED"
160
+ assert status_update_block.accessory.text.text == "Update"
161
+
162
+
163
+ @pytest.mark.django_db
164
+ class TestSlackMessageDeployWarning:
165
+ """Test SlackMessageDeployWarning message generation."""
166
+
167
+ def test_header_when_incident_mitigated(self) -> None:
168
+ """Test that header includes '(Mitigated)' when incident is MITIGATED.
169
+
170
+ Covers line 694: text=f":warning: Deploy warning {'(Mitigated) ' if self.incident.status == IncidentStatus.MITIGATED else ''}:warning:"
171
+ """
172
+ # Create an incident in MITIGATED status
173
+ incident = IncidentFactory.create(_status=IncidentStatus.MITIGATED)
174
+
175
+ # Create an IncidentChannel for conversation.name
176
+ IncidentChannelFactory.create(incident=incident)
177
+
178
+ # Create the deploy warning message
179
+ message = SlackMessageDeployWarning(incident=incident)
180
+
181
+ # Get the blocks
182
+ blocks = message.get_blocks()
183
+
184
+ # The first block should be a HeaderBlock with "(Mitigated)" in the text
185
+ header_block = blocks[0]
186
+ header_text = header_block.text.text # type: ignore[attr-defined]
187
+
188
+ assert "(Mitigated)" in header_text
189
+ assert ":warning:" in header_text
190
+
191
+ def test_header_when_incident_not_mitigated(self) -> None:
192
+ """Test that header does NOT include '(Mitigated)' when incident is not MITIGATED."""
193
+ # Create an incident in INVESTIGATING status
194
+ incident = IncidentFactory.create(_status=IncidentStatus.INVESTIGATING)
195
+
196
+ # Create an IncidentChannel
197
+ IncidentChannelFactory.create(incident=incident)
198
+
199
+ # Create the deploy warning message
200
+ message = SlackMessageDeployWarning(incident=incident)
201
+
202
+ # Get the blocks
203
+ blocks = message.get_blocks()
204
+
205
+ # The first block should be a HeaderBlock WITHOUT "(Mitigated)"
206
+ header_block = blocks[0]
207
+ header_text = header_block.text.text # type: ignore[attr-defined]
208
+
209
+ assert "(Mitigated)" not in header_text
210
+ assert ":warning:" in header_text
211
+
212
+ def test_additional_blocks_when_incident_mitigated_or_above(self) -> None:
213
+ """Test that additional blocks are added when incident status >= MITIGATED.
214
+
215
+ Covers line 705: if self.incident.status >= IncidentStatus.MITIGATED:
216
+ """
217
+ # Create an incident in MITIGATED status
218
+ incident = IncidentFactory.create(_status=IncidentStatus.MITIGATED)
219
+
220
+ # Create an IncidentChannel
221
+ IncidentChannelFactory.create(incident=incident)
222
+
223
+ # Create the deploy warning message
224
+ message = SlackMessageDeployWarning(incident=incident)
225
+
226
+ # Get the blocks
227
+ blocks = message.get_blocks()
228
+
229
+ # When status >= MITIGATED, there should be MORE than 2 blocks
230
+ # (HeaderBlock + SectionBlock + additional blocks from line 706)
231
+ assert len(blocks) > 2
232
+
233
+ def test_no_additional_blocks_when_incident_below_mitigated(self) -> None:
234
+ """Test that NO additional blocks are added when incident status < MITIGATED."""
235
+ # Create an incident in INVESTIGATING status (below MITIGATED)
236
+ incident = IncidentFactory.create(_status=IncidentStatus.INVESTIGATING)
237
+
238
+ # Create an IncidentChannel
239
+ IncidentChannelFactory.create(incident=incident)
240
+
241
+ # Create the deploy warning message
242
+ message = SlackMessageDeployWarning(incident=incident)
243
+
244
+ # Get the blocks
245
+ blocks = message.get_blocks()
246
+
247
+ # When status < MITIGATED, there should be exactly 2 blocks
248
+ # (HeaderBlock + SectionBlock only, no additional blocks)
249
+ assert len(blocks) == 2
250
+
251
+
252
+ @pytest.mark.django_db
253
+ class TestSlackMessageIncidentDeclaredAnnouncement:
254
+ """Test SlackMessageIncidentDeclaredAnnouncement message with custom fields."""
255
+
256
+ def test_custom_fields_displayed_when_present(self) -> None:
257
+ """Test that custom fields are displayed in the incident announcement."""
258
+ # Create an incident with custom fields
259
+ incident = IncidentFactory.create(
260
+ custom_fields={
261
+ "zendesk_ticket_id": "12345",
262
+ "seller_contract_id": "SELLER-67890",
263
+ "zoho_desk_ticket_id": "ZD-11111",
264
+ "is_key_account": True,
265
+ "is_seller_in_golden_list": True,
266
+ }
267
+ )
268
+
269
+ # Create the message
270
+ message = SlackMessageIncidentDeclaredAnnouncement(incident=incident)
271
+
272
+ # Get the blocks
273
+ blocks = message.get_blocks()
274
+
275
+ # Find the SectionBlock with fields (should contain custom fields)
276
+ # This is typically the 4th block (index 3)
277
+ fields_block = None
278
+ for block in blocks:
279
+ if hasattr(block, "fields") and block.fields:
280
+ fields_block = block
281
+ break
282
+
283
+ assert fields_block is not None, "Should have a block with fields"
284
+
285
+ # Convert fields to strings for easier assertion (access .text attribute)
286
+ fields_text = " ".join(field.text if hasattr(field, "text") else str(field) for field in fields_block.fields)
287
+
288
+ # Verify custom fields are present
289
+ assert "Zendesk Ticket" in fields_text
290
+ assert "12345" in fields_text
291
+ assert "Seller Contract" in fields_text
292
+ assert "SELLER-67890" in fields_text
293
+ assert "Zoho Desk Ticket" in fields_text
294
+ assert "ZD-11111" in fields_text
295
+ assert "Key Account" in fields_text
296
+ assert "Golden List Seller" in fields_text
297
+
298
+ def test_custom_fields_not_displayed_when_absent(self) -> None:
299
+ """Test that custom fields are NOT displayed when not present."""
300
+ # Create an incident WITHOUT custom fields
301
+ incident = IncidentFactory.create(custom_fields={})
302
+
303
+ # Create the message
304
+ message = SlackMessageIncidentDeclaredAnnouncement(incident=incident)
305
+
306
+ # Get the blocks
307
+ blocks = message.get_blocks()
308
+
309
+ # Find the SectionBlock with fields
310
+ fields_block = None
311
+ for block in blocks:
312
+ if hasattr(block, "fields") and block.fields:
313
+ fields_block = block
314
+ break
315
+
316
+ assert fields_block is not None, "Should have a block with fields"
317
+
318
+ # Convert fields to strings (access .text attribute)
319
+ fields_text = " ".join(field.text if hasattr(field, "text") else str(field) for field in fields_block.fields)
320
+
321
+ # Verify custom fields are NOT present
322
+ assert "Zendesk Ticket" not in fields_text
323
+ assert "Seller Contract" not in fields_text
324
+ assert "Zoho Desk Ticket" not in fields_text
325
+ assert "Key Account" not in fields_text
326
+ assert "Golden List Seller" not in fields_text
327
+
328
+ def test_partial_custom_fields_displayed(self) -> None:
329
+ """Test that only filled custom fields are displayed."""
330
+ # Create an incident with only some custom fields
331
+ incident = IncidentFactory.create(
332
+ custom_fields={
333
+ "zendesk_ticket_id": "12345",
334
+ # seller_contract_id not set
335
+ # zoho_desk_ticket_id not set
336
+ "is_key_account": False, # False should not display
337
+ # is_seller_in_golden_list not set
338
+ }
339
+ )
340
+
341
+ # Create the message
342
+ message = SlackMessageIncidentDeclaredAnnouncement(incident=incident)
343
+
344
+ # Get the blocks
345
+ blocks = message.get_blocks()
346
+
347
+ # Find the SectionBlock with fields
348
+ fields_block = None
349
+ for block in blocks:
350
+ if hasattr(block, "fields") and block.fields:
351
+ fields_block = block
352
+ break
353
+
354
+ assert fields_block is not None, "Should have a block with fields"
355
+
356
+ # Convert fields to strings (access .text attribute)
357
+ fields_text = " ".join(field.text if hasattr(field, "text") else str(field) for field in fields_block.fields)
358
+
359
+ # Verify only zendesk_ticket_id is present
360
+ assert "Zendesk Ticket" in fields_text
361
+ assert "12345" in fields_text
362
+
363
+ # Verify others are NOT present
364
+ assert "Seller Contract" not in fields_text
365
+ assert "Zoho Desk Ticket" not in fields_text
366
+ assert "Key Account" not in fields_text
367
+ assert "Golden List Seller" not in fields_text
@@ -0,0 +1,140 @@
1
+ """Fixtures for unified modal tests."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+
6
+ from firefighter.incidents.models import Environment, Priority
7
+ from firefighter.incidents.models.impact import ImpactLevel, ImpactType, LevelChoices
8
+
9
+
10
+ @pytest.fixture
11
+ def priority_factory(db):
12
+ """Factory to create Priority instances."""
13
+
14
+ def _create(**kwargs):
15
+ value = kwargs.get("value", 1)
16
+ name = kwargs.get("name", f"P{value}")
17
+ set_as_default = kwargs.get("default", False)
18
+
19
+ # If default=True, clear any other defaults first
20
+ if set_as_default:
21
+ Priority.objects.filter(default=True).update(default=False)
22
+
23
+ defaults = {
24
+ "emoji": "🔴",
25
+ "order": value,
26
+ "default": set_as_default,
27
+ "enabled_create": True,
28
+ "enabled_update": True,
29
+ "needs_postmortem": value <= 2, # P1-P2 need postmortem
30
+ }
31
+ # Remove name and value from kwargs if present
32
+ kwargs_copy = kwargs.copy()
33
+ kwargs_copy.pop("name", None)
34
+ kwargs_copy.pop("value", None)
35
+ defaults.update(kwargs_copy)
36
+
37
+ priority, created = Priority.objects.get_or_create(
38
+ name=name,
39
+ value=value,
40
+ defaults=defaults,
41
+ )
42
+
43
+ # If already exists and we want it as default, just set that
44
+ if not created and set_as_default:
45
+ priority.default = True
46
+ priority.save(update_fields=["default"])
47
+
48
+ return priority
49
+
50
+ return _create
51
+
52
+
53
+ @pytest.fixture
54
+ def environment_factory(db):
55
+ """Factory to create Environment instances."""
56
+
57
+ def _create(**kwargs):
58
+ value = kwargs.get("value", "TST")
59
+ set_as_default = kwargs.get("default", False)
60
+
61
+ # If default=True, clear any other defaults first
62
+ if set_as_default:
63
+ Environment.objects.filter(default=True).update(default=False)
64
+
65
+ defaults = {
66
+ "description": f"Environment {value}",
67
+ "order": 1,
68
+ "default": set_as_default,
69
+ }
70
+ # Remove value and default from kwargs if present
71
+ kwargs_copy = kwargs.copy()
72
+ kwargs_copy.pop("value", None)
73
+ kwargs_copy.pop("default", None)
74
+ defaults.update(kwargs_copy)
75
+
76
+ environment, created = Environment.objects.get_or_create(
77
+ value=value,
78
+ defaults=defaults,
79
+ )
80
+
81
+ # If already exists and we want it as default, just set that
82
+ if not created and set_as_default:
83
+ environment.default = True
84
+ environment.save(update_fields=["default"])
85
+
86
+ return environment
87
+
88
+ return _create
89
+
90
+
91
+ @pytest.fixture
92
+ def impact_level_factory(db):
93
+ """Factory to create ImpactLevel instances."""
94
+
95
+ def _create(**kwargs):
96
+ # Handle impact__name syntax by extracting nested parameters
97
+ impact_type_data = {}
98
+ keys_to_remove = []
99
+ for key in list(kwargs.keys()):
100
+ if key.startswith("impact__"):
101
+ nested_key = key.split("__", 1)[1]
102
+ impact_type_data[nested_key] = kwargs[key]
103
+ keys_to_remove.append(key)
104
+ for key in keys_to_remove:
105
+ kwargs.pop(key)
106
+
107
+ # Create or get ImpactType
108
+ impact_type = kwargs.pop("impact_type", None)
109
+ if isinstance(impact_type, ImpactType):
110
+ pass # Already have ImpactType instance
111
+ elif impact_type_data:
112
+ impact_type_name = impact_type_data.get("name", "Test Impact")
113
+ impact_type, _ = ImpactType.objects.get_or_create(name=impact_type_name, defaults={
114
+ "emoji": "📊",
115
+ "help_text": f"Test {impact_type_name} impact",
116
+ "value": impact_type_name.lower().replace(" ", "_"),
117
+ "order": 10,
118
+ })
119
+ else:
120
+ impact_type_name = "Test Impact"
121
+ impact_type, _ = ImpactType.objects.get_or_create(name=impact_type_name, defaults={
122
+ "emoji": "📊",
123
+ "help_text": "Test impact",
124
+ "value": "test_impact",
125
+ "order": 10,
126
+ })
127
+
128
+ # Handle value parameter
129
+ value = kwargs.pop("value", LevelChoices.LOW)
130
+
131
+ defaults = {
132
+ "impact_type": impact_type,
133
+ "value": value,
134
+ "name": value.label if hasattr(value, "label") else "Test Level",
135
+ "emoji": "📊",
136
+ }
137
+ defaults.update(kwargs)
138
+ return ImpactLevel.objects.create(**defaults)
139
+
140
+ return _create
@@ -27,7 +27,7 @@ class TestCloseModal:
27
27
  new_callable=PropertyMock(return_value=(True, [])),
28
28
  )
29
29
  incident = IncidentFactory.build()
30
- incident.status = IncidentStatus.FIXED
30
+ incident.status = IncidentStatus.MITIGATED
31
31
 
32
32
  # Act
33
33
  res = modal.build_modal_fn(incident=incident, body={})
@@ -54,7 +54,7 @@ class TestCloseModal:
54
54
  return_value=(True, []),
55
55
  new_callable=mocker.PropertyMock,
56
56
  )
57
- incident.status = IncidentStatus.FIXED
57
+ incident.status = IncidentStatus.MITIGATED
58
58
  incident.title = "This is the title"
59
59
  incident.description = "This is the description"
60
60
 
@@ -72,7 +72,8 @@ class TestCloseModal:
72
72
  def test_close_modal_build_cant_close(incident: Incident) -> None:
73
73
  # Arrange
74
74
  modal = CloseModal()
75
- incident.status = IncidentStatus.OPEN
75
+ # Use MITIGATING (Mitigating) status - cannot close from this status without going through MITIGATED
76
+ incident.status = IncidentStatus.MITIGATING
76
77
 
77
78
  # Act
78
79
  res = modal.build_modal_fn(incident=incident, body={})
@@ -90,6 +91,67 @@ class TestCloseModal:
90
91
  "This incident can't be closed yet." in values["blocks"][0]["text"]["text"]
91
92
  )
92
93
 
94
+ @staticmethod
95
+ def test_close_modal_build_shows_mitigated_status_requirement(
96
+ mocker: MockerFixture, incident: Incident
97
+ ) -> None:
98
+ """Test that the modal shows STATUS_NOT_MITIGATED error with proper references.
99
+
100
+ This test covers lines 151, 154, 159 in close.py where MITIGATED.label is used.
101
+ """
102
+ # Arrange
103
+ modal = CloseModal()
104
+ incident.status = IncidentStatus.INVESTIGATING
105
+
106
+ # Mock requires_closure_reason to return False so we bypass the closure reason modal
107
+ mocker.patch(
108
+ "firefighter.slack.views.modals.utils.UpdateStatusForm.requires_closure_reason",
109
+ return_value=False
110
+ )
111
+
112
+ # Mock can_be_closed to return False with STATUS_NOT_MITIGATED reason
113
+ mocker.patch.object(
114
+ Incident,
115
+ "can_be_closed",
116
+ new_callable=PropertyMock(return_value=(False, [("STATUS_NOT_MITIGATED", "Status not mitigated")])),
117
+ )
118
+
119
+ # Act
120
+ res = modal.build_modal_fn(incident=incident, body={})
121
+
122
+ # Assert
123
+ assert res.to_dict()
124
+ values = res.to_dict()
125
+ assert "blocks" in values
126
+
127
+ # Convert blocks to text for easier searching
128
+ blocks_text = str(values["blocks"])
129
+
130
+ # Verify that "Mitigated" (the label) appears in the error message
131
+ # Line 154: text=f":warning: *Status is not _{IncidentStatus.MITIGATED.label}_* :warning:\n"
132
+ # Line 159: text=f"You can only close an incident when its status is _{IncidentStatus.MITIGATED.label}_ or _{IncidentStatus.POST_MORTEM.label}_..."
133
+ assert "Mitigated" in blocks_text
134
+ assert "Post-mortem" in blocks_text or "Post Mortem" in blocks_text
135
+
136
+ # Verify the error message structure
137
+ assert "This incident can't be closed yet." in values["blocks"][0]["text"]["text"]
138
+
139
+ @staticmethod
140
+ def test_close_modal_build_shows_closure_reason_from_open(incident: Incident) -> None:
141
+ # Arrange
142
+ modal = CloseModal()
143
+ incident.status = IncidentStatus.OPEN
144
+
145
+ # Act
146
+ res = modal.build_modal_fn(incident=incident, body={})
147
+
148
+ # Assert
149
+ assert res.to_dict()
150
+ values = res.to_dict()
151
+ assert "blocks" in values
152
+ # Should show closure reason form
153
+ assert "Closure Reason Required" in values["blocks"][0]["text"]["text"]
154
+
93
155
  @staticmethod
94
156
  def test_submit_empty_bodied_form() -> None:
95
157
  modal = CloseModal()