firefighter-incident 0.0.14__py3-none-any.whl → 0.0.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/api/serializers.py +9 -0
  3. firefighter/confluence/signals/incident_updated.py +2 -2
  4. firefighter/incidents/enums.py +22 -2
  5. firefighter/incidents/forms/closure_reason.py +45 -0
  6. firefighter/incidents/forms/unified_incident.py +406 -0
  7. firefighter/incidents/forms/update_status.py +87 -1
  8. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  9. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  10. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  11. firefighter/incidents/models/incident.py +32 -5
  12. firefighter/incidents/static/css/main.min.css +1 -1
  13. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  14. firefighter/incidents/views/reports.py +3 -3
  15. firefighter/raid/apps.py +9 -26
  16. firefighter/raid/client.py +2 -2
  17. firefighter/raid/forms.py +75 -238
  18. firefighter/raid/signals/incident_created.py +38 -13
  19. firefighter/raid/signals/incident_updated.py +3 -2
  20. firefighter/slack/messages/slack_messages.py +19 -4
  21. firefighter/slack/rules.py +1 -1
  22. firefighter/slack/signals/create_incident_conversation.py +6 -0
  23. firefighter/slack/signals/incident_updated.py +7 -1
  24. firefighter/slack/views/modals/__init__.py +4 -0
  25. firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
  26. firefighter/slack/views/modals/close.py +15 -2
  27. firefighter/slack/views/modals/closure_reason.py +193 -0
  28. firefighter/slack/views/modals/open.py +60 -13
  29. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  30. firefighter/slack/views/modals/opening/select_impact.py +1 -1
  31. firefighter/slack/views/modals/opening/set_details.py +3 -2
  32. firefighter/slack/views/modals/postmortem.py +10 -2
  33. firefighter/slack/views/modals/update_status.py +28 -2
  34. firefighter/slack/views/modals/utils.py +51 -0
  35. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/METADATA +1 -1
  36. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/RECORD +62 -38
  37. firefighter_tests/test_incidents/test_enums.py +100 -0
  38. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  39. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  40. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  41. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  42. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  43. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  44. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  45. firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
  46. firefighter_tests/test_raid/conftest.py +154 -0
  47. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  48. firefighter_tests/test_raid/test_raid_forms.py +10 -253
  49. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  50. firefighter_tests/test_slack/messages/__init__.py +0 -0
  51. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  52. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  53. firefighter_tests/test_slack/views/modals/test_close.py +65 -3
  54. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  55. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  56. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  57. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  58. firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
  59. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  60. firefighter/raid/views/open_normal.py +0 -139
  61. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  62. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/WHEEL +0 -0
  63. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/entry_points.txt +0 -0
  64. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.16.dist-info}/licenses/LICENSE +0 -0
