firefighter-incident 0.0.13__py3-none-any.whl → 0.0.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. firefighter/_version.py +16 -3
  2. firefighter/api/serializers.py +17 -8
  3. firefighter/api/urls.py +8 -1
  4. firefighter/api/views/_base.py +1 -1
  5. firefighter/api/views/components.py +5 -5
  6. firefighter/api/views/incidents.py +9 -9
  7. firefighter/confluence/signals/incident_updated.py +2 -2
  8. firefighter/firefighter/settings/components/raid.py +3 -0
  9. firefighter/incidents/admin.py +24 -24
  10. firefighter/incidents/enums.py +22 -2
  11. firefighter/incidents/factories.py +14 -5
  12. firefighter/incidents/forms/close_incident.py +4 -4
  13. firefighter/incidents/forms/closure_reason.py +45 -0
  14. firefighter/incidents/forms/create_incident.py +4 -4
  15. firefighter/incidents/forms/unified_incident.py +406 -0
  16. firefighter/incidents/forms/update_status.py +91 -5
  17. firefighter/incidents/menus.py +2 -2
  18. firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
  19. firefighter/incidents/migrations/0009_update_sla.py +7 -5
  20. firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
  21. firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
  22. firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
  23. firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
  24. firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
  25. firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
  26. firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
  27. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  28. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  29. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  30. firefighter/incidents/models/__init__.py +1 -1
  31. firefighter/incidents/models/group.py +1 -1
  32. firefighter/incidents/models/incident.py +47 -20
  33. firefighter/incidents/models/{component.py → incident_category.py} +30 -29
  34. firefighter/incidents/models/incident_update.py +3 -3
  35. firefighter/incidents/static/css/main.min.css +1 -1
  36. firefighter/incidents/tables.py +9 -9
  37. firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
  38. firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
  39. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  40. firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
  41. firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
  42. firefighter/incidents/templates/pages/incident_detail.html +3 -3
  43. firefighter/incidents/urls.py +6 -6
  44. firefighter/incidents/views/components/details.py +9 -9
  45. firefighter/incidents/views/components/list.py +9 -9
  46. firefighter/incidents/views/reports.py +5 -5
  47. firefighter/incidents/views/users/details.py +2 -2
  48. firefighter/incidents/views/views.py +7 -7
  49. firefighter/jira_app/client.py +1 -1
  50. firefighter/logging/custom_json_formatter.py +2 -1
  51. firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
  52. firefighter/raid/admin.py +0 -11
  53. firefighter/raid/apps.py +9 -26
  54. firefighter/raid/client.py +5 -5
  55. firefighter/raid/forms.py +84 -213
  56. firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
  57. firefighter/raid/models.py +2 -21
  58. firefighter/raid/serializers.py +5 -4
  59. firefighter/raid/service.py +29 -27
  60. firefighter/raid/signals/incident_created.py +42 -15
  61. firefighter/raid/signals/incident_updated.py +3 -2
  62. firefighter/raid/utils.py +1 -1
  63. firefighter/raid/views/__init__.py +1 -1
  64. firefighter/slack/admin.py +8 -8
  65. firefighter/slack/management/commands/switch_test_users.py +272 -0
  66. firefighter/slack/messages/slack_messages.py +24 -9
  67. firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
  68. firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
  69. firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
  70. firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
  71. firefighter/slack/models/conversation.py +3 -3
  72. firefighter/slack/models/incident_channel.py +1 -1
  73. firefighter/slack/models/user.py +1 -1
  74. firefighter/slack/models/user_group.py +3 -3
  75. firefighter/slack/rules.py +2 -2
  76. firefighter/slack/signals/create_incident_conversation.py +6 -0
  77. firefighter/slack/signals/get_users.py +2 -2
  78. firefighter/slack/signals/incident_updated.py +8 -2
  79. firefighter/slack/utils.py +2 -2
  80. firefighter/slack/views/events/home.py +2 -2
  81. firefighter/slack/views/modals/__init__.py +4 -0
  82. firefighter/slack/views/modals/base_modal/form_utils.py +78 -0
  83. firefighter/slack/views/modals/close.py +18 -5
  84. firefighter/slack/views/modals/closure_reason.py +193 -0
  85. firefighter/slack/views/modals/open.py +83 -12
  86. firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
  87. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  88. firefighter/slack/views/modals/opening/select_impact.py +5 -2
  89. firefighter/slack/views/modals/opening/set_details.py +3 -2
  90. firefighter/slack/views/modals/postmortem.py +10 -2
  91. firefighter/slack/views/modals/update_status.py +32 -6
  92. firefighter/slack/views/modals/utils.py +51 -0
  93. firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
  94. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +2 -2
  95. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +133 -88
  96. firefighter_tests/conftest.py +4 -5
  97. firefighter_tests/test_api/test_api_landbot.py +1 -1
  98. firefighter_tests/test_firefighter/test_sso.py +146 -0
  99. firefighter_tests/test_incidents/test_enums.py +100 -0
  100. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  101. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  102. firefighter_tests/test_incidents/test_forms/test_form_utils.py +15 -15
  103. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  104. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  105. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  106. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  107. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  108. firefighter_tests/test_incidents/test_incident_urls.py +3 -3
  109. firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
  110. firefighter_tests/test_incidents/test_models/test_incident_model.py +70 -2
  111. firefighter_tests/test_raid/conftest.py +154 -0
  112. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  113. firefighter_tests/test_raid/test_priority_mapping.py +267 -0
  114. firefighter_tests/test_raid/test_raid_client.py +580 -0
  115. firefighter_tests/test_raid/test_raid_forms.py +552 -0
  116. firefighter_tests/test_raid/test_raid_models.py +185 -0
  117. firefighter_tests/test_raid/test_raid_serializers.py +507 -0
  118. firefighter_tests/test_raid/test_raid_service.py +442 -0
  119. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  120. firefighter_tests/test_raid/test_raid_views.py +196 -0
  121. firefighter_tests/test_slack/messages/__init__.py +0 -0
  122. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  123. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  124. firefighter_tests/test_slack/views/modals/test_close.py +71 -9
  125. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  126. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  127. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  128. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  129. firefighter_tests/test_slack/views/modals/test_update_status.py +331 -7
  130. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  131. firefighter/raid/views/open_normal.py +0 -139
  132. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  133. firefighter_fixtures/raid/area.json +0 -1
  134. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
  135. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
  136. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -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,10 +1,15 @@
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
- from firefighter.incidents.models import Component, Priority
9
+ from firefighter.incidents.models import IncidentCategory, Priority
10
+
11
+ if TYPE_CHECKING:
12
+ from firefighter.incidents.models import Incident
8
13
 
