firefighter-incident 0.0.22__py3-none-any.whl → 0.0.23__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 (50) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/api/serializers.py +18 -0
  3. firefighter/api/views/incidents.py +3 -0
  4. firefighter/confluence/models.py +66 -6
  5. firefighter/confluence/signals/incident_updated.py +8 -26
  6. firefighter/firefighter/settings/components/jira_app.py +33 -0
  7. firefighter/incidents/admin.py +3 -0
  8. firefighter/incidents/models/impact.py +3 -5
  9. firefighter/incidents/models/incident.py +24 -9
  10. firefighter/incidents/views/views.py +2 -0
  11. firefighter/jira_app/admin.py +15 -1
  12. firefighter/jira_app/apps.py +3 -0
  13. firefighter/jira_app/client.py +151 -3
  14. firefighter/jira_app/management/__init__.py +1 -0
  15. firefighter/jira_app/management/commands/__init__.py +1 -0
  16. firefighter/jira_app/migrations/0002_add_jira_postmortem_model.py +71 -0
  17. firefighter/jira_app/models.py +50 -0
  18. firefighter/jira_app/service_postmortem.py +292 -0
  19. firefighter/jira_app/signals/__init__.py +10 -0
  20. firefighter/jira_app/signals/incident_key_events_updated.py +88 -0
  21. firefighter/jira_app/signals/postmortem_created.py +155 -0
  22. firefighter/jira_app/templates/jira/postmortem/impact.txt +12 -0
  23. firefighter/jira_app/templates/jira/postmortem/incident_summary.txt +17 -0
  24. firefighter/jira_app/templates/jira/postmortem/mitigation_actions.txt +9 -0
  25. firefighter/jira_app/templates/jira/postmortem/root_causes.txt +12 -0
  26. firefighter/jira_app/templates/jira/postmortem/timeline.txt +7 -0
  27. firefighter/raid/signals/incident_updated.py +31 -11
  28. firefighter/slack/messages/slack_messages.py +39 -3
  29. firefighter/slack/signals/postmortem_created.py +51 -3
  30. firefighter/slack/views/modals/closure_reason.py +15 -0
  31. firefighter/slack/views/modals/key_event_message.py +9 -0
  32. firefighter/slack/views/modals/postmortem.py +32 -40
  33. firefighter/slack/views/modals/update_status.py +7 -1
  34. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/METADATA +1 -1
  35. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/RECORD +50 -31
  36. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/WHEEL +1 -1
  37. firefighter_tests/test_api/test_renderer.py +41 -0
  38. firefighter_tests/test_incidents/test_models/test_incident_model.py +29 -0
  39. firefighter_tests/test_jira_app/test_incident_key_events_sync.py +112 -0
  40. firefighter_tests/test_jira_app/test_models.py +138 -0
  41. firefighter_tests/test_jira_app/test_postmortem_issue_link.py +201 -0
  42. firefighter_tests/test_jira_app/test_postmortem_service.py +416 -0
  43. firefighter_tests/test_jira_app/test_timeline_template.py +135 -0
  44. firefighter_tests/test_raid/test_raid_signals.py +50 -8
  45. firefighter_tests/test_slack/messages/test_slack_messages.py +112 -23
  46. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +18 -2
  47. firefighter_tests/test_slack/views/modals/test_key_event_message.py +30 -0
  48. firefighter_tests/test_slack/views/modals/test_update_status.py +161 -129
  49. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/entry_points.txt +0 -0
  50. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/licenses/LICENSE +0 -0
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from django.conf import settings
3
4
  from django.db import models
4
5
  from django_stubs_ext.db.models import TypedModelMeta
5
6
 
@@ -59,3 +60,52 @@ class JiraIssue(models.Model):
59
60
 
60
61
  def __str__(self) -> str:
61
62
  return f"{self.key}: {self.summary}"