firefighter/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.14'
32
- __version_tuple__ = version_tuple = (0, 0, 14)
31
+ __version__ = version = '0.0.16'
32
+ __version_tuple__ = version_tuple = (0, 0, 16)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -218,6 +218,7 @@ class IncidentSerializer(TaggitSerializer, serializers.ModelSerializer[Incident]
218
218
  status = serializers.SerializerMethodField()
219
219
  created_by = UserSerializer(read_only=True)
220
220
  slack_channel_name = serializers.SerializerMethodField()
221
+ postmortem_url = serializers.SerializerMethodField()
221
222
 
222
223
  created_by_email = CreatableSlugRelatedField[User](
223
224
  source="created_by",
@@ -252,6 +253,13 @@ class IncidentSerializer(TaggitSerializer, serializers.ModelSerializer[Incident]
252
253
  def get_slack_channel_name(obj: Incident) -> str:
253
254
  return f"#{obj.slack_channel_name}" if obj.slack_channel_name else ""
254
255
 
256
+ @staticmethod
257
+ def get_postmortem_url(obj: Incident) -> str | None:
258
+ """Return the Confluence post-mortem page URL if it exists."""
259
+ if hasattr(obj, "postmortem_for"):
260
+ return obj.postmortem_for.page_url
261
+ return None
262
+
255
263
  def create(self, validated_data: dict[str, Any]) -> Incident:
256
264
  return Incident.objects.declare(**validated_data)
257
265
 
@@ -279,6 +287,7 @@ class IncidentSerializer(TaggitSerializer, serializers.ModelSerializer[Incident]
279
287
  "status",
280
288
  "slack_channel_name",
281
289
  "status_page_url",
290
+ "postmortem_url",
282
291
  "status",
283
292
  "environment_id",
284
293
  "incident_category_id",
@@ -39,7 +39,7 @@ def incident_updated_handler(
39
39
  if (
40
40
  "_status" in updated_fields
41
41
  and incident_update.status
42
- in {IncidentStatus.FIXED, IncidentStatus.POST_MORTEM}
42
+ in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
43
43
  and incident.needs_postmortem
44
44
  ):
45
45
  if not hasattr(incident, "postmortem_for"):
@@ -48,7 +48,7 @@ def incident_updated_handler(
48
48
  elif (
49
49
  "_status" in updated_fields
50
50
  and incident_update.status
51
- in {IncidentStatus.FIXED, IncidentStatus.POST_MORTEM}
51
+ in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
52
52
  and not incident.needs_postmortem
53
53
  ):
54
54
  publish_fixed_next_actions(incident)
@@ -6,8 +6,8 @@ from django.db import models
6
6
  class IncidentStatus(models.IntegerChoices):
7
7
  OPEN = 10, "Open"
8
8
  INVESTIGATING = 20, "Investigating"
9
- FIXING = 30, "Mitigating"
10
- FIXED = 40, "Mitigated"
9
+ MITIGATING = 30, "Mitigating"
10
+ MITIGATED = 40, "Mitigated"
11
11
  POST_MORTEM = 50, "Post-mortem"
12
12
  CLOSED = 60, "Closed"
13
13
 
@@ -30,3 +30,23 @@ class IncidentStatus(models.IntegerChoices):
30
30
  @staticmethod
31
31
  def choices_lt(val: int) -> list[tuple[int, str]]:
32
32
  return [i for i in IncidentStatus.choices if i[0] < val]
33
+
34
+ @staticmethod
35
+ def choices_lte(val: int) -> list[tuple[int, str]]:
36
+ return [i for i in IncidentStatus.choices if i[0] <= val]
37
+
38
+ @staticmethod
39
+ def choices_lte_skip_postmortem(val: int) -> list[tuple[int, str]]:
40
+ """Return choices up to val but excluding POST_MORTEM (for P3+ incidents)."""
41
+ return [i for i in IncidentStatus.choices if i[0] <= val and i[0] != IncidentStatus.POST_MORTEM]
42
+
43
+
44
+ class ClosureReason(models.TextChoices):
45
+ """Reasons for direct incident closure bypassing normal workflow."""
46
+
47
+ RESOLVED = "resolved", "Resolved normally"
48
+ DUPLICATE = "duplicate", "Duplicate incident"
49
+ FALSE_POSITIVE = "false_positive", "False alarm - no actual issue"
50
+ SUPERSEDED = "superseded", "Superseded by another incident"
51
+ EXTERNAL = "external", "External dependency/known issue"
52
+ CANCELLED = "cancelled", "Cancelled - no longer relevant"
@@ -0,0 +1,45 @@
1
+ """Form for incident closure with reason when closing from early statuses."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from django import forms
7
+
8
+ from firefighter.incidents.enums import ClosureReason
9
+
10
+ if TYPE_CHECKING:
11
+ from firefighter.incidents.models import Incident
12
+
13
+
14
+ class IncidentClosureReasonForm(forms.Form):
15
+ """Form for closing an incident with a mandatory reason from early statuses."""
16
+
17
+ closure_reason = forms.ChoiceField(
18
+ label="Closure Reason",
19
+ choices=ClosureReason.choices,
20
+ required=True,
21
+ help_text="Select the reason for closing this incident",
22
+ )
23
+ closure_reference = forms.CharField(
24
+ label="Reference (optional)",
25
+ max_length=100,
26
+ required=False,
27
+ help_text="Reference incident ID or external link for context (e.g., #1234 or URL)",
28
+ )
29
+ message = forms.CharField(
30
+ label="Closure Message",
31
+ widget=forms.Textarea,
32
+ required=True,
33
+ help_text="Brief explanation of why this incident is being closed",
34
+ )
35
+
36
+ def __init__(self, *args: Any, incident: Incident | None = None, **kwargs: Any) -> None: # noqa: ARG002
37
+ super().__init__(*args, **kwargs)
38
+
39
+ # Exclude RESOLVED from choices as it's for normal workflow closure
40
+ closure_field = self.fields["closure_reason"]
41
+ if hasattr(closure_field, "choices"):
42
+ closure_field.choices = [
43
+ choice for choice in ClosureReason.choices
44
+ if choice[0] != ClosureReason.RESOLVED
45
+ ]
@@ -0,0 +1,406 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+ from typing import cast as typing_cast
6
+
7
+ from django import forms
8
+ from django.db import models
9
+
10
+ from firefighter.incidents.forms.create_incident import CreateIncidentFormBase
11
+ from firefighter.incidents.forms.select_impact import SelectImpactForm
12
+ from firefighter.incidents.forms.utils import GroupedModelChoiceField
13
+ from firefighter.incidents.models import Environment, IncidentCategory, Priority
14
+ from firefighter.incidents.models.impact import LevelChoices
15
+ from firefighter.incidents.models.incident import Incident
16
+ from firefighter.incidents.signals import create_incident_conversation
17
+
18
+ if TYPE_CHECKING:
19
+ from firefighter.incidents.models.impact import ImpactLevel
20
+ from firefighter.incidents.models.user import User
21
+ from firefighter.jira_app.models import JiraUser
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class PlatformChoices(models.TextChoices):
27
+ """Platform choices for incidents."""
28
+
29
+ FR = "platform-FR", ":fr: FR"
30
+ DE = "platform-DE", ":de: DE"
31
+ IT = "platform-IT", ":it: IT"
32
+ ES = "platform-ES", ":es: ES"
33
+ UK = "platform-UK", ":uk: UK"
34
+ ALL = "platform-All", ":earth_africa: ALL"
35
+ INTERNAL = "platform-Internal", ":logo-manomano: Internal"
36
+
37
+
38
+ def initial_environments() -> list[Environment]:
39
+ """Get default environments."""
40
+ return list(Environment.objects.filter(default=True))
41
+
42
+
43
+ def initial_priority() -> Priority:
44
+ """Get default priority."""
45
+ return Priority.objects.get(default=True)
46
+
47
+
48
+ def initial_platform() -> str:
49
+ """Get default platform."""
50
+ return PlatformChoices.ALL.value
51
+
52
+
53
+ class UnifiedIncidentForm(CreateIncidentFormBase):
54
+ """Unified form for all incident types and priorities (P1-P5).
55
+
56
+ This form dynamically shows/hides fields based on:
57
+ - Priority/response_type (critical vs normal)
58
+ - Selected impacts (customer, seller, employee)
59
+
60
+ Common fields (always shown):
61
+ - title, description, incident_category
62
+ - environment (multiple choice)
63
+ - platform (multiple choice, default ALL)
64
+ - priority (hidden)
65
+
66
+ Conditional fields:
67
+ - suggested_team_routing (P4-P5 only)
68
+ - zendesk_ticket_id (if customer impact selected)
69
+ - seller_contract_id, is_key_account, etc. (if seller impact selected)
70
+ """
71
+
72
+ # === Common fields (ALL priorities) ===
73
+ title = forms.CharField(
74
+ label="Title",
75
+ max_length=128,
76
+ min_length=10,
77
+ widget=forms.TextInput(attrs={"placeholder": "What's going on?"}),
78
+ )
79
+
80
+ description = forms.CharField(
81
+ label="Summary",
82
+ widget=forms.Textarea(
83
+ attrs={
84
+ "placeholder": "Help people responding to the incident. This will be posted to #tech-incidents and on our internal status page.\nThis description can be edited later."
85
+ }
86
+ ),
87
+ min_length=10,
88
+ max_length=1200,
89
+ )
90
+
91
+ incident_category = GroupedModelChoiceField(
92
+ choices_groupby="group",
93
+ label="Incident category",
94
+ queryset=(
95
+ IncidentCategory.objects.all()
96
+ .select_related("group")
97
+ .order_by(
98
+ "group__order",
99
+ "name",
100
+ )
101
+ ),
102
+ )
103
+
104
+ environment = forms.ModelMultipleChoiceField(
105
+ label="Environment",
106
+ queryset=Environment.objects.all(),
107
+ initial=initial_environments,
108
+ required=True,
109
+ )
110
+
111
+ platform = forms.MultipleChoiceField(
112
+ label="Platform",
113
+ choices=PlatformChoices.choices,
114
+ initial=[PlatformChoices.ALL.value],
115
+ required=True,
116
+ )
117
+
118
+ priority = forms.ModelChoiceField(
119
+ label="Priority",
120
+ queryset=Priority.objects.filter(enabled_create=True),
121
+ initial=initial_priority,
122
+ widget=forms.HiddenInput(),
123
+ )
124
+
125
+ # === Conditional: Normal incidents only (P4-P5) ===
126
+ suggested_team_routing: forms.ModelChoiceField[Any] = forms.ModelChoiceField(
127
+ queryset=None, # Will be set in __init__
128
+ label="Feature Team or Train",
129
+ required=False, # Conditionally required based on response_type
130
+ )
131
+
132
+ # === Conditional: Customer impact ===
133
+ zendesk_ticket_id = forms.CharField(
134
+ label="Zendesk Ticket ID",
135
+ max_length=128,
136
+ min_length=2,
137
+ required=False,
138
+ )
139
+
140
+ # === Conditional: Seller impact ===
141
+ seller_contract_id = forms.CharField(
142
+ label="Seller Contract ID",
143
+ max_length=128,
144
+ min_length=0,
145
+ required=False, # Conditionally required in clean()
146
+ )
147
+
148
+ is_key_account = forms.BooleanField(
149
+ label="Is it a Key Account?",
150
+ required=False,
151
+ )
152
+
153
+ is_seller_in_golden_list = forms.BooleanField(
154
+ label="Is the seller in the Golden List?",
155
+ required=False,
156
+ )
157
+
158
+ zoho_desk_ticket_id = forms.CharField(
159
+ label="Zoho Desk Ticket ID",
160
+ max_length=128,
161
+ min_length=1,
162
+ required=False,
163
+ )
164
+
165
+ field_order = [
166
+ "incident_category",
167
+ "environment",
168
+ "platform",
169
+ "title",
170
+ "description",
171
+ "suggested_team_routing",
172
+ "zendesk_ticket_id",
173
+ "seller_contract_id",
174
+ "is_key_account",
175
+ "is_seller_in_golden_list",
176
+ "zoho_desk_ticket_id",
177
+ ]
178
+
179
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
180
+ """Initialize form with dynamic queryset for suggested_team_routing."""
181
+ super().__init__(*args, **kwargs)
182
+
183
+ # Set queryset for suggested_team_routing
184
+ try:
185
+ from firefighter.raid.models import FeatureTeam # noqa: PLC0415
186
+
187
+ field = typing_cast("forms.ModelChoiceField[Any]", self.fields["suggested_team_routing"])
188
+ field.queryset = FeatureTeam.objects.only("name").order_by("name")
189
+ except ImportError:
190
+ # RAID module not available
191
+ logger.warning("RAID module not available, suggested_team_routing will not work")
192
+
193
+ def get_visible_fields_for_impacts(
194
+ self, impacts_data: dict[str, ImpactLevel | str], response_type: str
195
+ ) -> list[str]:
196
+ """Determine which fields should be visible based on impacts and response type.
197
+
198
+ Args:
199
+ impacts_data: Dictionary of impact type → impact level (ImpactLevel object or UUID string)
200
+ response_type: "critical" or "normal"
201
+
202
+ Returns:
203
+ List of field names that should be visible
204
+ """
205
+ visible_fields = [
206
+ "title",
207
+ "description",
208
+ "incident_category",
209
+ "environment",
210
+ "platform",
211
+ "priority",
212
+ ]
213
+
214
+ # Add suggested_team_routing for normal incidents (P4-P5)
215
+ if response_type == "normal":
216
+ visible_fields.append("suggested_team_routing")
217
+
218
+ # Check impact selections
219
+ customer_impact = None
220
+ seller_impact = None
221
+
222
+ for field_name, impact_level in impacts_data.items():
223
+ if "customers_impact" in field_name:
224
+ customer_impact = impact_level
225
+ elif "sellers_impact" in field_name:
226
+ seller_impact = impact_level
227
+
228
+ # Helper to check if impact is not NONE
229
+ def has_impact(impact: ImpactLevel | str | None) -> bool:
230
+ if impact is None:
231
+ return False
232
+
233
+ # If it's a UUID string, fetch the ImpactLevel from database
234
+ if isinstance(impact, str):
235
+ from firefighter.incidents.models.impact import ( # noqa: PLC0415
236
+ ImpactLevel as ImpactLevelModel,
237
+ )
238
+
239
+ try:
240
+ impact_obj = ImpactLevelModel.objects.get(id=impact)
241
+ except ImpactLevelModel.DoesNotExist:
242
+ return False
243
+ else:
244
+ return impact_obj.value != LevelChoices.NONE.value
245
+
246
+ # Otherwise it's an ImpactLevel object
247
+ return impact.value != LevelChoices.NONE.value
248
+
249
+ # Add customer-specific fields
250
+ if has_impact(customer_impact):
251
+ visible_fields.append("zendesk_ticket_id")
252
+
253
+ # Add seller-specific fields
254
+ if has_impact(seller_impact):
255
+ visible_fields.extend([
256
+ "seller_contract_id",
257
+ "is_key_account",
258
+ "is_seller_in_golden_list",
259
+ "zoho_desk_ticket_id",
260
+ ])
261
+
262
+ return visible_fields
263
+
264
+ def clean(self) -> dict[str, Any]:
265
+ """Custom validation based on response type and impacts."""
266
+ cleaned_data = super().clean()
267
+ if cleaned_data is None:
268
+ cleaned_data = {}
269
+
270
+ # Get response_type from initial data if available
271
+ initial = self.initial or {}
272
+ response_type = initial.get("response_type", "critical")
273
+
274
+ # Validate suggested_team_routing is required for normal incidents
275
+ if response_type == "normal" and not cleaned_data.get("suggested_team_routing"):
276
+ self.add_error(
277
+ "suggested_team_routing",
278
+ "Feature Team is required for P4/P5 incidents",
279
+ )
280
+
281
+ return cleaned_data
282
+
283
+ def trigger_incident_workflow(
284
+ self,
285
+ creator: User,
286
+ impacts_data: dict[str, ImpactLevel],
287
+ response_type: str = "critical",
288
+ *args: Any,
289
+ **kwargs: Any,
290
+ ) -> None:
291
+ """Trigger the appropriate incident workflow based on response type.
292
+
293
+ Args:
294
+ creator: User creating the incident
295
+ impacts_data: Dictionary of impact data
296
+ response_type: "critical" or "normal"
297
+ *args: Additional positional arguments (unused)
298
+ **kwargs: Additional keyword arguments (unused)
299
+ """
300
+ if response_type == "critical":
301
+ self._trigger_critical_incident_workflow(creator, impacts_data)
302
+ else:
303
+ self._trigger_normal_incident_workflow(creator, impacts_data)
304
+
305
+ def _trigger_critical_incident_workflow(
306
+ self,
307
+ creator: User,
308
+ impacts_data: dict[str, ImpactLevel],
309
+ ) -> None:
310
+ """Create a critical incident (P1-P3) with Slack channel."""
311
+ # Create incident with first environment only (critical incidents use single env)
312
+ cleaned_data_copy = self.cleaned_data.copy()
313
+ logger.info(f"UNIFIED FORM - cleaned_data keys: {list(self.cleaned_data.keys())}")
314
+ logger.info(f"UNIFIED FORM - cleaned_data values: {self.cleaned_data}")
315
+
316
+ environments = cleaned_data_copy.pop("environment", [])
317
+ platforms = cleaned_data_copy.pop("platform", [])
318
+
319
+ # Extract customer/seller fields for Jira ticket (not stored in Incident model)
320
+ jira_extra_fields = {
321
+ "suggested_team_routing": cleaned_data_copy.pop("suggested_team_routing", None),
322
+ "zendesk_ticket_id": cleaned_data_copy.pop("zendesk_ticket_id", None),
323
+ "seller_contract_id": cleaned_data_copy.pop("seller_contract_id", None),
324
+ "is_key_account": cleaned_data_copy.pop("is_key_account", None),
325
+ "is_seller_in_golden_list": cleaned_data_copy.pop("is_seller_in_golden_list", None),
326
+ "zoho_desk_ticket_id": cleaned_data_copy.pop("zoho_desk_ticket_id", None),
327
+ # Pass full lists for Jira ticket creation
328
+ "environments": [env.value for env in environments], # Convert QuerySet to list of values
329
+ "platforms": platforms, # Already a list of strings
330
+ }
331
+ logger.info(f"UNIFIED FORM - jira_extra_fields extracted: {jira_extra_fields}")
332
+
333
+ # Use first environment
334
+ if environments:
335
+ cleaned_data_copy["environment"] = environments[0]
336
+
337
+ # Store custom fields in the incident
338
+ cleaned_data_copy["custom_fields"] = {
339
+ k: v for k, v in jira_extra_fields.items() if v is not None
340
+ }
341
+
342
+ incident = Incident.objects.declare(created_by=creator, **cleaned_data_copy)
343
+ impacts_form = SelectImpactForm(impacts_data)
344
+ impacts_form.save(incident=incident)
345
+
346
+ create_incident_conversation.send(
347
+ "unified_incident_form",
348
+ incident=incident,
349
+ jira_extra_fields=jira_extra_fields,
350
+ impacts_data=impacts_data, # Pass impacts_data for business_impact computation
351
+ )
352
+
353
+ def _trigger_normal_incident_workflow(
354
+ self,
355
+ creator: User,
356
+ impacts_data: dict[str, ImpactLevel],
357
+ ) -> None:
358
+ """Create a normal incident (P4-P5) with Jira ticket only."""
359
+ from firefighter.raid.client import client as jira_client # noqa: PLC0415
360
+ from firefighter.raid.forms import ( # noqa: PLC0415
361
+ prepare_jira_fields,
362
+ process_jira_issue,
363
+ )
364
+ from firefighter.raid.service import ( # noqa: PLC0415
365
+ get_jira_user_from_user,
366
+ )
367
+
368
+ jira_user: JiraUser = get_jira_user_from_user(creator)
369
+
370
+ # Extract environments and platforms
371
+ environments_qs = self.cleaned_data.get("environment", [])
372
+ environments = [env.value for env in environments_qs] # Convert QuerySet to list of values
373
+ platforms = self.cleaned_data.get("platform", [])
374
+
375
+ # Extract suggested team routing (convert FeatureTeam instance to string)
376
+ team_routing = self.cleaned_data.get("suggested_team_routing")
377
+ team_routing_name = team_routing.name if team_routing else None
378
+
379
+ # Prepare all Jira fields using the common function
380
+ # P4-P5 pass all environments (unlike P1-P3 which pass first only)
381
+ jira_fields = prepare_jira_fields(
382
+ title=self.cleaned_data["title"],
383
+ description=self.cleaned_data["description"],
384
+ priority=self.cleaned_data["priority"].value,
385
+ reporter=jira_user.id,
386
+ incident_category=self.cleaned_data["incident_category"].name,
387
+ environments=environments, # ✅ P4-P5: pass ALL environments
388
+ platforms=platforms,
389
+ impacts_data=impacts_data,
390
+ optional_fields={
391
+ "zendesk_ticket_id": self.cleaned_data.get("zendesk_ticket_id", ""),
392
+ "seller_contract_id": self.cleaned_data.get("seller_contract_id", ""),
393
+ "zoho_desk_ticket_id": self.cleaned_data.get("zoho_desk_ticket_id", ""),
394
+ "is_key_account": self.cleaned_data.get("is_key_account"),
395
+ "is_seller_in_golden_list": self.cleaned_data.get("is_seller_in_golden_list"),
396
+ "suggested_team_routing": team_routing_name,
397
+ },
398
+ )
399
+
400
+ # Create Jira issue with all prepared fields
401
+ issue_data = jira_client.create_issue(**jira_fields)
402
+
403
+ # Process the created Jira ticket (create JiraTicket in DB, save impacts, alert Slack)
404
+ process_jira_issue(
405
+ issue_data, creator, jira_user=jira_user, impacts_data=impacts_data
406
+ )
@@ -1,11 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING, Any
4
+
3
5
  from django import forms
4
6
 
5
7
  from firefighter.incidents.enums import IncidentStatus
6
8
  from firefighter.incidents.forms.utils import EnumChoiceField, GroupedModelChoiceField
7
9
  from firefighter.incidents.models import IncidentCategory, Priority
8
10
 
11
+ if TYPE_CHECKING:
12
+ from firefighter.incidents.models import Incident
13
+
9
14
 
10
15
  class UpdateStatusForm(forms.Form):
11
16
  message = forms.CharField(
@@ -18,7 +23,7 @@ class UpdateStatusForm(forms.Form):
18
23
  status = EnumChoiceField(
19
24
  enum_class=IncidentStatus,
20
25
  label="Status",
21
- choices=IncidentStatus.choices_lt(IncidentStatus.CLOSED),
26
+ choices=IncidentStatus.choices_lte(IncidentStatus.CLOSED),
22
27
  )
23
28
  priority = forms.ModelChoiceField(
24
29
  label="Priority",
@@ -34,3 +39,84 @@ class UpdateStatusForm(forms.Form):
34
39
  "name",
35
40
  ),
36
41
  )
42
+
43
+ def __init__(self, *args: Any, incident: Incident | None = None, **kwargs: Any) -> None:
44
+ super().__init__(*args, **kwargs)
45
+
46
+ # Dynamically adjust status choices based on incident requirements
47
+ if incident:
48
+ current_status = incident.status
49
+
50
+ # Check if incident requires post-mortem (P1/P2 in PRD)
51
+ # We check the conditions directly rather than using incident.needs_postmortem
52
+ # because that property also checks if confluence is installed
53
+ requires_postmortem = (
54
+ incident.priority
55
+ and incident.environment
56
+ and incident.priority.needs_postmortem
57
+ and incident.environment.value == "PRD"
58
+ )
59
+
60
+ # Get the status field (we know it's an EnumChoiceField)
61
+ status_field = self.fields["status"]
62
+
63
+ # For incidents requiring post-mortem (P1/P2 in PRD)
64
+ if requires_postmortem:
65
+ if current_status == IncidentStatus.OPEN:
66
+ # From Opened: can go to INVESTIGATING or CLOSED (with reason)
67
+ allowed_statuses = [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]
68
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
69
+ elif current_status == IncidentStatus.INVESTIGATING:
70
+ # From Investigating: can go to MITIGATING or CLOSED (with reason)
71
+ allowed_statuses = [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]
72
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
73
+ elif current_status == IncidentStatus.MITIGATING:
74
+ # From Mitigating: can only go to MITIGATED
75
+ allowed_statuses = [IncidentStatus.MITIGATED]
76
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
77
+ elif current_status == IncidentStatus.MITIGATED:
78
+ # From Mitigated: can only go to POST_MORTEM
79
+ allowed_statuses = [IncidentStatus.POST_MORTEM]
80
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
81
+ elif current_status == IncidentStatus.POST_MORTEM:
82
+ # From Post-mortem: can only go to CLOSED
83
+ allowed_statuses = [IncidentStatus.CLOSED]
84
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
85
+ else:
86
+ # Default: all statuses up to closed
87
+ status_field.choices = IncidentStatus.choices_lte(IncidentStatus.CLOSED) # type: ignore[attr-defined]
88
+ # For P3+ incidents (no post-mortem needed)
89
+ elif current_status == IncidentStatus.OPEN:
90
+ # From Opened: can go to INVESTIGATING or CLOSED (with reason)
91
+ allowed_statuses = [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]
92
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
93
+ elif current_status == IncidentStatus.INVESTIGATING:
94
+ # From Investigating: can go to MITIGATING or CLOSED (with reason)
95
+ allowed_statuses = [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]
96
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
97
+ elif current_status == IncidentStatus.MITIGATING:
98
+ # From Mitigating: can only go to MITIGATED
99
+ allowed_statuses = [IncidentStatus.MITIGATED]
100
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
101
+ elif current_status == IncidentStatus.MITIGATED:
102
+ # From Mitigated: can go to CLOSED (P3+ doesn't need post-mortem)
103
+ allowed_statuses = [IncidentStatus.CLOSED]
104
+ status_field.choices = [(s.value, s.label) for s in allowed_statuses] # type: ignore[attr-defined]
105
+ else:
106
+ # Default fallback
107
+ status_field.choices = IncidentStatus.choices_lte_skip_postmortem(IncidentStatus.CLOSED) # type: ignore[attr-defined]
108
+
109
+ @staticmethod
110
+ def requires_closure_reason(incident: Incident, target_status: IncidentStatus) -> bool:
111
+ """Check if closing this incident to the target status requires a closure reason.
112
+
113
+ Based on the workflow diagram:
114
+ - P1/P2 and P3/P4/P5: require reason when closing from Opened or Investigating
115
+ """
116
+ if target_status != IncidentStatus.CLOSED:
117
+ return False
118
+
119
+ current_status = incident.status
120
+
121
+ # Require reason if closing from Opened or Investigating (for any priority)
122
+ return current_status.value in {IncidentStatus.OPEN, IncidentStatus.INVESTIGATING}