firefighter-incident 0.0.26__py3-none-any.whl → 0.0.28__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 (32) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/confluence/models.py +16 -1
  3. firefighter/incidents/management/__init__.py +1 -0
  4. firefighter/incidents/management/commands/__init__.py +1 -0
  5. firefighter/incidents/management/commands/backdate_incident_mitigated.py +94 -0
  6. firefighter/incidents/management/commands/test_postmortem_reminders.py +113 -0
  7. firefighter/incidents/migrations/0030_add_mitigated_at_field.py +22 -0
  8. firefighter/incidents/models/incident.py +43 -8
  9. firefighter/jira_app/service_postmortem.py +13 -0
  10. firefighter/jira_app/signals/postmortem_created.py +108 -46
  11. firefighter/jira_app/templates/jira/postmortem/impact.txt +9 -4
  12. firefighter/slack/messages/slack_messages.py +234 -18
  13. firefighter/slack/migrations/0009_add_postmortem_reminder_periodic_task.py +60 -0
  14. firefighter/slack/rules.py +22 -0
  15. firefighter/slack/tasks/send_postmortem_reminders.py +127 -0
  16. firefighter/slack/views/modals/close.py +113 -3
  17. firefighter/slack/views/modals/closure_reason.py +39 -15
  18. firefighter/slack/views/modals/postmortem.py +75 -7
  19. firefighter/slack/views/modals/update_status.py +4 -4
  20. {firefighter_incident-0.0.26.dist-info → firefighter_incident-0.0.28.dist-info}/METADATA +1 -1
  21. {firefighter_incident-0.0.26.dist-info → firefighter_incident-0.0.28.dist-info}/RECORD +32 -24
  22. firefighter_tests/test_incidents/test_incident_urls.py +4 -0
  23. firefighter_tests/test_incidents/test_models/test_incident_model.py +109 -1
  24. firefighter_tests/test_incidents/test_views/test_incident_detail_view.py +4 -0
  25. firefighter_tests/test_slack/messages/test_slack_messages.py +4 -0
  26. firefighter_tests/test_slack/views/modals/test_close.py +4 -0
  27. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +109 -26
  28. firefighter_tests/test_slack/views/modals/test_postmortem_modal.py +72 -0
  29. firefighter_tests/test_slack/views/modals/test_update_status.py +45 -51
  30. {firefighter_incident-0.0.26.dist-info → firefighter_incident-0.0.28.dist-info}/WHEEL +0 -0
  31. {firefighter_incident-0.0.26.dist-info → firefighter_incident-0.0.28.dist-info}/entry_points.txt +0 -0
  32. {firefighter_incident-0.0.26.dist-info → firefighter_incident-0.0.28.dist-info}/licenses/LICENSE +0 -0
@@ -9,6 +9,7 @@ from slack_sdk.errors import SlackApiError
9
9
 
10
10
  from firefighter.incidents.enums import ClosureReason, IncidentStatus
11
11
  from firefighter.incidents.factories import IncidentFactory, UserFactory
12
+ from firefighter.incidents.models import Environment, Priority
12
13
  from firefighter.slack.views.modals.closure_reason import ClosureReasonModal
13
14
 
14
15
 
@@ -16,12 +17,16 @@ from firefighter.slack.views.modals.closure_reason import ClosureReasonModal
16
17
  class TestClosureReasonModalMessageTabDisabled:
17
18
  """Test ClosureReasonModal handles messages_tab_disabled gracefully."""
18
19
 
19
- def test_closure_reason_handles_messages_tab_disabled(self, caplog: pytest.LogCaptureFixture, mocker) -> None:
20
+ def test_closure_reason_handles_messages_tab_disabled(
21
+ self, caplog: pytest.LogCaptureFixture, mocker
22
+ ) -> None:
20
23
  """Test that messages_tab_disabled error is handled gracefully with warning log."""
21
24
  # Create test data
22
25
  user = UserFactory.build()
23
26
  user.save()
24
- incident = IncidentFactory.build(_status=IncidentStatus.INVESTIGATING, created_by=user)
27
+ incident = IncidentFactory.build(
28
+ _status=IncidentStatus.INVESTIGATING, created_by=user
29
+ )
25
30
  incident.save()
26
31
 
27
32
  # Mock can_be_closed to return True so the closure can proceed
