firefighter-incident 0.0.25__py3-none-any.whl → 0.0.27__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 (30) 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 +18 -4
  10. firefighter/jira_app/signals/postmortem_created.py +108 -46
  11. firefighter/slack/messages/slack_messages.py +162 -0
  12. firefighter/slack/migrations/0009_add_postmortem_reminder_periodic_task.py +60 -0
  13. firefighter/slack/rules.py +22 -0
  14. firefighter/slack/tasks/send_postmortem_reminders.py +127 -0
  15. firefighter/slack/views/modals/close.py +113 -3
  16. firefighter/slack/views/modals/closure_reason.py +54 -25
  17. firefighter/slack/views/modals/update_status.py +4 -4
  18. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/METADATA +1 -1
  19. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/RECORD +30 -23
  20. firefighter_tests/test_incidents/test_incident_urls.py +4 -0
  21. firefighter_tests/test_incidents/test_models/test_incident_model.py +109 -1
  22. firefighter_tests/test_incidents/test_views/test_incident_detail_view.py +4 -0
  23. firefighter_tests/test_jira_app/test_postmortem_service.py +2 -2
  24. firefighter_tests/test_slack/messages/test_slack_messages.py +4 -0
  25. firefighter_tests/test_slack/views/modals/test_close.py +4 -0
  26. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +109 -26
  27. firefighter_tests/test_slack/views/modals/test_update_status.py +45 -51
  28. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/WHEEL +0 -0
  29. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/entry_points.txt +0 -0
  30. {firefighter_incident-0.0.25.dist-info → firefighter_incident-0.0.27.dist-info}/licenses/LICENSE +0 -0
@@ -615,6 +615,168 @@ class SlackMessageIncidentPostMortemCreated(SlackMessageSurface):
615
615
  return [SectionBlock(text=self.get_text())]
616
616
 
617
617
 
