firefighter-incident 0.0.14__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.
- firefighter/_version.py +2 -2
- firefighter/api/serializers.py +9 -0
- firefighter/confluence/signals/incident_updated.py +2 -2
- firefighter/incidents/enums.py +22 -2
- firefighter/incidents/forms/closure_reason.py +45 -0
- firefighter/incidents/forms/unified_incident.py +406 -0
- firefighter/incidents/forms/update_status.py +87 -1
- firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
- firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
- firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
- firefighter/incidents/models/incident.py +32 -5
- firefighter/incidents/static/css/main.min.css +1 -1
- firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
- firefighter/incidents/views/reports.py +3 -3
- firefighter/raid/apps.py +9 -26
- firefighter/raid/client.py +2 -2
- firefighter/raid/forms.py +75 -238
- firefighter/raid/signals/incident_created.py +38 -13
- firefighter/raid/signals/incident_updated.py +3 -2
- firefighter/slack/messages/slack_messages.py +19 -4
- firefighter/slack/rules.py +1 -1
- firefighter/slack/signals/create_incident_conversation.py +6 -0
- firefighter/slack/signals/incident_updated.py +7 -1
- firefighter/slack/views/modals/__init__.py +4 -0
- firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
- firefighter/slack/views/modals/close.py +15 -2
- firefighter/slack/views/modals/closure_reason.py +193 -0
- firefighter/slack/views/modals/open.py +59 -12
- firefighter/slack/views/modals/opening/details/unified.py +203 -0
- firefighter/slack/views/modals/opening/set_details.py +3 -2
- firefighter/slack/views/modals/postmortem.py +10 -2
- firefighter/slack/views/modals/update_status.py +28 -2
- firefighter/slack/views/modals/utils.py +51 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +61 -37
- firefighter_tests/test_incidents/test_enums.py +100 -0
- firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
- firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
- firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
- firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
- firefighter_tests/test_raid/conftest.py +154 -0
- firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
- firefighter_tests/test_raid/test_raid_forms.py +10 -253
- firefighter_tests/test_raid/test_raid_signals.py +187 -0
- firefighter_tests/test_slack/messages/__init__.py +0 -0
- firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
- firefighter_tests/test_slack/views/modals/conftest.py +140 -0
- firefighter_tests/test_slack/views/modals/test_close.py +65 -3
- firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
- firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
- firefighter_tests/test_slack/views/modals/test_open.py +146 -2
- firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
- firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
- firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
- firefighter/raid/views/open_normal.py +0 -139
- firefighter/slack/views/modals/opening/details/critical.py +0 -88
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
-
from unittest.mock import MagicMock
|
|
4
|
+
from unittest.mock import MagicMock, PropertyMock
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
7
|
from django.conf import settings
|
|
8
8
|
from pytest_mock import MockerFixture
|
|
9
9
|
|
|
10
|
+
from firefighter.incidents.enums import IncidentStatus
|
|
10
11
|
from firefighter.incidents.factories import IncidentFactory, UserFactory
|
|
11
12
|
from firefighter.incidents.models import Incident
|
|
12
13
|
from firefighter.slack.views import UpdateStatusModal
|
|
@@ -47,7 +48,10 @@ class TestUpdateStatusModal:
|
|
|
47
48
|
modal.handle_modal_fn(ack=ack, body={}, incident=incident, user=user)
|
|
48
49
|
|
|
49
50
|
@staticmethod
|
|
50
|
-
def test_submit_valid_form(mocker: MockerFixture
|
|
51
|
+
def test_submit_valid_form(mocker: MockerFixture) -> None:
|
|
52
|
+
# Create an incident in OPEN status so we can transition to INVESTIGATING
|
|
53
|
+
incident = IncidentFactory.build(_status=IncidentStatus.OPEN) # OPEN status
|
|
54
|
+
|
|
51
55
|
modal = UpdateStatusModal()
|
|
52
56
|
trigger_incident_workflow = mocker.patch.object(
|
|
53
57
|
modal, "_trigger_incident_workflow"
|
|
@@ -56,14 +60,326 @@ class TestUpdateStatusModal:
|
|
|
56
60
|
ack = MagicMock()
|
|
57
61
|
user = UserFactory.build()
|
|
58
62
|
user.save()
|
|
63
|
+
|
|
64
|
+
# Create a valid submission that transitions from OPEN to INVESTIGATING (valid workflow)
|
|
65
|
+
valid_submission_copy = dict(valid_submission)
|
|
66
|
+
# Change status to INVESTIGATING (20) which is valid from OPEN
|
|
67
|
+
valid_submission_copy["view"]["state"]["values"]["status"]["status"]["selected_option"] = {
|
|
68
|
+
"text": {"type": "plain_text", "text": "Investigating", "emoji": True},
|
|
69
|
+
"value": "20",
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
modal.handle_modal_fn(
|
|
60
|
-
ack=ack, body=
|
|
73
|
+
ack=ack, body=valid_submission_copy, incident=incident, user=user
|
|
61
74
|
)
|
|
62
75
|
|
|
63
76
|
# Assert
|
|
64
77
|
ack.assert_called_once_with()
|
|
65
78
|
trigger_incident_workflow.assert_called_once()
|
|
66
79
|
|
|
80
|
+
@staticmethod
|
|
81
|
+
def test_cannot_close_without_required_key_events(mocker: MockerFixture) -> None:
|
|
82
|
+
"""Test that closing is prevented when required key events are missing.
|
|
83
|
+
|
|
84
|
+
This tests the scenario where a P3+ incident (no postmortem needed) is in
|
|
85
|
+
MITIGATED status and tries to close, but missing key events blocks it.
|
|
86
|
+
"""
|
|
87
|
+
# Create a user first
|
|
88
|
+
user = UserFactory.build()
|
|
89
|
+
user.save()
|
|
90
|
+
|
|
91
|
+
# Create a P3+ incident in MITIGATED status (can go directly to CLOSED)
|
|
92
|
+
incident = IncidentFactory.build(
|
|
93
|
+
_status=IncidentStatus.MITIGATED,
|
|
94
|
+
created_by=user,
|
|
95
|
+
)
|
|
96
|
+
incident.save()
|
|
97
|
+
# Mock needs_postmortem to return False (P3+ incident)
|
|
98
|
+
mocker.patch.object(
|
|
99
|
+
type(incident),
|
|
100
|
+
"needs_postmortem",
|
|
101
|
+
new_callable=PropertyMock,
|
|
102
|
+
return_value=False
|
|
103
|
+
)
|
|
104
|
+
# Mock can_be_closed to return False with MISSING_REQUIRED_KEY_EVENTS reason
|
|
105
|
+
mocker.patch.object(
|
|
106
|
+
type(incident),
|
|
107
|
+
"can_be_closed",
|
|
108
|
+
new_callable=PropertyMock,
|
|
109
|
+
return_value=(False, [("MISSING_REQUIRED_KEY_EVENTS", "Missing key events: detected, started")])
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
modal = UpdateStatusModal()
|
|
113
|
+
trigger_incident_workflow = mocker.patch.object(
|
|
114
|
+
modal, "_trigger_incident_workflow"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
ack = MagicMock()
|
|
118
|
+
user = UserFactory.build()
|
|
119
|
+
user.save()
|
|
120
|
+
|
|
121
|
+
# Create a submission trying to close the incident
|
|
122
|
+
submission_copy = dict(valid_submission)
|
|
123
|
+
# Change status to CLOSED (60)
|
|
124
|
+
submission_copy["view"]["state"]["values"]["status"]["status"]["selected_option"] = {
|
|
125
|
+
"text": {"type": "plain_text", "text": "Closed", "emoji": True},
|
|
126
|
+
"value": "60",
|
|
127
|
+
}
|
|
128
|
+
# Update the private_metadata to match our test incident
|
|
129
|
+
submission_copy["view"]["private_metadata"] = str(incident.id)
|
|
130
|
+
|
|
131
|
+
modal.handle_modal_fn(
|
|
132
|
+
ack=ack, body=submission_copy, incident=incident, user=user
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Assert that ack was called with errors (may be 1 or 2 calls depending on form validation)
|
|
136
|
+
assert ack.called
|
|
137
|
+
# Check the last call (the error response)
|
|
138
|
+
last_call_kwargs = ack.call_args.kwargs
|
|
139
|
+
assert "response_action" in last_call_kwargs
|
|
140
|
+
assert last_call_kwargs["response_action"] == "errors"
|
|
141
|
+
assert "errors" in last_call_kwargs
|
|
142
|
+
assert "status" in last_call_kwargs["errors"]
|
|
143
|
+
# Check that the error message mentions the missing key events
|
|
144
|
+
error_msg = last_call_kwargs["errors"]["status"]
|
|
145
|
+
assert "Cannot close this incident" in error_msg
|
|
146
|
+
assert "Missing key events" in error_msg
|
|
147
|
+
|
|
148
|
+
# Verify that incident update was NOT triggered
|
|
149
|
+
trigger_incident_workflow.assert_not_called()
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def test_cannot_close_from_postmortem_without_key_events(mocker: MockerFixture) -> None:
|
|
153
|
+
"""Test that closing from POST_MORTEM is prevented when key events missing.
|
|
154
|
+
|
|
155
|
+
This tests a P1/P2 incident in POST_MORTEM trying to close but blocked
|
|
156
|
+
by missing key events.
|
|
157
|
+
"""
|
|
158
|
+
# Create a user first
|
|
159
|
+
user = UserFactory.build()
|
|
160
|
+
user.save()
|
|
161
|
+
|
|
162
|
+
# Create a P1/P2 incident in POST_MORTEM status
|
|
163
|
+
incident = IncidentFactory.build(
|
|
164
|
+
_status=IncidentStatus.POST_MORTEM,
|
|
165
|
+
created_by=user,
|
|
166
|
+
)
|
|
167
|
+
incident.save()
|
|
168
|
+
# Mock can_be_closed to return False with MISSING_REQUIRED_KEY_EVENTS reason
|
|
169
|
+
mocker.patch.object(
|
|
170
|
+
type(incident),
|
|
171
|
+
"can_be_closed",
|
|
172
|
+
new_callable=PropertyMock,
|
|
173
|
+
return_value=(False, [("MISSING_REQUIRED_KEY_EVENTS", "Missing key events: detected, started")])
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
modal = UpdateStatusModal()
|
|
177
|
+
trigger_incident_workflow = mocker.patch.object(
|
|
178
|
+
modal, "_trigger_incident_workflow"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
ack = MagicMock()
|
|
182
|
+
user = UserFactory.build()
|
|
183
|
+
user.save()
|
|
184
|
+
|
|
185
|
+
# Create a submission trying to close the incident
|
|
186
|
+
submission_copy = dict(valid_submission)
|
|
187
|
+
submission_copy["view"]["state"]["values"]["status"]["status"]["selected_option"] = {
|
|
188
|
+
"text": {"type": "plain_text", "text": "Closed", "emoji": True},
|
|
189
|
+
"value": "60",
|
|
190
|
+
}
|
|
191
|
+
submission_copy["view"]["private_metadata"] = str(incident.id)
|
|
192
|
+
|
|
193
|
+
modal.handle_modal_fn(
|
|
194
|
+
ack=ack, body=submission_copy, incident=incident, user=user
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Assert that ack was called with errors
|
|
198
|
+
assert ack.called
|
|
199
|
+
last_call_kwargs = ack.call_args.kwargs
|
|
200
|
+
assert "response_action" in last_call_kwargs
|
|
201
|
+
assert last_call_kwargs["response_action"] == "errors"
|
|
202
|
+
assert "errors" in last_call_kwargs
|
|
203
|
+
assert "status" in last_call_kwargs["errors"]
|
|
204
|
+
error_msg = last_call_kwargs["errors"]["status"]
|
|
205
|
+
assert "Cannot close this incident" in error_msg
|
|
206
|
+
assert "Missing key events" in error_msg
|
|
207
|
+
|
|
208
|
+
# Verify that incident update was NOT triggered
|
|
209
|
+
trigger_incident_workflow.assert_not_called()
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def test_cannot_close_p1_p2_without_postmortem(mocker: MockerFixture, priority_factory, environment_factory) -> None:
|
|
213
|
+
"""Test that P1/P2 incidents in PRD cannot be closed directly from INVESTIGATING.
|
|
214
|
+
|
|
215
|
+
For P1/P2 incidents requiring post-mortem, although the form allows CLOSED as an option
|
|
216
|
+
from INVESTIGATING status, the can_be_closed validation should prevent closure with
|
|
217
|
+
an error message about needing to go through post-mortem.
|
|
218
|
+
"""
|
|
219
|
+
# Create a user first
|
|
220
|
+
user = UserFactory.build()
|
|
221
|
+
user.save()
|
|
222
|
+
|
|
223
|
+
# Create P1 priority (needs_postmortem=True) and PRD environment
|
|
224
|
+
p1_priority = priority_factory(value=1, name="P1", needs_postmortem=True)
|
|
225
|
+
prd_environment = environment_factory(value="PRD", name="Production")
|
|
226
|
+
|
|
227
|
+
# Create a P1/P2 incident in INVESTIGATING status
|
|
228
|
+
# From INVESTIGATING, the form allows transitioning to CLOSED (but can_be_closed will block it)
|
|
229
|
+
incident = IncidentFactory.build(
|
|
230
|
+
_status=IncidentStatus.INVESTIGATING,
|
|
231
|
+
created_by=user,
|
|
232
|
+
priority=p1_priority,
|
|
233
|
+
environment=prd_environment,
|
|
234
|
+
)
|
|
235
|
+
incident.save()
|
|
236
|
+
# Mock can_be_closed to return False with STATUS_NOT_POST_MORTEM reason
|
|
237
|
+
mocker.patch.object(
|
|
238
|
+
type(incident),
|
|
239
|
+
"can_be_closed",
|
|
240
|
+
new_callable=PropertyMock,
|
|
241
|
+
return_value=(False, [("STATUS_NOT_POST_MORTEM", "Incident is not in PostMortem status, and needs one because of its priority and environment (P1/PRD).")])
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
modal = UpdateStatusModal()
|
|
245
|
+
|
|
246
|
+
# Mock handle_update_status_close_request to NOT show closure reason modal
|
|
247
|
+
# This allows the test to reach the can_be_closed validation
|
|
248
|
+
mocker.patch(
|
|
249
|
+
"firefighter.slack.views.modals.update_status.handle_update_status_close_request",
|
|
250
|
+
return_value=False
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
trigger_incident_workflow = mocker.patch.object(
|
|
254
|
+
modal, "_trigger_incident_workflow"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
ack = MagicMock()
|
|
258
|
+
user = UserFactory.build()
|
|
259
|
+
user.save()
|
|
260
|
+
|
|
261
|
+
# Create a submission trying to close the incident
|
|
262
|
+
submission_copy = dict(valid_submission)
|
|
263
|
+
submission_copy["view"]["state"]["values"]["status"]["status"]["selected_option"] = {
|
|
264
|
+
"text": {"type": "plain_text", "text": "Closed", "emoji": True},
|
|
265
|
+
"value": "60",
|
|
266
|
+
}
|
|
267
|
+
submission_copy["view"]["private_metadata"] = str(incident.id)
|
|
268
|
+
|
|
269
|
+
modal.handle_modal_fn(
|
|
270
|
+
ack=ack, body=submission_copy, incident=incident, user=user
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
# Assert that ack was called with errors
|
|
274
|
+
assert ack.called
|
|
275
|
+
last_call_kwargs = ack.call_args.kwargs
|
|
276
|
+
assert "response_action" in last_call_kwargs
|
|
277
|
+
assert last_call_kwargs["response_action"] == "errors"
|
|
278
|
+
assert "errors" in last_call_kwargs
|
|
279
|
+
assert "status" in last_call_kwargs["errors"]
|
|
280
|
+
error_msg = last_call_kwargs["errors"]["status"]
|
|
281
|
+
assert "Cannot close this incident" in error_msg
|
|
282
|
+
assert "PostMortem status" in error_msg
|
|
283
|
+
|
|
284
|
+
# Verify that incident update was NOT triggered
|
|
285
|
+
trigger_incident_workflow.assert_not_called()
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def test_closure_reason_modal_shown_when_closing_from_investigating(mocker: MockerFixture) -> None:
|
|
289
|
+
"""Test that closure reason modal is shown when trying to close from INVESTIGATING.
|
|
290
|
+
|
|
291
|
+
This tests that handle_update_status_close_request correctly shows the
|
|
292
|
+
closure reason modal and returns True, blocking the normal closure flow.
|
|
293
|
+
"""
|
|
294
|
+
# Create an incident in INVESTIGATING status
|
|
295
|
+
incident = IncidentFactory.build(
|
|
296
|
+
_status=IncidentStatus.INVESTIGATING,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
modal = UpdateStatusModal()
|
|
300
|
+
|
|
301
|
+
# Mock handle_update_status_close_request to return True (modal shown)
|
|
302
|
+
mock_handle_close = mocker.patch(
|
|
303
|
+
"firefighter.slack.views.modals.update_status.handle_update_status_close_request",
|
|
304
|
+
return_value=True
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
trigger_incident_workflow = mocker.patch.object(
|
|
308
|
+
modal, "_trigger_incident_workflow"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
ack = MagicMock()
|
|
312
|
+
user = UserFactory.build()
|
|
313
|
+
user.save()
|
|
314
|
+
|
|
315
|
+
# Create a submission trying to close the incident
|
|
316
|
+
submission_copy = dict(valid_submission)
|
|
317
|
+
submission_copy["view"]["state"]["values"]["status"]["status"]["selected_option"] = {
|
|
318
|
+
"text": {"type": "plain_text", "text": "Closed", "emoji": True},
|
|
319
|
+
"value": "60",
|
|
320
|
+
}
|
|
321
|
+
submission_copy["view"]["private_metadata"] = str(incident.id)
|
|
322
|
+
|
|
323
|
+
modal.handle_modal_fn(
|
|
324
|
+
ack=ack, body=submission_copy, incident=incident, user=user
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Verify handle_update_status_close_request was called
|
|
328
|
+
mock_handle_close.assert_called_once_with(ack, submission_copy, incident, IncidentStatus.CLOSED)
|
|
329
|
+
|
|
330
|
+
# Verify that incident update was NOT triggered (because closure reason modal was shown)
|
|
331
|
+
trigger_incident_workflow.assert_not_called()
|
|
332
|
+
|
|
333
|
+
@staticmethod
|
|
334
|
+
def test_can_close_when_all_conditions_met(mocker: MockerFixture) -> None:
|
|
335
|
+
"""Test that closing is allowed when all conditions are met."""
|
|
336
|
+
# Create a user first
|
|
337
|
+
user = UserFactory.build()
|
|
338
|
+
user.save()
|
|
339
|
+
|
|
340
|
+
# Create an incident in MITIGATED status with all conditions met
|
|
341
|
+
incident = IncidentFactory.build(
|
|
342
|
+
_status=IncidentStatus.MITIGATED,
|
|
343
|
+
created_by=user,
|
|
344
|
+
)
|
|
345
|
+
# IMPORTANT: Save the incident so it has an ID for the form to reference
|
|
346
|
+
incident.save()
|
|
347
|
+
|
|
348
|
+
# Mock can_be_closed to return True (all conditions met)
|
|
349
|
+
mocker.patch.object(
|
|
350
|
+
type(incident),
|
|
351
|
+
"can_be_closed",
|
|
352
|
+
new_callable=PropertyMock,
|
|
353
|
+
return_value=(True, [])
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
modal = UpdateStatusModal()
|
|
357
|
+
trigger_incident_workflow = mocker.patch.object(
|
|
358
|
+
modal, "_trigger_incident_workflow"
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
ack = MagicMock()
|
|
362
|
+
|
|
363
|
+
# Create a submission to close the incident
|
|
364
|
+
submission_copy = dict(valid_submission)
|
|
365
|
+
submission_copy["view"]["state"]["values"]["status"]["status"]["selected_option"] = {
|
|
366
|
+
"text": {"type": "plain_text", "text": "Closed", "emoji": True},
|
|
367
|
+
"value": "60",
|
|
368
|
+
}
|
|
369
|
+
submission_copy["view"]["private_metadata"] = str(incident.id)
|
|
370
|
+
|
|
371
|
+
modal.handle_modal_fn(
|
|
372
|
+
ack=ack, body=submission_copy, incident=incident, user=user
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Assert that ack was called successfully (no errors)
|
|
376
|
+
# The first call is the successful ack() without errors
|
|
377
|
+
first_call_kwargs = ack.call_args_list[0][1] if ack.call_args_list else ack.call_args.kwargs
|
|
378
|
+
assert first_call_kwargs == {} or "errors" not in first_call_kwargs
|
|
379
|
+
|
|
380
|
+
# Verify that incident update WAS triggered
|
|
381
|
+
trigger_incident_workflow.assert_called_once()
|
|
382
|
+
|
|
67
383
|
|
|
68
384
|
valid_submission = {
|
|
69
385
|
"type": "view_submission",
|
|
@@ -162,6 +478,14 @@ valid_submission = {
|
|
|
162
478
|
},
|
|
163
479
|
"value": "50",
|
|
164
480
|
},
|
|
481
|
+
{
|
|
482
|
+
"text": {
|
|
483
|
+
"type": "plain_text",
|
|
484
|
+
"text": "Closed",
|
|
485
|
+
"emoji": True,
|
|
486
|
+
},
|
|
487
|
+
"value": "60",
|
|
488
|
+
},
|
|
165
489
|
],
|
|
166
490
|
},
|
|
167
491
|
},
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Test the modal utils module."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import MagicMock, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from firefighter.incidents.enums import IncidentStatus
|
|
9
|
+
from firefighter.incidents.factories import IncidentFactory
|
|
10
|
+
from firefighter.slack.views.modals.utils import (
|
|
11
|
+
get_close_modal_view,
|
|
12
|
+
handle_close_modal_callback,
|
|
13
|
+
handle_update_status_close_request,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.django_db
|
|
18
|
+
class TestModalUtils:
|
|
19
|
+
"""Test modal utility functions."""
|
|
20
|
+
|
|
21
|
+
def test_get_close_modal_view_requires_reason(self):
|
|
22
|
+
"""Test get_close_modal_view when closure reason is required."""
|
|
23
|
+
# Create incident in OPEN status (requires closure reason)
|
|
24
|
+
incident = IncidentFactory.create(_status=IncidentStatus.OPEN)
|
|
25
|
+
body = {}
|
|
26
|
+
|
|
27
|
+
with patch("firefighter.slack.views.modals.utils.UpdateStatusForm.requires_closure_reason", return_value=True), \
|
|
28
|
+
patch("firefighter.slack.views.modals.utils.modal_closure_reason.build_modal_fn") as mock_build:
|
|
29
|
+
mock_build.return_value = MagicMock()
|
|
30
|
+
|
|
31
|
+
result = get_close_modal_view(body, incident)
|
|
32
|
+
|
|
33
|
+
assert result is not None
|
|
34
|
+
mock_build.assert_called_once_with(body, incident)
|
|
35
|
+
|
|
36
|
+
def test_get_close_modal_view_no_reason_required(self):
|
|
37
|
+
"""Test get_close_modal_view when closure reason is not required."""
|
|
38
|
+
# Create incident in POST_MORTEM status (doesn't require closure reason)
|
|
39
|
+
incident = IncidentFactory.create(_status=IncidentStatus.POST_MORTEM)
|
|
40
|
+
body = {}
|
|
41
|
+
|
|
42
|
+
with patch("firefighter.slack.views.modals.utils.UpdateStatusForm.requires_closure_reason", return_value=False):
|
|
43
|
+
result = get_close_modal_view(body, incident)
|
|
44
|
+
|
|
45
|
+
assert result is None
|
|
46
|
+
|
|
47
|
+
def test_handle_close_modal_callback_closure_reason(self):
|
|
48
|
+
"""Test handle_close_modal_callback for closure reason modal."""
|
|
49
|
+
incident = IncidentFactory.create()
|
|
50
|
+
user = MagicMock()
|
|
51
|
+
ack = MagicMock()
|
|
52
|
+
|
|
53
|
+
body = {
|
|
54
|
+
"view": {
|
|
55
|
+
"callback_id": "incident_closure_reason"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
with patch("firefighter.slack.views.modals.utils.modal_closure_reason.handle_modal_fn") as mock_handle:
|
|
60
|
+
mock_handle.return_value = True
|
|
61
|
+
|
|
62
|
+
result = handle_close_modal_callback(ack, body, incident, user)
|
|
63
|
+
|
|
64
|
+
assert result is True
|
|
65
|
+
mock_handle.assert_called_once_with(ack, body, incident, user)
|
|
66
|
+
|
|
67
|
+
def test_handle_close_modal_callback_normal_modal(self):
|
|
68
|
+
"""Test handle_close_modal_callback for normal close modal."""
|
|
69
|
+
incident = IncidentFactory.create()
|
|
70
|
+
user = MagicMock()
|
|
71
|
+
ack = MagicMock()
|
|
72
|
+
|
|
73
|
+
body = {
|
|
74
|
+
"view": {
|
|
75
|
+
"callback_id": "incident_close" # Not closure reason
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
result = handle_close_modal_callback(ack, body, incident, user)
|
|
80
|
+
|
|
81
|
+
assert result is None
|
|
82
|
+
|
|
83
|
+
def test_handle_update_status_close_request_requires_reason(self):
|
|
84
|
+
"""Test handle_update_status_close_request when reason is required."""
|
|
85
|
+
incident = IncidentFactory.create(_status=IncidentStatus.OPEN)
|
|
86
|
+
ack = MagicMock()
|
|
87
|
+
body = {}
|
|
88
|
+
target_status = IncidentStatus.CLOSED
|
|
89
|
+
|
|
90
|
+
with patch("firefighter.slack.views.modals.utils.UpdateStatusForm.requires_closure_reason", return_value=True), \
|
|
91
|
+
patch("firefighter.slack.views.modals.utils.modal_closure_reason.build_modal_fn") as mock_build:
|
|
92
|
+
mock_build.return_value = MagicMock()
|
|
93
|
+
|
|
94
|
+
result = handle_update_status_close_request(ack, body, incident, target_status)
|
|
95
|
+
|
|
96
|
+
assert result is True
|
|
97
|
+
ack.assert_called_once_with(response_action="push", view=mock_build.return_value)
|
|
98
|
+
mock_build.assert_called_once_with(body, incident)
|
|
99
|
+
|
|
100
|
+
def test_handle_update_status_close_request_no_reason_required(self):
|
|
101
|
+
"""Test handle_update_status_close_request when reason is not required."""
|
|
102
|
+
incident = IncidentFactory.create(_status=IncidentStatus.POST_MORTEM)
|
|
103
|
+
ack = MagicMock()
|
|
104
|
+
body = {}
|
|
105
|
+
target_status = IncidentStatus.CLOSED
|
|
106
|
+
|
|
107
|
+
with patch("firefighter.slack.views.modals.utils.UpdateStatusForm.requires_closure_reason", return_value=False):
|
|
108
|
+
result = handle_update_status_close_request(ack, body, incident, target_status)
|
|
109
|
+
|
|
110
|
+
assert result is False
|
|
111
|
+
ack.assert_not_called()
|
|
112
|
+
|
|
113
|
+
def test_handle_update_status_close_request_non_close_status(self):
|
|
114
|
+
"""Test handle_update_status_close_request for non-close status."""
|
|
115
|
+
incident = IncidentFactory.create()
|
|
116
|
+
ack = MagicMock()
|
|
117
|
+
body = {}
|
|
118
|
+
target_status = IncidentStatus.INVESTIGATING
|
|
119
|
+
|
|
120
|
+
result = handle_update_status_close_request(ack, body, incident, target_status)
|
|
121
|
+
|
|
122
|
+
assert result is False
|
|
123
|
+
ack.assert_not_called()
|
|
124
|
+
|
|
125
|
+
def test_handle_close_modal_callback_missing_view(self):
|
|
126
|
+
"""Test handle_close_modal_callback with missing view in body."""
|
|
127
|
+
incident = IncidentFactory.create()
|
|
128
|
+
user = MagicMock()
|
|
129
|
+
ack = MagicMock()
|
|
130
|
+
|
|
131
|
+
body = {} # No view key
|
|
132
|
+
|
|
133
|
+
result = handle_close_modal_callback(ack, body, incident, user)
|
|
134
|
+
|
|
135
|
+
assert result is None
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
from typing import TYPE_CHECKING
|
|
5
|
-
|
|
6
|
-
from slack_sdk.models.blocks.basic_components import MarkdownTextObject
|
|
7
|
-
from slack_sdk.models.blocks.blocks import ContextBlock
|
|
8
|
-
|
|
9
|
-
from firefighter.raid.forms import (
|
|
10
|
-
CreateNormalCustomerIncidentForm,
|
|
11
|
-
CreateRaidDocumentationRequestIncidentForm,
|
|
12
|
-
CreateRaidFeatureRequestIncidentForm,
|
|
13
|
-
CreateRaidInternalIncidentForm,
|
|
14
|
-
RaidCreateIncidentSellerForm,
|
|
15
|
-
)
|
|
16
|
-
from firefighter.slack.views.modals.opening.set_details import SetIncidentDetails
|
|
17
|
-
|
|
18
|
-
if TYPE_CHECKING:
|
|
19
|
-
from firefighter.slack.views.modals.base_modal.form_utils import (
|
|
20
|
-
SlackFormAttributesDict,
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
|
-
slack_fields: SlackFormAttributesDict = {
|
|
26
|
-
"title": {
|
|
27
|
-
"input": {
|
|
28
|
-
"multiline": False,
|
|
29
|
-
"placeholder": "Summary of the issue.",
|
|
30
|
-
},
|
|
31
|
-
"block": {"hint": None},
|
|
32
|
-
},
|
|
33
|
-
"description": {
|
|
34
|
-
"input": {
|
|
35
|
-
"multiline": True,
|
|
36
|
-
"placeholder": "Explain your issue in English giving as much details as possible. It helps people handling the issue. \nThis description can be edited later.",
|
|
37
|
-
},
|
|
38
|
-
"block": {"hint": None},
|
|
39
|
-
},
|
|
40
|
-
"suggested_team_routing": {
|
|
41
|
-
"widget": {
|
|
42
|
-
"post_block": ContextBlock(
|
|
43
|
-
elements=[
|
|
44
|
-
MarkdownTextObject(
|
|
45
|
-
text="Feature Team or Train that should own the issue. If you don't know access <https://manomano.atlassian.net/wiki/spaces/QRAFT/pages/3970335291/Teams+and+owners|here> for guidance."
|
|
46
|
-
),
|
|
47
|
-
]
|
|
48
|
-
)
|
|
49
|
-
},
|
|
50
|
-
},
|
|
51
|
-
"incident_category": {
|
|
52
|
-
"input": {
|
|
53
|
-
"placeholder": "Select incident category",
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
class CreateRaidCustomerIncidentFormSlack(CreateNormalCustomerIncidentForm):
|
|
60
|
-
slack_fields: SlackFormAttributesDict = slack_fields
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
class OpeningRaidCustomerModal(SetIncidentDetails[CreateRaidCustomerIncidentFormSlack]):
|
|
64
|
-
open_action: str = "open_incident_raid_customer_request"
|
|
65
|
-
push_action: str = "push_raid_customer_request"
|
|
66
|
-
callback_id: str = "open_incident_raid_customer_request"
|
|
67
|
-
id = "raid_customer_request"
|
|
68
|
-
title = "New Customer Incident"
|
|
69
|
-
|
|
70
|
-
form_class = CreateRaidCustomerIncidentFormSlack
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
class CreateRaidDocumentationRequestIncidentFormSlack(
|
|
74
|
-
CreateRaidDocumentationRequestIncidentForm
|
|
75
|
-
):
|
|
76
|
-
slack_fields: SlackFormAttributesDict = slack_fields
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
class CreateRaidFeatureRequestIncidentFormSlack(CreateRaidFeatureRequestIncidentForm):
|
|
80
|
-
slack_fields: SlackFormAttributesDict = slack_fields
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class OpeningRaidFeatureRequestModal(
|
|
84
|
-
SetIncidentDetails[CreateRaidFeatureRequestIncidentFormSlack]
|
|
85
|
-
):
|
|
86
|
-
open_action: str = "open_incident_raid_feature_request"
|
|
87
|
-
push_action: str = "push_raid_feature_request"
|
|
88
|
-
callback_id: str = "open_incident_raid_feature_request"
|
|
89
|
-
|
|
90
|
-
title = "New Feature Request"
|
|
91
|
-
form_class = CreateRaidFeatureRequestIncidentFormSlack
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
class OpeningRaidDocumentationRequestModal(
|
|
95
|
-
SetIncidentDetails[CreateRaidDocumentationRequestIncidentFormSlack]
|
|
96
|
-
):
|
|
97
|
-
open_action: str = "open_incident_raid_documentation_request"
|
|
98
|
-
push_action: str = "push_raid_documentation_request"
|
|
99
|
-
callback_id: str = "open_incident_raid_documentation_request"
|
|
100
|
-
|
|
101
|
-
title = "New Documentation Request"
|
|
102
|
-
form_class = CreateRaidDocumentationRequestIncidentFormSlack
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
class CreateRaidInternalIncidentFormSlack(CreateRaidInternalIncidentForm):
|
|
106
|
-
slack_fields: SlackFormAttributesDict = slack_fields
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
class OpeningRaidInternalModal(SetIncidentDetails[CreateRaidInternalIncidentFormSlack]):
|
|
110
|
-
open_action: str = "open_incident_raid_internal_request"
|
|
111
|
-
push_action: str = "push_raid_internal_request"
|
|
112
|
-
callback_id: str = "open_incident_raid_internal_request"
|
|
113
|
-
|
|
114
|
-
title = "New Internal Incident"
|
|
115
|
-
|
|
116
|
-
form_class = CreateRaidInternalIncidentFormSlack
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
class CreateRaidSellerIncidentFormSlack(RaidCreateIncidentSellerForm):
|
|
120
|
-
slack_fields: SlackFormAttributesDict = slack_fields
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
class OpeningRaidSellerModal(SetIncidentDetails[CreateRaidSellerIncidentFormSlack]):
|
|
124
|
-
open_action: str = "open_incident_raid_seller_request"
|
|
125
|
-
push_action: str = "push_raid_seller_request"
|
|
126
|
-
callback_id: str = "open_incident_raid_seller_request"
|
|
127
|
-
|
|
128
|
-
title = "New Seller Incident"
|
|
129
|
-
form_class = CreateRaidSellerIncidentFormSlack
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
# Instantiate all the modals to register actions
|
|
133
|
-
_modals = [
|
|
134
|
-
raid_seller := OpeningRaidSellerModal(),
|
|
135
|
-
raid_internal := OpeningRaidInternalModal(),
|
|
136
|
-
raid_customer := OpeningRaidCustomerModal(),
|
|
137
|
-
raid_feature := OpeningRaidFeatureRequestModal(),
|
|
138
|
-
raid_documentation := OpeningRaidDocumentationRequestModal(),
|
|
139
|
-
]
|