@@ -29,7 +34,7 @@ class TestClosureReasonModalMessageTabDisabled:
29
34
  type(incident),
30
35
  "can_be_closed",
31
36
  new_callable=mocker.PropertyMock,
32
- return_value=(True, [])
37
+ return_value=(True, []),
33
38
  )
34
39
 
35
40
  # Create modal and mock
@@ -46,9 +51,7 @@ class TestClosureReasonModalMessageTabDisabled:
46
51
  "selected_option": {"value": ClosureReason.CANCELLED}
47
52
  }
48
53
  },
49
- "closure_reference": {
50
- "input_closure_reference": {"value": ""}
51
- },
54
+ "closure_reference": {"input_closure_reference": {"value": ""}},
52
55
  "closure_message": {
53
56
  "input_closure_message": {"value": "Test closure message"}
54
57
  },
@@ -63,18 +66,17 @@ class TestClosureReasonModalMessageTabDisabled:
63
66
  slack_error_response = MagicMock()
64
67
  slack_error_response.get.return_value = "messages_tab_disabled"
65
68
 
66
- with patch("firefighter.slack.views.modals.closure_reason.respond") as mock_respond:
69
+ with patch(
70
+ "firefighter.slack.views.modals.closure_reason.respond"
71
+ ) as mock_respond:
67
72
  mock_respond.side_effect = SlackApiError(
68
73
  message="The request to the Slack API failed.",
69
- response=slack_error_response
74
+ response=slack_error_response,
70
75
  )
71
76
 
72
77
  # Execute
73
78
  result = modal.handle_modal_fn(
74
- ack=ack,
75
- body=body,
76
- incident=incident,
77
- user=user
79
+ ack=ack, body=body, incident=incident, user=user
78
80
  )
79
81
 
80
82
  # Assertions
@@ -87,7 +89,8 @@ class TestClosureReasonModalMessageTabDisabled:
87
89
 
88
90
  # Verify warning was logged
89
91
  assert any(
90
- "Cannot send DM to user" in record.message and record.levelname == "WARNING"
92
+ "Cannot send DM to user" in record.message
93
+ and record.levelname == "WARNING"
91
94
  for record in caplog.records
92
95
  )
93
96
 
@@ -96,7 +99,9 @@ class TestClosureReasonModalMessageTabDisabled:
96
99
  # Create test data
97
100
  user = UserFactory.build()
98
101
  user.save()
99
- incident = IncidentFactory.build(_status=IncidentStatus.INVESTIGATING, created_by=user)
102
+ incident = IncidentFactory.build(
103
+ _status=IncidentStatus.INVESTIGATING, created_by=user
104
+ )
100
105
  incident.save()
101
106
 
102
107
  # Mock can_be_closed to return True so the closure can proceed
@@ -104,7 +109,7 @@ class TestClosureReasonModalMessageTabDisabled:
104
109
  type(incident),
105
110
  "can_be_closed",
106
111
  new_callable=mocker.PropertyMock,
107
- return_value=(True, [])
112
+ return_value=(True, []),
108
113
  )
109
114
 
110
115
  # Create modal and mock
@@ -121,9 +126,7 @@ class TestClosureReasonModalMessageTabDisabled:
121
126
  "selected_option": {"value": ClosureReason.CANCELLED}
122
127
  }
123
128
  },
124
- "closure_reference": {
125
- "input_closure_reference": {"value": ""}
126
- },
129
+ "closure_reference": {"input_closure_reference": {"value": ""}},
127
130
  "closure_message": {
128
131
  "input_closure_message": {"value": "Test closure message"}
129
132
  },
@@ -138,17 +141,97 @@ class TestClosureReasonModalMessageTabDisabled:
138
141
  slack_error_response = MagicMock()
139
142
  slack_error_response.get.return_value = "channel_not_found"
140
143
 
141
- with patch("firefighter.slack.views.modals.closure_reason.respond") as mock_respond:
144
+ with patch(
145
+ "firefighter.slack.views.modals.closure_reason.respond"
146
+ ) as mock_respond:
142
147
  mock_respond.side_effect = SlackApiError(
143
148
  message="The request to the Slack API failed.",
144
- response=slack_error_response
149
+ response=slack_error_response,
145
150
  )
146
151
 
147
152
  # Execute and expect exception
148
153
  with pytest.raises(SlackApiError):