618
+ class SlackMessageIncidentPostMortemCreatedAnnouncement(SlackMessageSurface):
619
+ """Message to announce post-mortem creation in #critical-incidents (tech_incidents tag)."""
620
+
621
+ id = "ff_incident_postmortem_created_announcement"
622
+ incident: Incident
623
+
624
+ def __init__(self, incident: Incident) -> None:
625
+ self.incident = incident
626
+ super().__init__()
627
+
628
+ def get_text(self) -> str:
629
+ """Generate announcement text for critical incidents channel."""
630
+ return f"📔 Post-mortem created for {self.incident.priority} incident #{self.incident.id}: {self.incident.title}"
631
+
632
+ def get_blocks(self) -> list[Block]:
633
+ fields = [
634
+ f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
635
+ f":calendar: *Created at:* {date_time(self.incident.created_at)}",
636
+ f":slack: *Channel:* <#{self.incident.conversation.channel_id}>",
637
+ ]
638
+
639
+ # Add Confluence link if available
640
+ if hasattr(self.incident, "postmortem_for"):
641
+ fields.append(
642
+ f":confluence: <{self.incident.postmortem_for.page_url}|*Confluence Post-Mortem*>"
643
+ )
644
+
645
+ # Add Jira link if available
646
+ if hasattr(self.incident, "jira_postmortem_for"):
647
+ jira_pm = self.incident.jira_postmortem_for
648
+ fields.append(
649
+ f":jira_new: <{jira_pm.issue_url}|*Jira Post-Mortem ({jira_pm.jira_issue_key})*>"
650
+ )
651
+
652
+ blocks: list[Block] = [
653
+ SectionBlock(
654
+ text=f"📔 *Post-mortem created for incident #{self.incident.id}*"
655
+ ),
656
+ SectionBlock(text=f"*{shorten(self.incident.title, 2995, placeholder='...')}*"),
657
+ DividerBlock(),
658
+ SectionBlock(fields=fields),
659
+ ]
660
+
661
+ return blocks
662
+
663
+
664
+ class SlackMessagePostMortemReminder5Days(SlackMessageSurface):
665
+ """Reminder message sent 5 days after incident reaches MITIGATED status.
666
+
667
+ Sent to both the incident channel and #critical-incidents (tech_incidents tag).
668
+ """
669
+
670
+ id = "ff_incident_postmortem_reminder_5days"
671
+ incident: Incident
672
+
673
+ def __init__(self, incident: Incident) -> None:
674
+ self.incident = incident
675
+ super().__init__()
676
+
677
+ def get_text(self) -> str:
678
+ return f"⏰ Reminder: Post-mortem for incident #{self.incident.id} needs to be completed"
679
+
680
+ def get_blocks(self) -> list[Block]:
681
+ blocks: list[Block] = [
682
+ HeaderBlock(
683
+ text=PlainTextObject(
684
+ text="⏰ Post-mortem Reminder ⏰",
685
+ emoji=True,
686
+ )
687
+ ),
688
+ SectionBlock(
689
+ text="This incident was mitigated 5 days ago. The post-mortem *must* be completed to close the incident."
690
+ ),
691
+ DividerBlock(),
692
+ ]
693
+
694
+ # Add links to post-mortems
695
+ pm_links = []
696
+ if hasattr(self.incident, "postmortem_for"):
697
+ pm_links.append(
698
+ ButtonElement(
699
+ text="Open Confluence Post-Mortem",
700
+ url=self.incident.postmortem_for.page_edit_url,
701
+ action_id="open_link",
702
+ )
703
+ )
704
+
705
+ if hasattr(self.incident, "jira_postmortem_for"):
706
+ jira_pm = self.incident.jira_postmortem_for
707
+ pm_links.append(
708
+ ButtonElement(
709
+ text=f"Open Jira Post-Mortem ({jira_pm.jira_issue_key})",
710
+ url=jira_pm.issue_url,
711
+ action_id="open_link",
712
+ )
713
+ )
714
+
715
+ if pm_links:
716
+ blocks.append(ActionsBlock(elements=pm_links))
717
+
718
+ # Add action buttons
719
+ blocks.extend([
720
+ DividerBlock(),
721
+ SectionBlock(
722
+ text="Update the incident status or close it once the post-mortem is complete.",
723
+ accessory=ButtonElement(
724
+ text="Update status",
725
+ value=str(self.incident.id),
726
+ action_id=UpdateStatusModal.open_action,
727
+ ),
728
+ ),
729
+ ])
730
+
731
+ return blocks
732
+
733
+
734
+ class SlackMessagePostMortemReminder5DaysAnnouncement(SlackMessageSurface):
735
+ """Announcement version of 5-day reminder for #critical-incidents channel."""
736
+
737
+ id = "ff_incident_postmortem_reminder_5days_announcement"
738
+ incident: Incident
739
+
740
+ def __init__(self, incident: Incident) -> None:
741
+ self.incident = incident
742
+ super().__init__()
743
+
744
+ def get_text(self) -> str:
745
+ return f"⏰ Post-mortem reminder for {self.incident.priority} incident #{self.incident.id}: {self.incident.title}"
746
+
747
+ def get_blocks(self) -> list[Block]:
748
+ fields = [
749
+ f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
750
+ f":calendar: *Mitigated:* {date_time(self.incident.mitigated_at) if self.incident.mitigated_at else 'Unknown'}",
751
+ f":slack: *Channel:* <#{self.incident.conversation.channel_id}>",
752
+ ]
753
+
754
+ # Add post-mortem links
755
+ if hasattr(self.incident, "postmortem_for"):
756
+ fields.append(
757
+ f":confluence: <{self.incident.postmortem_for.page_url}|*Confluence Post-Mortem*>"
758
+ )
759
+
760
+ if hasattr(self.incident, "jira_postmortem_for"):
761
+ jira_pm = self.incident.jira_postmortem_for
762
+ fields.append(
763
+ f":jira_new: <{jira_pm.issue_url}|*Jira Post-Mortem ({jira_pm.jira_issue_key})*>"
764
+ )
765
+
766
+ blocks: list[Block] = [
767
+ SectionBlock(
768
+ text=f"⏰ *Post-mortem reminder for incident #{self.incident.id}*"
769
+ ),
770
+ SectionBlock(
771
+ text=f"*{shorten(self.incident.title, 2995, placeholder='...')}*\n_This incident was mitigated 5 days ago. Post-mortem completion is overdue._"
772
+ ),
773
+ DividerBlock(),
774
+ SectionBlock(fields=fields),
775
+ ]
776
+
777
+ return blocks
778
+
779
+
618
780
  class SlackMessageIncidentDuringOffHours(SlackMessageSurface):