63
+
64
+
65
+ class JiraPostMortem(models.Model):
66
+ """Jira Post-mortem linked to an Incident."""
67
+
68
+ incident = models.OneToOneField(
69
+ "incidents.Incident",
70
+ on_delete=models.CASCADE,
71
+ related_name="jira_postmortem_for",
72
+ help_text="Incident this post-mortem is for",
73
+ )
74
+
75
+ jira_issue_key = models.CharField(
76
+ max_length=32,
77
+ unique=True,
78
+ help_text="Jira issue key (e.g., INCIDENT-123)",
79
+ )
80
+
81
+ jira_issue_id = models.CharField(
82
+ max_length=32,
83
+ unique=True,
84
+ help_text="Jira issue ID",
85
+ )
86
+
87
+ created_at = models.DateTimeField(auto_now_add=True)
88
+ updated_at = models.DateTimeField(auto_now=True)
89
+
90
+ created_by = models.ForeignKey(
91
+ User,
92
+ on_delete=models.SET_NULL,
93
+ null=True,
94
+ blank=True,
95
+ related_name="jira_postmortems_created",
96
+ )
97
+
98
+ class Meta(TypedModelMeta):
99
+ db_table = "jira_postmortem"
100
+ verbose_name = "Jira Post-mortem"
101
+ verbose_name_plural = "Jira Post-mortems"
102
+
103
+ def __str__(self) -> str:
104
+ return (
105
+ f"Jira Post-mortem {self.jira_issue_key} for incident #{self.incident.id}"
106
+ )
107
+
108
+ @property
109
+ def issue_url(self) -> str:
110
+ """Return Jira issue URL."""
111
+ return f"{settings.RAID_JIRA_API_URL}/browse/{self.jira_issue_key}"
@@ -0,0 +1,292 @@
1
+ """Service for creating and managing Jira post-mortems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime, timedelta
7
+ from typing import TYPE_CHECKING
8
+
9
+ from django.conf import settings
10
+ from django.template.loader import render_to_string
11
+
12
+ from firefighter.jira_app.client import (
13
+ JiraClient,
14
+ JiraUserDatabaseError,
15
+ JiraUserNotFoundError,
16
+ )
17
+ from firefighter.jira_app.models import JiraPostMortem
18
+
19
+ if TYPE_CHECKING:
20
+ from firefighter.incidents.models.incident import Incident
21
+ from firefighter.incidents.models.user import User
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class JiraPostMortemService:
27
+ """Service for creating and managing Jira post-mortems."""
28
+
29
+ def __init__(self) -> None:
30
+ self.client = JiraClient()
31
+ self.project_key = getattr(settings, "JIRA_POSTMORTEM_PROJECT_KEY", "INCIDENT")
32
+ self.issue_type = getattr(settings, "JIRA_POSTMORTEM_ISSUE_TYPE", "Post-mortem")
33
+ self.field_ids = getattr(
34
+ settings,
35
+ "JIRA_POSTMORTEM_FIELDS",
36
+ {
37
+ "incident_summary": "customfield_12699",
38
+ "timeline": "customfield_12700",
39
+ "root_causes": "customfield_12701",
40
+ "impact": "customfield_12702",
41
+ "mitigation_actions": "customfield_12703",
42
+ "incident_category": "customfield_12369",
43
+ },
44
+ )
45
+
46
+ def create_postmortem_for_incident(
47
+ self,
48
+ incident: Incident,
49
+ created_by: User | None = None,
50
+ ) -> JiraPostMortem:
51
+ """Create a Jira post-mortem for an incident.
52
+
53
+ Args:
54
+ incident: Incident to create post-mortem for
55
+ created_by: User creating the post-mortem
56
+
57
+ Returns:
58
+ JiraPostMortem instance
59
+
60
+ Raises:
61
+ ValueError: If incident already has a Jira post-mortem
62
+ JiraAPIError: If Jira API call fails
63
+ """
64
+ if hasattr(incident, "jira_postmortem_for"):
65
+ error_msg = f"Incident #{incident.id} already has a Jira post-mortem"
66
+ raise ValueError(error_msg)
67
+
68
+ logger.info(f"Creating Jira post-mortem for incident #{incident.id}")
69
+
70
+ # Prefetch incident updates and jira_ticket for timeline and parent link
71
+ from firefighter.incidents.models.incident import Incident # noqa: PLC0415
72
+
73
+ incident = (
74
+ Incident.objects.select_related("priority", "environment", "jira_ticket")
75
+ .prefetch_related("incidentupdate_set")
76
+ .get(pk=incident.pk)
77
+ )
78
+
79
+ # Generate content from templates
80
+ fields = self._generate_issue_fields(incident)
81
+
82
+ # Get parent issue key from RAID Jira ticket if available
83
+ parent_issue_key = None
84
+ if hasattr(incident, "jira_ticket") and incident.jira_ticket:
85
+ parent_issue_key = incident.jira_ticket.key
86
+
87
+ # Create Jira issue with optional parent link
88
+ jira_issue = self.client.create_postmortem_issue(
89
+ project_key=self.project_key,
90
+ issue_type=self.issue_type,
91
+ fields=fields,
92
+ parent_issue_key=parent_issue_key,
93
+ )
94
+
95
+ # Assign to incident commander if available
96
+ commander = (
97
+ incident.roles_set.select_related("user__jira_user", "role_type")
98
+ .filter(role_type__slug="commander")
99
+ .first()
100
+ )
101
+ if commander:
102
+ jira_user = getattr(commander.user, "jira_user", None)
103
+ if jira_user is None:
104
+ try:
105
+ jira_user = self.client.get_jira_user_from_user(commander.user)
106
+ except (JiraUserNotFoundError, JiraUserDatabaseError) as exc:
107
+ logger.warning(
108
+ "Unable to fetch Jira user for commander %s: %s",
109
+ commander.user_id,
110
+ exc,
111
+ )
112
+ if jira_user is not None:
113
+ assigned = self.client.assign_issue(
114
+ issue_key=jira_issue["key"],
115
+ account_id=jira_user.id,
116
+ )
117
+ if assigned:
118
+ logger.info(
119
+ "Assigned post-mortem %s to commander %s",
120
+ jira_issue["key"],
121
+ commander.user.username,
122
+ )
123
+
124
+ # Create JiraPostMortem record
125
+ jira_postmortem = JiraPostMortem.objects.create(
126
+ incident=incident,
127
+ jira_issue_key=jira_issue["key"],
128
+ jira_issue_id=jira_issue["id"],
129
+ created_by=created_by,
130
+ )
131
+
132
+ logger.info(
133
+ f"Created Jira post-mortem {jira_postmortem.jira_issue_key} "
134
+ f"for incident #{incident.id}"
135
+ )
136
+
137
+ return jira_postmortem
138
+
139
+ def _generate_issue_fields(
140
+ self, incident: Incident
141
+ ) -> dict[str, str | dict[str, str] | list[dict[str, str]]]:
142
+ """Generate Jira issue fields from incident data.
143
+
144
+ Args:
145
+ incident: Incident to generate fields for
146
+
147
+ Returns:
148
+ Dictionary of Jira field IDs to values
149
+ """
150
+ # Generate summary (standard field)
151
+ env = getattr(settings, "ENV", "dev")
152
+ topic_prefix = "" if env in {"support", "prod"} else f"[IGNORE - TEST {env}] "
153
+ summary = (
154
+ f"{topic_prefix}#{incident.slack_channel_name} "
155
+ f"({incident.priority.name}) {incident.title}"
156
+ )
157
+
158
+ # Generate content from Django templates (Jira Wiki Markup)
159
+ context = {
160
+ "incident": incident,
161
+ "priority": incident.priority,
162
+ "created_at": incident.created_at,
163
+ "components": [], # No component relationship available
164
+ }
165
+
166
+ incident_summary = render_to_string(
167
+ "jira/postmortem/incident_summary.txt",
168
+ context,
169
+ )
170
+
171
+ timeline = render_to_string(
172
+ "jira/postmortem/timeline.txt",
173
+ context,
174
+ )
175
+
176
+ impact = render_to_string(
177
+ "jira/postmortem/impact.txt",
178
+ context,
179
+ )
180
+
181
+ mitigation_actions = render_to_string(
182
+ "jira/postmortem/mitigation_actions.txt",
183
+ context,
184
+ )
185
+
186
+ # Optional: root causes (editable placeholder for manual completion)
187
+ root_causes = render_to_string(
188
+ "jira/postmortem/root_causes.txt",
189
+ context,
190
+ )
191
+
192
+ # Build field mapping
193
+ fields: dict[str, str | dict[str, str] | list[dict[str, str]]] = {
194
+ "summary": summary,
195
+ self.field_ids["incident_summary"]: incident_summary,
196
+ self.field_ids["timeline"]: timeline,
197
+ self.field_ids["impact"]: impact,
198
+ self.field_ids["mitigation_actions"]: mitigation_actions,
199
+ }
200
+
201
+ due_date = self._add_business_days(incident.created_at, 40).date()
202
+ fields["duedate"] = due_date.isoformat()
203
+
204
+ # Add optional fields if not empty
205
+ if root_causes.strip():
206
+ fields[self.field_ids["root_causes"]] = root_causes
207
+
208
+ # Add incident category if available
209
+ if incident.incident_category:
210
+ # Jira select field requires dict with value key
211
+ category_field_id = self.field_ids["incident_category"]
212
+ fields[category_field_id] = {"value": incident.incident_category.name}
213
+
214
+ # Replicate custom fields from incident ticket to post-mortem
215
+ self._add_replicated_custom_fields(incident, fields)
216
+
217
+ return fields
218
+
219
+ def _add_replicated_custom_fields(
220
+ self,
221
+ incident: Incident,
222
+ fields: dict[str, str | dict[str, str] | list[dict[str, str]]],
223
+ ) -> None:
224
+ """Replicate custom fields from incident ticket to post-mortem.
225
+
226
+ Replicates the following fields from the incident ticket:
227
+ - Priority (customfield_11064)
228
+ - Affected environments (customfield_11049)
229
+ - Zoho desk ticket (customfield_10896)
230
+ - Zendesk ticket (customfield_10895)
231
+ - Seller Contract ID (customfield_10908)
232
+ - Platform (customfield_10201)
233
+ - Business Impact (customfield_10936)
234
+
235
+ Args:
236
+ incident: Incident to extract fields from
237
+ fields: Dictionary to add fields to (modified in place)
238
+ """
239
+ custom_fields = incident.custom_fields or {}
240
+
241
+ # Priority - customfield_11064 (option field)
242
+ if incident.priority:
243
+ priority_value = str(incident.priority.value)
244
+ fields["customfield_11064"] = {"value": priority_value}
245
+
246
+ # Affected environments - customfield_11049 (array field)
247
+ environments = custom_fields.get("environments", [])
248
+ if environments:
249
+ fields["customfield_11049"] = [{"value": env} for env in environments]
250
+
251
+ # Zendesk ticket - customfield_10895 (string field)
252
+ zendesk_ticket_id = custom_fields.get("zendesk_ticket_id")
253
+ if zendesk_ticket_id:
254
+ fields["customfield_10895"] = str(zendesk_ticket_id)
255
+
256
+ # Zoho desk ticket - customfield_10896 (string field)
257
+ zoho_desk_ticket_id = custom_fields.get("zoho_desk_ticket_id")
258
+ if zoho_desk_ticket_id:
259
+ fields["customfield_10896"] = str(zoho_desk_ticket_id)
260
+
261
+ # Seller Contract ID - customfield_10908 (string field)
262
+ seller_contract_id = custom_fields.get("seller_contract_id")
263
+ if seller_contract_id:
264
+ fields["customfield_10908"] = str(seller_contract_id)
265
+
266
+ # Platform - customfield_10201 (option field)
267
+ platform = custom_fields.get("platform")
268
+ if platform:
269
+ # Remove "platform-" prefix if present
270
+ platform_value = platform.replace("platform-", "") if isinstance(platform, str) else platform
271
+ fields["customfield_10201"] = {"value": platform_value}
272
+
273
+ # Business Impact - customfield_10936 (option field)
274
+ # Business impact is stored in the Jira ticket, not in custom_fields
275
+ if hasattr(incident, "jira_ticket") and incident.jira_ticket:
276
+ business_impact = incident.jira_ticket.business_impact
277
+ if business_impact and business_impact not in {"", "N/A"}:
278
+ fields["customfield_10936"] = {"value": business_impact}
279
+
280
+ @staticmethod
281
+ def _add_business_days(start: datetime, days: int) -> datetime:
282
+ current = start
283
+ added = 0
284
+ while added < days:
285
+ current += timedelta(days=1)
286
+ if current.weekday() < 5:
287
+ added += 1
288
+ return current
289
+
290
+
291
+ # Singleton instance
292
+ jira_postmortem_service = JiraPostMortemService()
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from firefighter.jira_app.signals.incident_key_events_updated import (
4
+ sync_key_events_to_jira_postmortem,
5
+ )
6
+ from firefighter.jira_app.signals.postmortem_created import (
7
+ postmortem_created_handler,
8
+ )
9
+
10
+ __all__ = ["postmortem_created_handler", "sync_key_events_to_jira_postmortem"]
@@ -0,0 +1,88 @@
1
+ """Signal handlers for incident key events updates to sync with Jira post-mortem."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from django.conf import settings
9
+ from django.dispatch import receiver
10
+ from django.template.loader import render_to_string
11
+
12
+ from firefighter.incidents.models.incident import Incident as IncidentModel
13
+ from firefighter.incidents.signals import incident_key_events_updated
14
+ from firefighter.jira_app.client import JiraClient
15
+ from firefighter.jira_app.service_postmortem import JiraPostMortemService
16
+
17
+ if TYPE_CHECKING:
18
+ from firefighter.incidents.models.incident import Incident
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @receiver(signal=incident_key_events_updated)
24
+ def sync_key_events_to_jira_postmortem(
25
+ sender: Any, incident: Incident, **kwargs: dict[str, Any]
26
+ ) -> None:
27
+ """Update Jira post-mortem timeline when key events are updated.
28
+
29
+ This handler is triggered when incident key events are updated via the web UI
30
+ or Slack, and syncs the timeline to the associated Jira post-mortem ticket.
31
+
32
+ Args:
33
+ sender: The sender of the signal
34
+ incident: The incident whose key events were updated
35
+ **kwargs: Additional keyword arguments
36
+ """
37
+ # Check if Jira post-mortem is enabled
38
+ if not getattr(settings, "ENABLE_JIRA_POSTMORTEM", False):
39
+ logger.debug("Jira post-mortem disabled, skipping timeline sync")
40
+ return
41
+
42
+ # Check if incident has a Jira post-mortem
43
+ if not hasattr(incident, "jira_postmortem_for") or not incident.jira_postmortem_for:
44
+ logger.debug(f"Incident #{incident.id} has no Jira post-mortem, skipping timeline sync")
45
+ return
46
+
47
+ jira_postmortem = incident.jira_postmortem_for
48
+ logger.info(
49
+ f"Syncing key events timeline to Jira post-mortem {jira_postmortem.jira_issue_key} "
50
+ f"for incident #{incident.id}"
51
+ )
52
+
53
+ try:
54
+ # Prefetch incident updates for timeline generation
55
+ incident_refreshed = (
56
+ IncidentModel.objects.select_related("priority", "environment")
57
+ .prefetch_related("incidentupdate_set")
58
+ .get(pk=incident.pk)
59
+ )
60
+
61
+ # Generate updated timeline from template
62
+ timeline_content = render_to_string(
63
+ "jira/postmortem/timeline.txt",
64
+ {"incident": incident_refreshed},
65
+ )
66
+
67
+ # Get the field ID for timeline from service
68
+ service = JiraPostMortemService()
69
+ timeline_field_id = service.field_ids.get("timeline")
70
+
71
+ if not timeline_field_id:
72
+ logger.error("Timeline field ID not found in Jira post-mortem service configuration")
73
+ return
74
+
75
+ # Update the Jira ticket
76
+ client = JiraClient()
77
+ issue = client.jira.issue(jira_postmortem.jira_issue_key)
78
+ issue.update(fields={timeline_field_id: timeline_content})
79
+
80
+ logger.info(
81
+ f"Successfully updated timeline in Jira post-mortem {jira_postmortem.jira_issue_key}"
82
+ )
83
+
84
+ except Exception:
85
+ logger.exception(
86
+ f"Failed to update timeline in Jira post-mortem {jira_postmortem.jira_issue_key} "
87
+ f"for incident #{incident.id}"
88
+ )
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any, Never
5
+
6
+ from django.apps import apps
7
+ from django.conf import settings
8
+ from django.dispatch.dispatcher import receiver
9
+
10
+ from firefighter.incidents.enums import IncidentStatus
11
+ from firefighter.incidents.signals import incident_updated
12
+
13
+ if TYPE_CHECKING:
14
+ from firefighter.confluence.models import PostMortemManager
15
+ from firefighter.incidents.models.incident import Incident
16
+ from firefighter.incidents.models.incident_update import IncidentUpdate
17
+ from firefighter.jira_app.service_postmortem import JiraPostMortemService
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _get_jira_postmortem_service() -> JiraPostMortemService:
23
+ """Lazy import to avoid circular dependency."""
24
+ from firefighter.jira_app.service_postmortem import ( # noqa: PLC0415
25
+ jira_postmortem_service,
26
+ )
27
+
28
+ return jira_postmortem_service
29
+
30
+
31
+ def _get_confluence_postmortem_manager() -> type[PostMortemManager] | None:
32
+ """Lazy import to avoid circular dependency with Confluence."""
33
+ if not apps.is_installed("firefighter.confluence"):
34
+ return None
35
+ from firefighter.confluence.models import PostMortemManager # noqa: PLC0415
36
+
37
+ return PostMortemManager
38
+
39
+
40
+ @receiver(signal=incident_updated)
41
+ def postmortem_created_handler(
42
+ sender: Any,
43
+ incident: Incident,
44
+ incident_update: IncidentUpdate,
45
+ updated_fields: list[str],
46
+ **kwargs: Never,
47
+ ) -> None:
48
+ """Handle post-mortem creation when incident reaches MITIGATED status.
49
+
50
+ This handler is registered in jira_app to ensure it works independently
51
+ of Confluence being enabled. It creates post-mortems for both Confluence
52
+ and Jira based on their respective feature flags.
53
+ """
54
+ logger.debug(
55
+ f"postmortem_created_handler called with sender={sender}, "
56
+ f"incident_id={incident.id}, status={incident_update.status}, "
57
+ f"updated_fields={updated_fields}"
58
+ )
59
+
60
+ if not apps.is_installed("firefighter.slack"):
61
+ logger.error("Slack app is not installed. Skipping.")
62
+ return
63
+
64
+ # Import Slack tasks after apps are loaded
65
+ from firefighter.slack.tasks.reminder_postmortem import ( # noqa: PLC0415
66
+ publish_fixed_next_actions,
67
+ publish_postmortem_reminder,
68
+ )
69
+
70
+ logger.debug(f"Checking sender: sender={sender}, type={type(sender)}")
71
+ if sender != "update_status":
72
+ logger.debug(f"Ignoring signal from sender={sender}")
73
+ return
74
+
75
+ logger.debug("Sender is update_status, checking postmortem conditions")
76
+
77
+ # Check if we should create post-mortem(s)
78
+ if (
79
+ "_status" not in updated_fields
80
+ or incident_update.status
81
+ not in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
82
+ or not incident.needs_postmortem
83
+ ):
84
+ logger.debug(
85
+ f"Not creating post-mortem: _status in fields={('_status' in updated_fields)}, "
86
+ f"status={incident_update.status}, needs_postmortem={incident.needs_postmortem}"
87
+ )
88
+ # For P3+ incidents, publish next actions reminder
89
+ if (
90
+ "_status" in updated_fields
91
+ and incident_update.status
92
+ in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
93
+ and not incident.needs_postmortem
94
+ ):
95
+ publish_fixed_next_actions(incident)
96
+ return
97
+
98
+ logger.info(
99
+ f"Creating post-mortem(s) for incident #{incident.id} "
100
+ f"(status={incident_update.status}, needs_postmortem={incident.needs_postmortem})"
101
+ )
102
+
103
+ enable_confluence = getattr(settings, "ENABLE_CONFLUENCE", False)
104
+ enable_jira_postmortem = getattr(settings, "ENABLE_JIRA_POSTMORTEM", False)
105
+
106
+ confluence_pm = None
107
+ jira_pm = None
108
+
109
+ # Check and create Confluence post-mortem
110
+ if enable_confluence:
111
+ has_confluence = hasattr(incident, "postmortem_for")
112
+ logger.debug(f"Confluence enabled, has_confluence={has_confluence}")
113
+ if not has_confluence:
114
+ confluence_manager = _get_confluence_postmortem_manager()
115
+ if confluence_manager:
116
+ logger.info(f"Creating Confluence post-mortem for incident #{incident.id}")
117
+ try:
118
+ confluence_pm = confluence_manager._create_confluence_postmortem( # noqa: SLF001
119
+ incident
120
+ )
121
+ except Exception:
122
+ logger.exception(
123
+ f"Failed to create Confluence post-mortem for incident #{incident.id}"
124
+ )
125
+ else:
126
+ logger.debug(f"Confluence post-mortem already exists for incident #{incident.id}")
127
+
128
+ # Check and create Jira post-mortem
129
+ if enable_jira_postmortem:
130
+ has_jira = hasattr(incident, "jira_postmortem_for")
131
+ logger.debug(f"Jira post-mortem enabled, has_jira={has_jira}")
132
+ if not has_jira:
133
+ logger.info(f"Creating Jira post-mortem for incident #{incident.id}")
134
+ try:
135
+ jira_service = _get_jira_postmortem_service()
136
+ jira_pm = jira_service.create_postmortem_for_incident(incident)
137
+ except Exception:
138
+ logger.exception(
139
+ f"Failed to create Jira post-mortem for incident #{incident.id}"
140
+ )
141
+ else:
142
+ logger.debug(f"Jira post-mortem already exists for incident #{incident.id}")
143
+
144
+ # Send signal if at least one post-mortem was created
145
+ if confluence_pm or jira_pm:
146
+ from firefighter.incidents.signals import postmortem_created # noqa: PLC0415
147
+
148
+ logger.info(
149
+ f"Post-mortem(s) created for incident #{incident.id}: "
150
+ f"confluence={confluence_pm is not None}, jira={jira_pm is not None}"
151
+ )
152
+ postmortem_created.send_robust(sender=__name__, incident=incident)
153
+
154
+ # Publish reminder
155
+ publish_postmortem_reminder(incident)
@@ -0,0 +1,12 @@
1
+ h2. Impact
2
+
3
+ h3. Affected Systems
4
+ {% if components %}
5
+ {% for component in components %}* {{ component.name }}
6
+ {% endfor %}
7
+ {% else %}_No components recorded._
8
+ {% endif %}
9
+
10
+ h3. User Impact
11
+
12
+ *{color:red}_TODO: Describe the impact on users and business._{color}*
@@ -0,0 +1,17 @@
1
+ h2. Incident Summary
2
+
3
+ *Incident:* [#{{ incident.slack_channel_name }}|{{ incident.slack_channel_url }}]
4
+ *Priority:* {{ incident.priority.name }} (P{{ incident.priority.value }})
5
+ {% if incident.description %}
6
+
7
+ h3. Description
8
+
9
+ {{ incident.description }}
10
+ {% endif %}
11
+ {% if components %}
12
+
13
+ h3. Affected Components
14
+
15
+ {% for component in components %}* {{ component.name }}
16
+ {% endfor %}
17
+ {% endif %}
@@ -0,0 +1,9 @@
1
+ h2. Mitigation Actions
2
+
3
+ h3. Immediate Actions Taken
4
+
5
+ *{color:red}_TODO: List the immediate actions taken to mitigate the incident._{color}*
6
+
7
+ h3. Long-term Actions
8
+
9
+ *{color:red}_TODO: List the long-term actions to prevent recurrence._{color}*
@@ -0,0 +1,12 @@
1
+ h2. Root Causes
2
+
3
+ *{color:red}_TODO: Analyze and document the root causes of this incident._{color}*
4
+
5
+ h3. Contributing Factors
6
+
7
+ * *{color:red}_TODO: Factor 1_{color}*
8
+ * *{color:red}_TODO: Factor 2_{color}*
9
+
10
+ h3. Why it happened
11
+
12
+ *{color:red}_TODO: Explain why this incident occurred._{color}*
@@ -0,0 +1,7 @@
1
+ h2. Timeline
2
+
3
+ || Time || Event ||
4
+ | {{ incident.created_at|date:"Y-m-d H:i" }} UTC | Incident created ({{ incident.priority.name }}) |
5
+ {% for update in incident.incidentupdate_set.all|dictsort:"event_ts" %}{% if update.status %}| {{ update.event_ts|date:"Y-m-d H:i" }} UTC | Status changed to: {{ update.status.label }} |
6
+ {% endif %}{% if update.event_type %}| {{ update.event_ts|date:"Y-m-d H:i" }} UTC | Key event: {{ update.event_type|title }}{% if update.message %} - {{ update.message }}{% endif %} |
7
+ {% endif %}{% endfor %}