149
- modal.handle_modal_fn(
150
- ack=ack,
151
- body=body,
152
- incident=incident,
153
- user=user
154
- )
154
+ modal.handle_modal_fn(ack=ack, body=body, incident=incident, user=user)
155
+
156
+
157
+ @pytest.mark.django_db
158
+ class TestClosureReasonModalEarlyClosureBypass:
159
+ """Test early-closure path respects submitted closure reason."""
160
+
161
+ def test_allows_early_closure_with_submitted_reason(self, settings) -> None:
162
+ """Ensure can_be_closed passes when a closure reason is provided for early closure."""
163
+ settings.ENABLE_JIRA_POSTMORTEM = True
164
+
165
+ # Ensure required priority/environment for needs_postmortem + PRD
166
+ priority = Priority.objects.create(
167
+ name="P1-test",
168
+ value=9991,
169
+ description="P1 test",
170
+ order=9991,
171
+ needs_postmortem=True,
172
+ )
173
+ env, _ = Environment.objects.get_or_create(
174
+ value="PRD",
175
+ defaults={
176
+ "name": "Production",
177
+ "description": "Production",
178
+ "order": 9991,
179
+ },
180
+ )
181
+
182
+ user = UserFactory.create()
183
+ incident = IncidentFactory.create(
184
+ _status=IncidentStatus.INVESTIGATING,
185
+ created_by=user,
186
+ priority=priority,
187
+ environment=env,
188
+ )
189
+
190
+ modal = ClosureReasonModal()
191
+ ack = MagicMock()
192
+
193
+ body = {
194
+ "view": {
195
+ "state": {
196
+ "values": {
197
+ "closure_reason": {
198
+ "select_closure_reason": {
199
+ "selected_option": {"value": ClosureReason.CANCELLED}
200
+ }
201
+ },
202
+ "closure_reference": {
203
+ "input_closure_reference": {"value": "INC-42"}
204
+ },
205
+ "closure_message": {
206
+ "input_closure_message": {
207
+ "value": "Closing early with reason"
208
+ }
209
+ },
210
+ }
211
+ },
212
+ "private_metadata": str(incident.id),
213
+ },
214
+ "user": {"id": "U123456"},
215
+ }
216
+
217
+ with patch(
218
+ "firefighter.slack.views.modals.closure_reason.respond"
219
+ ) as mock_respond:
220
+ mock_respond.return_value = None
221
+
222
+ result = modal.handle_modal_fn(
223
+ ack=ack,
224
+ body=body,
225
+ incident=incident,
226
+ user=user,
227
+ )
228
+
229
+ # Early closure should succeed and close the incident
230
+ assert result is True
231
+ incident.refresh_from_db()
232
+ assert incident.status == IncidentStatus.CLOSED
233
+ assert incident.closure_reason == ClosureReason.CANCELLED
234
+ assert incident.closure_reference == "INC-42"
235
+
236
+ # Ack should clear modal stack
237
+ ack.assert_called_once_with(response_action="clear")
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from types import SimpleNamespace
4
+
5
+ from firefighter.slack.views.modals.postmortem import PostMortemModal
6
+
7
+
8
+ class TestPostMortemModal:
9
+ @staticmethod
10
+ def _get_text_blocks(view_dict: dict) -> list[str]:
11
+ return [
12
+ block.get("text", {}).get("text", "")
13
+ for block in view_dict.get("blocks", [])
14
+ if block.get("type") == "section"
15
+ ]
16
+
17
+ @staticmethod
18
+ def _get_action_elements(view_dict: dict) -> list[dict]:
19
+ elements: list[dict] = []
20
+ for block in view_dict.get("blocks", []):
21
+ if block.get("type") == "actions":
22
+ elements.extend(block.get("elements", []))
23
+ return elements
24
+
25
+ @staticmethod
26
+ def test_p1_p2_shows_auto_creation_message(mocker):
27
+ incident = SimpleNamespace(id=123, needs_postmortem=True)
28
+
29
+ # No existing PMs
30
+ mocker.patch(
31
+ "firefighter.slack.views.modals.postmortem._safe_has_relation",
32
+ return_value=False,
33
+ create=True,
34
+ )
35
+
36
+ view = PostMortemModal().build_modal_fn(incident)
37
+ view_dict = view.to_dict()
38
+
39
+ texts = TestPostMortemModal._get_text_blocks(view_dict)
40
+ assert any(
41
+ "automatically created when the incident reaches MITIGATED" in t
42
+ for t in texts
43
+ )
44
+ # No manual-create button for mandatory PMs
45
+ action_ids = [
46
+ el.get("action_id")
47
+ for el in TestPostMortemModal._get_action_elements(view_dict)
48
+ ]
49
+ assert "incident_create_postmortem_now" not in action_ids
50
+
51
+ @staticmethod
52
+ def test_p3_shows_optional_message_and_button(mocker):
53
+ incident = SimpleNamespace(id=456, needs_postmortem=False)
54
+
55
+ # No existing PMs
56
+ mocker.patch(
57
+ "firefighter.slack.views.modals.postmortem._safe_has_relation",
58
+ return_value=False,
59
+ create=True,
60
+ )
61
+
62
+ view = PostMortemModal().build_modal_fn(incident)
63
+ view_dict = view.to_dict()
64
+
65
+ texts = TestPostMortemModal._get_text_blocks(view_dict)
66
+ assert any("P3 incident post-mortem is not mandatory" in t for t in texts)
67
+
68
+ action_ids = [
69
+ el.get("action_id")
70
+ for el in TestPostMortemModal._get_action_elements(view_dict)
71
+ ]
72
+ assert "incident_create_postmortem_now" in action_ids
@@ -129,12 +129,12 @@ class TestUpdateStatusModal:
129
129
 