619
781
  id = "ff_incident_during_off_hours"
620
782
 
@@ -0,0 +1,60 @@
1
+ # Generated by Django 4.2.21 on 2025-12-19 14:05
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ def create_postmortem_reminder_task(apps, schema_editor):
7
+ """Create periodic task for post-mortem reminders.
8
+
9
+ Runs twice daily at 10:00 AM and 3:00 PM to check for incidents mitigated 5+ days ago.
10
+ """
11
+ CrontabSchedule = apps.get_model("django_celery_beat", "CrontabSchedule")
12
+ PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
13
+
14
+ # Create crontab schedule for 10:00 AM and 3:00 PM (Europe/Paris timezone)
15
+ # Crontab format: minute hour day_of_week day_of_month month_of_year
16
+ # hour='10,15' means run at both 10:00 and 15:00
17
+ # Using Europe/Paris timezone handles DST automatically
18
+ schedule, _ = CrontabSchedule.objects.get_or_create(
19
+ minute="0",
20
+ hour="10,15",
21
+ day_of_week="*",
22
+ day_of_month="*",
23
+ month_of_year="*",
24
+ timezone="Europe/Paris",
25
+ )
26
+
27
+ # Create the periodic task
28
+ PeriodicTask.objects.get_or_create(
29
+ name="Send post-mortem reminders for mitigated incidents",
30
+ defaults={
31
+ "task": "slack.send_postmortem_reminders",
32
+ "crontab": schedule,
33
+ "enabled": True,
34
+ "description": "Send reminders for incidents mitigated 5+ days ago that still need post-mortem completion (runs at 10 AM and 3 PM)",
35
+ },
36
+ )
37
+
38
+
39
+ def remove_postmortem_reminder_task(apps, schema_editor):
40
+ """Remove the periodic task on migration rollback."""
41
+ PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask")
42
+
43
+ PeriodicTask.objects.filter(
44
+ task="slack.send_postmortem_reminders"
45
+ ).delete()
46
+
47
+
48
+ class Migration(migrations.Migration):
49
+
50
+ dependencies = [
51
+ ("slack", "0008_alter_conversation_incident_categories_and_more"),
52
+ ("django_celery_beat", "0019_alter_periodictasks_options"),
53
+ ]
54
+
55
+ operations = [
56
+ migrations.RunPython(
57
+ create_postmortem_reminder_task,
58
+ reverse_code=remove_postmortem_reminder_task,
59
+ ),
60
+ ]
@@ -52,3 +52,25 @@ def should_publish_in_it_deploy_channel(incident: Incident) -> bool:
52
52
  and not incident.private
53
53
  and incident.incident_category.deploy_warning
54
54
  )