9
14
 
10
15
  class UpdateStatusForm(forms.Form):
@@ -18,19 +23,100 @@ 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",
25
30
  queryset=Priority.objects.filter(enabled_update=True),
26
31
  )
27
- component = GroupedModelChoiceField(
32
+ incident_category = GroupedModelChoiceField(
28
33
  choices_groupby="group",
29
- label="Issue category",
30
- queryset=Component.objects.all()
34
+ label="Incident category",
35
+ queryset=IncidentCategory.objects.all()
31
36
  .select_related("group")
32
37
  .order_by(
33
38
  "group__order",
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}
@@ -50,8 +50,8 @@ def setup_navbar_menu() -> None:
50
50
  reverse("incidents:incident-statistics"),
51
51
  ),
52
52
  MenuItem(
53
- "Issue categories",
54
- reverse("incidents:component-list"),
53
+ "Incident categories",
54
+ reverse("incidents:incident-category-list"),
55
55
  ),
56
56
  ]
57
57
  Menu.add_item(
@@ -1,14 +1,16 @@
1
+ from datetime import timedelta
2
+
1
3
  from django.db import migrations
2
4
 
3
5
 
4
6
  def update_priority_settings(apps, schema_editor):
5
7
  Priority = apps.get_model("incidents", "Priority")
6
8
  priorities_to_update = {
7
- "P1": ("01:00:00", True, True),
8
- "P2": ("04:00:00", True, True),
9
- "P3": ("1 day, 00:00:00", True, True),
10
- "P4": ("5 days, 00:00:00", True, True),
11
- "P5": ("10 days, 00:00:00", True, True),
9
+ "P1": (timedelta(hours=1), True, True),
10
+ "P2": (timedelta(hours=4), True, True),
11
+ "P3": (timedelta(days=1), True, True),
12
+ "P4": (timedelta(days=5), True, True),
13
+ "P5": (timedelta(days=10), True, True),
12
14
  }
13
15
 
14
16
  for name, (sla, enabled_create, enabled_update) in priorities_to_update.items():
@@ -1,14 +1,16 @@
1
+ from datetime import timedelta
2
+
1
3
  from django.db import migrations
2
4
 
3
5
 
4
6
  def update_priority_settings(apps, schema_editor):
5
7
  Priority = apps.get_model("incidents", "Priority")
6
8
  priorities_to_update = {
7
- "P1": ("02:00:00", True, True),
8
- "P2": ("04:00:00", True, True),
9
- "P3": ("1 day, 00:00:00", True, True),
10
- "P4": ("5 days, 00:00:00", True, True),
11
- "P5": ("10 days, 00:00:00", True, True),
9
+ "P1": (timedelta(hours=2), True, True),
10
+ "P2": (timedelta(hours=4), True, True),
11
+ "P3": (timedelta(days=1), True, True),
12
+ "P4": (timedelta(days=5), True, True),
13
+ "P5": (timedelta(days=10), True, True),
12
14
  }
13
15
 
14
16
  for name, (sla, enabled_create, enabled_update) in priorities_to_update.items():
@@ -0,0 +1,64 @@
1
+ # Generated manually on 2025-08-19 - Step 1: Create IncidentCategory model
2
+
3
+ import uuid
4
+
5
+ from django.db import migrations, models
6
+
7
+
8
+ class Migration(migrations.Migration):
9
+
10
+ dependencies = [
11
+ ("incidents", "0019_set_security_components_private"),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.CreateModel(
16
+ name="IncidentCategory",
17
+ fields=[
18
+ (
19
+ "id",
20
+ models.UUIDField(
21
+ default=uuid.uuid4,
22
+ editable=False,
23
+ primary_key=True,
24
+ serialize=False,
25
+ ),
26
+ ),
27
+ ("name", models.CharField(max_length=128)),
28
+ ("description", models.TextField(blank=True)),
29
+ (
30
+ "order",
31
+ models.IntegerField(
32
+ default=0,
33
+ help_text="Order of the incident category in the list. Should be unique per `Group`.",
34
+ ),
35
+ ),
36
+ (
37
+ "private",
38
+ models.BooleanField(
39
+ default=False,
40
+ help_text="If true, incident created with this incident category won't be communicated, and conversations will be made private. This is useful for sensitive incident categories. In the future, private incidents may be visible only to its members.",
41
+ ),
42
+ ),
43
+ (
44
+ "deploy_warning",
45
+ models.BooleanField(
46
+ default=True,
47
+ help_text="If true, a warning will be sent when creating an incident of high severity with this incident category.",
48
+ ),
49
+ ),
50
+ ("created_at", models.DateTimeField(auto_now_add=True)),
51
+ ("updated_at", models.DateTimeField(auto_now=True)),
52
+ (
53
+ "group",
54
+ models.ForeignKey(
55
+ on_delete=models.PROTECT,
56
+ to="incidents.group"
57
+ ),
58
+ ),
59
+ ],
60
+ options={
61
+ "ordering": ["order"],
62
+ },
63
+ ),
64
+ ]