130
130
  # Verify that can_be_closed returns False due to missing milestones (real check, no mock)
131
131
  can_close, reasons = incident.can_be_closed
132
- assert can_close is False, (
133
- f"Incident should not be closable without required milestones. Got: {can_close}, reasons: {reasons}"
134
- )
135
- assert any("MISSING_REQUIRED_KEY_EVENTS" in reason[0] for reason in reasons), (
136
- f"Expected MISSING_REQUIRED_KEY_EVENTS in reasons, got: {reasons}"
137
- )
132
+ assert (
133
+ can_close is False
134
+ ), f"Incident should not be closable without required milestones. Got: {can_close}, reasons: {reasons}"
135
+ assert any(
136
+ "MISSING_REQUIRED_KEY_EVENTS" in reason[0] for reason in reasons
137
+ ), f"Expected MISSING_REQUIRED_KEY_EVENTS in reasons, got: {reasons}"
138
138
 
139
139
  modal = UpdateStatusModal()
140
140
  trigger_incident_workflow = mocker.patch.object(
@@ -163,26 +163,23 @@ class TestUpdateStatusModal:
163
163
  assert ack.called, "ack should have been called"
164
164
  # Check the last call (the error response)
165
165
  last_call_kwargs = ack.call_args.kwargs
166
- assert "response_action" in last_call_kwargs, (
167
- f"Expected 'response_action' in ack, got: {last_call_kwargs}"
168
- )
169
- assert last_call_kwargs["response_action"] == "errors", (
170
- f"Expected response_action='errors', got: {last_call_kwargs.get('response_action')}"
171
- )
172
- assert "errors" in last_call_kwargs, (
173
- f"Expected 'errors' in ack, got: {last_call_kwargs}"
174
- )
175
- assert "status" in last_call_kwargs["errors"], (
176
- f"Expected 'status' in errors, got: {last_call_kwargs.get('errors')}"
177
- )
166
+ assert (
167
+ "response_action" in last_call_kwargs
168
+ ), f"Expected 'response_action' in ack, got: {last_call_kwargs}"
169
+ assert (
170
+ last_call_kwargs["response_action"] == "errors"
171
+ ), f"Expected response_action='errors', got: {last_call_kwargs.get('response_action')}"
172
+ assert (
173
+ "errors" in last_call_kwargs
174
+ ), f"Expected 'errors' in ack, got: {last_call_kwargs}"
175
+ assert (
176
+ "status" in last_call_kwargs["errors"]
177
+ ), f"Expected 'status' in errors, got: {last_call_kwargs.get('errors')}"
178
178
  # Check that the error message mentions the missing key events
179
179
  error_msg = last_call_kwargs["errors"]["status"]
180
- assert "Cannot close this incident" in error_msg, (
181
- f"Expected closure error, got: {error_msg}"
182
- )
183
- assert "key events" in error_msg.lower(), (
184
- f"Expected 'key events' in error, got: {error_msg}"
185
- )
180
+ assert (
181
+ "missing key events" in error_msg.lower()
182
+ ), f"Expected missing key events error, got: {error_msg}"
186
183
 
187
184
  # Verify that incident update was NOT triggered
188
185
  trigger_incident_workflow.assert_not_called()
@@ -233,12 +230,12 @@ class TestUpdateStatusModal:
233
230
 
234
231
  # Verify that can_be_closed returns False due to missing milestones
235
232
  can_close, reasons = incident.can_be_closed
236
- assert can_close is False, (
237
- "Incident should not be closable without required milestones"
238
- )
239
- assert any("MISSING_REQUIRED_KEY_EVENTS" in reason[0] for reason in reasons), (
240
- f"Expected MISSING_REQUIRED_KEY_EVENTS in reasons, got: {reasons}"
241
- )
233
+ assert (
234
+ can_close is False
235
+ ), "Incident should not be closable without required milestones"
236
+ assert any(
237
+ "MISSING_REQUIRED_KEY_EVENTS" in reason[0] for reason in reasons
238
+ ), f"Expected MISSING_REQUIRED_KEY_EVENTS in reasons, got: {reasons}"
242
239
 
243
240
  modal = UpdateStatusModal()
244
241
  trigger_incident_workflow = mocker.patch.object(
@@ -264,25 +261,22 @@ class TestUpdateStatusModal:
264
261
  # Assert that ack was called with errors
265
262
  assert ack.called, "ack should have been called"
266
263
  last_call_kwargs = ack.call_args.kwargs
267
- assert "response_action" in last_call_kwargs, (
268
- f"Expected 'response_action' in ack call, got: {last_call_kwargs}"
269
- )
270
- assert last_call_kwargs["response_action"] == "errors", (
271
- f"Expected response_action='errors', got: {last_call_kwargs.get('response_action')}"
272
- )
273
- assert "errors" in last_call_kwargs, (
274
- f"Expected 'errors' in ack call, got: {last_call_kwargs}"
275
- )
276
- assert "status" in last_call_kwargs["errors"], (
277
- f"Expected 'status' in errors, got: {last_call_kwargs.get('errors')}"
278
- )
264
+ assert (
265
+ "response_action" in last_call_kwargs
266
+ ), f"Expected 'response_action' in ack call, got: {last_call_kwargs}"
267
+ assert (
268
+ last_call_kwargs["response_action"] == "errors"
269
+ ), f"Expected response_action='errors', got: {last_call_kwargs.get('response_action')}"
270
+ assert (
271
+ "errors" in last_call_kwargs
272
+ ), f"Expected 'errors' in ack call, got: {last_call_kwargs}"
273
+ assert (
274
+ "status" in last_call_kwargs["errors"]
275
+ ), f"Expected 'status' in errors, got: {last_call_kwargs.get('errors')}"
279
276
  error_msg = last_call_kwargs["errors"]["status"]
280
- assert "Cannot close this incident" in error_msg, (
281
- f"Expected closure error message, got: {error_msg}"
282
- )
283
- assert "key events" in error_msg.lower(), (
284
- f"Expected 'key events' in error message, got: {error_msg}"
285
- )
277
+ assert (
278
+ "missing key events" in error_msg.lower()
279
+ ), f"Expected missing key events error message, got: {error_msg}"
286
280
 
287
281
  # Verify that incident update was NOT triggered
288
282
  trigger_incident_workflow.assert_not_called()
@@ -465,9 +459,9 @@ class TestUpdateStatusModal:
465
459
  first_call_kwargs = (
466
460
  ack.call_args_list[0][1] if ack.call_args_list else ack.call_args.kwargs
467
461
  )
468
- assert first_call_kwargs == {} or "errors" not in first_call_kwargs, (
469
- f"Should allow updating priority without changing status. Got errors: {first_call_kwargs.get('errors')}"
470
- )
462
+ assert (
463
+ first_call_kwargs == {} or "errors" not in first_call_kwargs
464
+ ), f"Should allow updating priority without changing status. Got errors: {first_call_kwargs.get('errors')}"
471
465
 
472
466
  # Verify that incident update WAS triggered (priority changed)
473
467
  trigger_incident_workflow.assert_called_once()