55
+
56
+
57
+ def should_publish_pm_in_general_channel(incident: Incident) -> bool:
58
+ """Determine if post-mortem creation should be announced in #critical-incidents.
59
+
60
+ Post-mortems are announced for P1-P3 production incidents that are not private
61
+ and require a post-mortem.
62
+
63
+ Args:
64
+ incident: The incident for which a post-mortem was created.
65
+
66
+ Returns:
67
+ True if the post-mortem creation should be announced in tech_incidents channel.
68
+ """
69
+ return (
70
+ incident.priority is not None
71
+ and incident.priority.value <= 3
72
+ and incident.environment is not None
73
+ and incident.environment.value == "PRD"
74
+ and not incident.private
75
+ and incident.needs_postmortem
76
+ )
@@ -0,0 +1,127 @@
1
+ """Celery task to send post-mortem reminders for incidents.
2
+
3
+ This task runs periodically to check for incidents that were mitigated
4
+ 5 days ago and still need their post-mortem completed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from datetime import timedelta
11
+ from typing import TYPE_CHECKING
12
+
13
+ from celery import shared_task
14
+ from django.conf import settings
15
+ from django.utils import timezone
16
+
17
+ from firefighter.incidents.enums import IncidentStatus
18
+ from firefighter.incidents.models.incident import Incident
19
+ from firefighter.slack.models.conversation import Conversation
20
+ from firefighter.slack.rules import should_publish_pm_in_general_channel
21
+
22
+ if TYPE_CHECKING:
23
+ from firefighter.slack.models import Message
24
+
25
+ if settings.ENABLE_SLACK:
26
+ from firefighter.slack.messages.slack_messages import (
27
+ SlackMessagePostMortemReminder5Days,
28
+ SlackMessagePostMortemReminder5DaysAnnouncement,
29
+ )
30
+ from firefighter.slack.models import Message
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Days after mitigation to send reminder
35
+ POSTMORTEM_REMINDER_DAYS = 5
36
+
37
+
38
+ @shared_task(name="slack.send_postmortem_reminders")
39
+ def send_postmortem_reminders() -> None:
40
+ """Send post-mortem completion reminders for incidents mitigated 5+ days ago.
41
+
42
+ This task:
43
+ 1. Finds incidents that were mitigated at least 5 days ago
44
+ 2. Filters for incidents that still need their post-mortem completed
45
+ 3. Sends reminders to both the incident channel and #critical-incidents
46
+ 4. Tracks sent reminders to avoid duplicates
47
+ """
48
+ # Calculate the cutoff date (5 days ago)
49
+ cutoff_date = timezone.now() - timedelta(days=POSTMORTEM_REMINDER_DAYS)
50
+
51
+ # Get incidents that:
52
+ # - Were mitigated at least 5 days ago
53
+ # - Still need post-mortem (P1-P3)
54
+ # - Are in MITIGATED or POST_MORTEM status (not yet closed)
55
+ # - Are not ignored
56
+ incidents_needing_reminder = Incident.objects.filter(
57
+ mitigated_at__lte=cutoff_date,
58
+ mitigated_at__isnull=False,
59
+ _status__in=[
60
+ IncidentStatus.MITIGATED.value,
61
+ IncidentStatus.POST_MORTEM.value,
62
+ ],
63
+ priority__needs_postmortem=True,
64
+ ignore=False,
65
+ ).select_related("conversation", "priority", "environment")
66
+
67
+ logger.info(
68
+ f"Found {incidents_needing_reminder.count()} incidents needing post-mortem reminders"
69
+ )
70
+
71
+ for incident in incidents_needing_reminder:
72
+ # Check if we already sent a reminder for this incident
73
+ if Message.objects.filter(
74
+ ff_type=SlackMessagePostMortemReminder5Days.id,
75
+ incident=incident,
76
+ ).exists():
77
+ logger.debug(
78
+ f"Skipping incident #{incident.id} - reminder already sent"
79
+ )
80
+ continue
81
+
82
+ # Skip if no conversation
83
+ if not hasattr(incident, "conversation") or not incident.conversation:
84
+ logger.warning(
85
+ f"Incident #{incident.id} has no conversation, skipping reminder"
86
+ )
87
+ continue
88
+
89
+ # Send reminder to incident channel
90
+ try:
91
+ reminder_message = SlackMessagePostMortemReminder5Days(incident)
92
+ incident.conversation.send_message_and_save(reminder_message)
93
+ logger.info(
94
+ f"Sent post-mortem reminder to incident #{incident.id} channel"
95
+ )
96
+ except Exception:
97
+ logger.exception(
98
+ f"Failed to send post-mortem reminder to incident #{incident.id} channel"
99
+ )
100
+ continue
101
+
102
+ # Send announcement to #critical-incidents if applicable
103
+ if should_publish_pm_in_general_channel(incident):
104
+ try:
105
+ tech_incidents_conversation = Conversation.objects.get_or_none(
106
+ tag="tech_incidents"
107
+ )
108
+ if tech_incidents_conversation:
109
+ announcement = SlackMessagePostMortemReminder5DaysAnnouncement(
110
+ incident
111
+ )
112
+ tech_incidents_conversation.send_message_and_save(announcement)
113
+ logger.info(
114
+ f"Sent post-mortem reminder to tech_incidents for incident #{incident.id}"
115
+ )
116
+ else:
117
+ logger.warning(
118
+ "Could not find tech_incidents conversation! Is there a channel with tag tech_incidents?"
119
+ )
120
+ except Exception:
121
+ logger.exception(
122
+ f"Failed to send post-mortem reminder to tech_incidents for incident #{incident.id}"
123
+ )
124
+
125
+ logger.info(
126
+ f"Post-mortem reminder task completed. Processed {incidents_needing_reminder.count()} incidents."
127
+ )
@@ -104,7 +104,9 @@ class CloseModal(
104
104
  ]
105
105
  ),
106
106
  ]
107
- elif reason[0] == "STATUS_NOT_POST_MORTEM":
107
+ elif reason[0] == "STATUS_NOT_POST_MORTEM" and getattr(
108
+ settings, "ENABLE_CONFLUENCE", False
109
+ ):
108
110
  reason_blocks += [
109
111
  SectionBlock(
110
112
  text=f":warning: *Status is not _{IncidentStatus.POST_MORTEM.label}_* :warning:\n"
@@ -148,6 +150,44 @@ class CloseModal(
148
150
  ]
149
151
  ),
150
152
  ]
153
+ elif reason[0] == "JIRA_POSTMORTEM_NOT_READY" and getattr(
154
+ settings, "ENABLE_JIRA", False
155
+ ):
156
+ reason_blocks += [
157
+ SectionBlock(
158
+ text=":warning: *Jira post-mortem not ready* :warning:\n"
159
+ ),
160
+ ContextBlock(
161
+ elements=[
162
+ MarkdownTextObject(
163
+ text=f"{reason[1]}\nPlease move the Jira post-mortem to the Ready status before closing the incident."
164
+ )
165
+ ]
166
+ ),
167
+ ActionsBlock(
168
+ elements=[
169
+ ButtonElement(
170
+ text="Open Jira post-mortem",
171
+ action_id="open_link",
172
+ url=incident.jira_postmortem_for.issue_url,
173
+ style="primary",
174
+ ),
175
+ ]
176
+ ),
177
+ ]
178
+ elif reason[0] == "JIRA_POSTMORTEM_STATUS_UNKNOWN":
179
+ reason_blocks += [
180
+ SectionBlock(
181
+ text=":warning: *Could not verify Jira post-mortem status* :warning:\n"
182
+ ),
183
+ ContextBlock(
184
+ elements=[
185
+ MarkdownTextObject(
186
+ text=f"{reason[1]}\nPlease check the Jira post-mortem status before closing the incident."
187
+ )
188
+ ]
189
+ ),
190
+ ]
151
191
  elif reason[0] == "STATUS_NOT_MITIGATED":
152
192
  reason_blocks += [
153
193
  SectionBlock(
@@ -178,6 +218,74 @@ class CloseModal(
178
218
  ]
179
219
  ),
180
220
  ]
221
+ elif reason[0] == "POSTMORTEM_NOT_READY":
222
+ reason_blocks += [
223
+ SectionBlock(
224
+ text=":warning: *Post-mortem is not Ready* :warning:\n"
225
+ ),
226
+ ContextBlock(
227
+ elements=[
228
+ MarkdownTextObject(
229
+ text="The linked Jira post-mortem must be in the *Ready* status before closing the incident.\n"
230
+ + reason[1]
231
+ )
232
+ ]
233
+ ),
234
+ ActionsBlock(
235
+ elements=[
236
+ ButtonElement(
237
+ text="Open Jira post-mortem",
238
+ action_id="open_link",
239
+ url=(
240
+ getattr(
241
+ incident.jira_postmortem_for,
242
+ "issue_url",
243
+ None,
244
+ )
245
+ if hasattr(incident, "jira_postmortem_for")
246
+ else None
247
+ ),
248
+ style="primary",
249
+ ),
250
+ ButtonElement(
251
+ text="Update status",
252
+ action_id=UpdateStatusModal.push_action,
253
+ value=str(incident.id),
254
+ ),
255
+ ]
256
+ ),
257
+ ]
258
+ elif reason[0] == "POSTMORTEM_STATUS_UNKNOWN":
259
+ reason_blocks += [
260
+ SectionBlock(
261
+ text=":warning: *Could not verify post-mortem status* :warning:\n"
262
+ ),
263
+ ContextBlock(
264
+ elements=[
265
+ MarkdownTextObject(
266
+ text="We could not verify the Jira post-mortem status. Please open it in Jira to confirm it is Ready before closing."
267
+ )
268
+ ]
269
+ ),
270
+ ActionsBlock(
271
+ elements=[
272
+ ButtonElement(
273
+ text="Open Jira post-mortem",
274
+ action_id="open_link",
275
+ url=(
276
+ getattr(
277
+ incident.jira_postmortem_for,
278
+ "issue_url",
279
+ None,
280
+ )
281
+ if hasattr(incident, "jira_postmortem_for")
282
+ else None
283
+ ),
284
+ style="primary",
285
+ ),
286
+ ]
287
+ ),
288
+ ]
181
289
 
182
290
  return View(
183
291
  type="modal",
@@ -227,7 +335,7 @@ class CloseModal(
227
335
 
228
336
  def handle_modal_fn( # type: ignore[override]
229
337
  self, ack: Ack, body: dict[str, Any], incident: Incident, user: User
230
- ) -> bool | None:
338
+ ) -> bool | None:
231
339
  """Handle response from /incident close modal."""
232
340
  # Check if this should be handled by closure reason modal
233
341
  closure_result = handle_close_modal_callback(ack, body, incident, user)
@@ -243,7 +351,9 @@ class CloseModal(
243
351
  update_kwargs = {}
244
352
  for changed_key in form.changed_data:
245
353
  if changed_key == "incident_category":
246
- update_kwargs["incident_category_id"] = form.cleaned_data[changed_key].id
354
+ update_kwargs["incident_category_id"] = form.cleaned_data[
355
+ changed_key
356
+ ].id
247
357
  if changed_key in {"description", "title", "message"}:
248
358
  update_kwargs[changed_key] = form.cleaned_data[changed_key]
249
359
  # Check can close
@@ -115,36 +115,63 @@ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
115
115
  self, ack: Ack, body: dict[str, Any], incident: Incident, user: User
116
116
  ) -> bool | None:
117
117
  """Handle the closure reason modal submission."""
118
- # Early validation: Check if incident can be closed BEFORE calling ack()
119
- # This validation happens BEFORE ack() so we can display errors in the modal
120
- can_close, reasons = incident.can_be_closed
121
- if not can_close:
122
- # Build error message from reasons
123
- error_messages = [reason[1] for reason in reasons]
124
- error_text = "\n".join([f"• {msg}" for msg in error_messages])
125
- ack(
126
- response_action="errors",
127
- errors={
128
- "closure_message": f"Cannot close this incident:\n{error_text}"
129
- }
130
- )
131
- return False
132
-
133
- # Clear ALL modals in the stack (not just this one)
134
- # This ensures the underlying "Update Status" modal is also closed
135
- ack(response_action="clear")
136
-
137
- # Extract form values
118
+ # Extract form values up-front so validation can account for the submitted reason
138
119
  state_values = body["view"]["state"]["values"]
139
120
  closure_reason = state_values["closure_reason"]["select_closure_reason"][
140
121
  "selected_option"
141
122
  ]["value"]
142
123
  closure_reference = (
143
- state_values["closure_reference"]["input_closure_reference"].get("value", "")
124
+ state_values["closure_reference"]["input_closure_reference"].get(
125
+ "value", ""
126
+ )
144
127
  or ""
145
128
  )
146
129
  message = state_values["closure_message"]["input_closure_message"]["value"]
147
130
 
131
+ # For early closure (OPEN/INVESTIGATING), we bypass normal workflow checks
132
+ # For normal closure (MITIGATED/POST_MORTEM), we must validate key events
133
+ current_status = incident.status
134
+ is_early_closure = current_status.value in {IncidentStatus.OPEN, IncidentStatus.INVESTIGATING}
135
+
136
+ if not is_early_closure:
137
+ # Normal closure path - validate that incident can be closed
138
+ can_close, reasons = incident.can_be_closed
139
+ if not can_close:
140
+ # Build error message from reasons
141
+ error_messages = [reason[1] for reason in reasons]
142
+ error_text = "\n".join([f"• {msg}" for msg in error_messages])
143
+ ack(
144
+ response_action="errors",
145
+ errors={
146
+ "closure_message": f"Cannot close this incident:\n{error_text}"
147
+ }
148
+ )
149
+ return False
150
+ else:
151
+ # Early closure path - validate with the submitted closure reason
152
+ # Temporarily inject the submitted closure_reason so early-closure bypass applies
153
+ original_closure_reason = incident.closure_reason
154
+ incident.closure_reason = closure_reason
155
+ try:
156
+ can_close, reasons = incident.can_be_closed
157
+ finally:
158
+ incident.closure_reason = original_closure_reason
159
+ if not can_close:
160
+ # Build error message from reasons
161
+ error_messages = [reason[1] for reason in reasons]
162
+ error_text = "\n".join([f"• {msg}" for msg in error_messages])
163
+ ack(
164
+ response_action="errors",
165
+ errors={
166
+ "closure_message": f"Cannot close this incident:\n{error_text}"
167
+ },
168
+ )
169
+ return False
170
+
171
+ # Clear ALL modals in the stack (not just this one)
172
+ # This ensures the underlying "Update Status" modal is also closed
173
+ ack(response_action="clear")
174
+
148
175
  try:
149
176
  # Update incident with closure fields
150
177
  incident.closure_reason = closure_reason
@@ -160,9 +187,7 @@ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
160
187
  )
161
188
 
162
189
  except Exception:
163
- logger.exception(
164
- "Error closing incident #%s with reason", incident.id
165
- )
190
+ logger.exception("Error closing incident #%s with reason", incident.id)
166
191
  respond(
167
192
  body=body,
168
193
  text=f"❌ Failed to close incident #{incident.id}",
@@ -177,7 +202,11 @@ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
177
202
  f"✅ Incident #{incident.id} has been closed.\n"
178
203
  f"*Reason:* {ClosureReason(closure_reason).label}\n"
179
204
  f"*Message:* {message}"
180
- + (f"\n*Reference:* {closure_reference}" if closure_reference else "")
205
+ + (
206
+ f"\n*Reference:* {closure_reference}"
207
+ if closure_reference
208
+ else ""
209
+ )
181
210
  ),
182
211
  )
183
212
  except SlackApiError as e:
@@ -127,12 +127,12 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
127
127
  # Build error message from reasons
128
128
  error_messages = [reason[1] for reason in reasons]
129
129
  error_text = "\n".join([f"• {msg}" for msg in error_messages])
130
- logger.warning(f"Cannot close incident #{incident.id} via Update Status: {error_text}")
130
+ logger.warning(
131
+ f"Cannot close incident #{incident.id} via Update Status: {error_text}"
132
+ )
131
133
  ack(
132
134
  response_action="errors",
133
- errors={
134
- "status": f"Cannot close this incident:\n{error_text}"
135
- }
135
+ errors={"status": f"{error_text}"},
136
136
  )
137
137
  return
138
138
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: firefighter-incident
3
- Version: 0.0.25
3
+ Version: 0.0.27
4
4
  Summary: Incident Management tool made for Slack using Django
5
5
  Project-URL: Repository, https://github.com/ManoManoTech/firefighter-incident
6
6
  Project-URL: Documentation, https://manomanotech.github.io/firefighter-incident/latest/