firefighter-incident 0.0.16__py3-none-any.whl → 0.0.17__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 (31) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/incidents/forms/edit.py +5 -3
  3. firefighter/incidents/forms/unified_incident.py +180 -56
  4. firefighter/incidents/forms/update_status.py +94 -58
  5. firefighter/incidents/forms/utils.py +14 -0
  6. firefighter/incidents/models/incident.py +3 -2
  7. firefighter/raid/apps.py +0 -1
  8. firefighter/slack/signals/__init__.py +16 -0
  9. firefighter/slack/signals/incident_updated.py +43 -1
  10. firefighter/slack/utils.py +43 -6
  11. firefighter/slack/views/modals/base_modal/form_utils.py +3 -1
  12. firefighter/slack/views/modals/downgrade_workflow.py +3 -1
  13. firefighter/slack/views/modals/edit.py +53 -7
  14. firefighter/slack/views/modals/opening/set_details.py +20 -0
  15. firefighter_fixtures/incidents/priorities.json +1 -1
  16. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/METADATA +1 -1
  17. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/RECORD +28 -29
  18. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +160 -23
  19. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +38 -60
  20. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +35 -20
  21. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +8 -6
  22. firefighter_tests/test_slack/test_signals_downgrade.py +147 -0
  23. firefighter_tests/test_slack/views/modals/test_edit.py +324 -0
  24. firefighter_tests/test_slack/views/modals/test_opening_unified.py +42 -0
  25. firefighter_tests/test_slack/views/modals/test_update_status.py +72 -2
  26. firefighter/raid/signals/incident_created.py +0 -129
  27. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +0 -372
  28. firefighter_tests/test_raid/test_priority_mapping.py +0 -267
  29. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/WHEEL +0 -0
  30. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/entry_points.txt +0 -0
  31. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.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.16'
32
- __version_tuple__ = version_tuple = (0, 0, 16)
31
+ __version__ = version = '0.0.17'
32
+ __version_tuple__ = version_tuple = (0, 0, 17)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -5,8 +5,9 @@ from django import forms
5
5
  from firefighter.incidents.models import Environment
6
6
 
7
7
 
8
- def initial_environments() -> Environment:
9
- return Environment.objects.get(default=True)
8
+ def initial_environments() -> list[Environment]:
9
+ """Get default environments for the form."""
10
+ return list(Environment.objects.filter(default=True))
10
11
 
11
12
 
12
13
  class EditMetaForm(forms.Form):
@@ -26,8 +27,9 @@ class EditMetaForm(forms.Form):
26
27
  min_length=10,
27
28
  max_length=1200,
28
29
  )
29
- environment = forms.ModelChoiceField(
30
+ environment = forms.ModelMultipleChoiceField(
30
31
  label="Environment",
31
32
  queryset=Environment.objects.all(),
32
33
  initial=initial_environments,
34
+ required=True,
33
35
  )
@@ -287,120 +287,244 @@ class UnifiedIncidentForm(CreateIncidentFormBase):
287
287
  response_type: str = "critical",
288
288
  *args: Any,
289
289
  **kwargs: Any,
290
- ) -> None:
291
- """Trigger the appropriate incident workflow based on response type.
290
+ ) -> Incident:
291
+ """Trigger unified incident workflow for all priorities.
292
+
293
+ This unified workflow:
294
+ 1. Always creates an Incident in the database (P1-P5)
295
+ 2. Conditionally creates a Slack channel (P1-P3 only)
296
+ 3. Always creates a Jira ticket linked to the Incident (P1-P5)
292
297
 
293
298
  Args:
294
299
  creator: User creating the incident
295
300
  impacts_data: Dictionary of impact data
296
- response_type: "critical" or "normal"
301
+ response_type: "critical" (P1-P3) or "normal" (P4-P5)
297
302
  *args: Additional positional arguments (unused)
298
303
  **kwargs: Additional keyword arguments (unused)
304
+
305
+ Returns:
306
+ The created Incident object
299
307
  """
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)
308
+ # Step 1: Always create Incident in database (ALL priorities)
309
+ incident = self._create_incident(creator)
304
310
 
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)
311
+ # Save impacts
312
+ impacts_form = SelectImpactForm(impacts_data)
313
+ impacts_form.save(incident=incident)
314
+
315
+ # Step 2: Conditionally create Slack channel (P1-P3 only)
316
+ if self._should_create_slack_channel(response_type):
317
+ self._create_slack_channel(incident, impacts_data)
318
+
319
+ # Step 3: Always create Jira ticket (ALL priorities) - UNIFIED
320
+ self._create_jira_ticket(incident, creator, impacts_data)
321
+
322
+ return incident
323
+
324
+ def _should_create_slack_channel(self, response_type: str) -> bool:
325
+ """Determine if incident needs Slack channel based on priority.
326
+
327
+ Args:
328
+ response_type: "critical" or "normal"
329
+
330
+ Returns:
331
+ True if Slack channel should be created (P1-P3), False otherwise (P4-P5)
332
+ """
333
+ return response_type == "critical"
334
+
335
+ def _create_incident(self, creator: User) -> Incident:
336
+ """Create Incident object in database for ALL priorities.
337
+
338
+ Args:
339
+ creator: User creating the incident
340
+
341
+ Returns:
342
+ Created Incident object
343
+ """
312
344
  cleaned_data_copy = self.cleaned_data.copy()
313
345
  logger.info(f"UNIFIED FORM - cleaned_data keys: {list(self.cleaned_data.keys())}")
314
346
  logger.info(f"UNIFIED FORM - cleaned_data values: {self.cleaned_data}")
315
347
 
348
+ # Extract environments and platforms (not stored directly in Incident model)
316
349
  environments = cleaned_data_copy.pop("environment", [])
317
350
  platforms = cleaned_data_copy.pop("platform", [])
318
351
 
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),
352
+ # Extract Jira-only fields (not stored in Incident model)
353
+ # Convert suggested_team_routing to JSON-serializable value (name string)
354
+ team_routing = cleaned_data_copy.pop("suggested_team_routing", None)
355
+ team_routing_name = team_routing.name if team_routing else None
356
+
357
+ self._jira_extra_fields = {
358
+ "suggested_team_routing": team_routing_name, # Store name string, not model instance
322
359
  "zendesk_ticket_id": cleaned_data_copy.pop("zendesk_ticket_id", None),
323
360
  "seller_contract_id": cleaned_data_copy.pop("seller_contract_id", None),
324
361
  "is_key_account": cleaned_data_copy.pop("is_key_account", None),
325
362
  "is_seller_in_golden_list": cleaned_data_copy.pop("is_seller_in_golden_list", None),
326
363
  "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
364
+ "environments": [env.value for env in environments],
365
+ "platforms": platforms,
330
366
  }
331
- logger.info(f"UNIFIED FORM - jira_extra_fields extracted: {jira_extra_fields}")
367
+ logger.info(f"UNIFIED FORM - jira_extra_fields extracted: {self._jira_extra_fields}")
332
368
 
333
- # Use first environment
369
+ # Use highest priority environment (lowest order value) for main environment field
334
370
  if environments:
335
- cleaned_data_copy["environment"] = environments[0]
371
+ cleaned_data_copy["environment"] = min(environments, key=lambda env: env.order)
336
372
 
337
- # Store custom fields in the incident
373
+ # Store custom fields in the incident (including all environments)
338
374
  cleaned_data_copy["custom_fields"] = {
339
- k: v for k, v in jira_extra_fields.items() if v is not None
375
+ k: v for k, v in self._jira_extra_fields.items() if v is not None
340
376
  }
341
377
 
378
+ # Create Incident in database (ALL priorities)
342
379
  incident = Incident.objects.declare(created_by=creator, **cleaned_data_copy)
343
- impacts_form = SelectImpactForm(impacts_data)
344
- impacts_form.save(incident=incident)
380
+ logger.info(f"UNIFIED FORM - Incident created: {incident.id}")
381
+
382
+ return incident
345
383
 
384
+ def _create_slack_channel(
385
+ self,
386
+ incident: Incident,
387
+ impacts_data: dict[str, ImpactLevel],
388
+ ) -> None:
389
+ """Create Slack channel for P1-P3 incidents only.
390
+
391
+ Args:
392
+ incident: The created Incident object
393
+ impacts_data: Dictionary of impact data
394
+ """
395
+ logger.info(f"UNIFIED FORM - Creating Slack channel for incident {incident.id}")
396
+
397
+ # Signal to create Slack channel (triggers bookmarks, roles message, etc.)
346
398
  create_incident_conversation.send(
347
399
  "unified_incident_form",
348
400
  incident=incident,
349
- jira_extra_fields=jira_extra_fields,
350
- impacts_data=impacts_data, # Pass impacts_data for business_impact computation
401
+ jira_extra_fields=self._jira_extra_fields,
402
+ impacts_data=impacts_data,
351
403
  )
352
404
 
353
- def _trigger_normal_incident_workflow(
405
+ def _create_jira_ticket(
354
406
  self,
407
+ incident: Incident,
355
408
  creator: User,
356
409
  impacts_data: dict[str, ImpactLevel],
357
410
  ) -> None:
358
- """Create a normal incident (P4-P5) with Jira ticket only."""
411
+ """Create Jira ticket for ALL priorities - UNIFIED method.
412
+
413
+ Args:
414
+ incident: The created Incident object
415
+ creator: User creating the incident
416
+ impacts_data: Dictionary of impact data
417
+ """
418
+ from firefighter.incidents.forms.select_impact import ( # noqa: PLC0415
419
+ SelectImpactForm,
420
+ )
359
421
  from firefighter.raid.client import client as jira_client # noqa: PLC0415
360
422
  from firefighter.raid.forms import ( # noqa: PLC0415
423
+ alert_slack_new_jira_ticket,
361
424
  prepare_jira_fields,
362
- process_jira_issue,
425
+ set_jira_ticket_watchers_raid,
363
426
  )
427
+ from firefighter.raid.models import JiraTicket # noqa: PLC0415
364
428
  from firefighter.raid.service import ( # noqa: PLC0415
365
429
  get_jira_user_from_user,
366
430
  )
431
+ from firefighter.slack.messages.slack_messages import ( # noqa: PLC0415
432
+ SlackMessageIncidentDeclaredAnnouncement,
433
+ )
434
+
435
+ logger.info(f"UNIFIED FORM - Creating Jira ticket for incident {incident.id}")
367
436
 
437
+ # Get Jira user
368
438
  jira_user: JiraUser = get_jira_user_from_user(creator)
369
439
 
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", [])
440
+ # Extract environments and platforms from jira_extra_fields
441
+ environments = self._jira_extra_fields.get("environments", [])
442
+ platforms = self._jira_extra_fields.get("platforms", [])
374
443
 
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
444
+ # Build description (enhanced for P1-P3 with Slack channel, simple for P4-P5)
445
+ if hasattr(incident, "conversation"):
446
+ # P1-P3: Enhanced description with emoji and formatting
447
+ description = f"""{incident.description}
448
+
449
+ 🧯 This incident has been created for a critical incident.
450
+ 📦 Incident category: {incident.incident_category.name}
451
+ {incident.priority.emoji} Priority: {incident.priority.name}
452
+ """
453
+ else:
454
+ # P4-P5: Simple description
455
+ description = incident.description
378
456
 
379
- # Prepare all Jira fields using the common function
380
- # P4-P5 pass all environments (unlike P1-P3 which pass first only)
457
+ # Prepare all Jira fields using common function (UNIFIED for all priorities)
381
458
  jira_fields = prepare_jira_fields(
382
- title=self.cleaned_data["title"],
383
- description=self.cleaned_data["description"],
384
- priority=self.cleaned_data["priority"].value,
459
+ title=incident.title,
460
+ description=description,
461
+ priority=incident.priority.value,
385
462
  reporter=jira_user.id,
386
- incident_category=self.cleaned_data["incident_category"].name,
387
- environments=environments, # P4-P5: pass ALL environments
463
+ incident_category=incident.incident_category.name,
464
+ environments=environments, # All environments for all priorities
388
465
  platforms=platforms,
389
466
  impacts_data=impacts_data,
390
467
  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,
468
+ "zendesk_ticket_id": self._jira_extra_fields.get("zendesk_ticket_id", ""),
469
+ "seller_contract_id": self._jira_extra_fields.get("seller_contract_id", ""),
470
+ "zoho_desk_ticket_id": self._jira_extra_fields.get("zoho_desk_ticket_id", ""),
471
+ "is_key_account": self._jira_extra_fields.get("is_key_account"),
472
+ "is_seller_in_golden_list": self._jira_extra_fields.get("is_seller_in_golden_list"),
473
+ "suggested_team_routing": self._jira_extra_fields.get("suggested_team_routing"),
397
474
  },
398
475
  )
399
476
 
400
- # Create Jira issue with all prepared fields
477
+ # Create Jira issue via API (UNIFIED)
401
478
  issue_data = jira_client.create_issue(**jira_fields)
479
+ logger.info(f"UNIFIED FORM - Jira issue created: {issue_data.get('key')}")
480
+
481
+ # Create JiraTicket in DB, linked to Incident (UNIFIED)
482
+ jira_ticket = JiraTicket.objects.create(**issue_data, incident=incident)
402
483
 
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
484
+ # Save impact levels
485
+ impacts_form = SelectImpactForm(impacts_data)
486
+ impacts_form.save(incident=jira_ticket)
487
+
488
+ # Set up Jira watchers
489
+ set_jira_ticket_watchers_raid(jira_ticket)
490
+
491
+ # Add Jira links to incident and Slack channel (if exists)
492
+ jira_client.jira.add_simple_link(
493
+ issue=str(jira_ticket.id),
494
+ object={
495
+ "url": incident.status_page_url,
496
+ "title": f"FireFighter incident #{incident.id}",
497
+ },
406
498
  )
499
+
500
+ # Send Slack notifications based on priority
501
+ # P1-P3 incidents have Slack channels, P4-P5 incidents don't
502
+ if incident.priority.value <= 3:
503
+ # P1-P3: Add Jira link to Slack channel (if exists)
504
+ if hasattr(incident, "conversation"):
505
+ logger.info(f"UNIFIED FORM - Adding Jira link to Slack channel for incident {incident.id}")
506
+ jira_client.jira.add_simple_link(
507
+ issue=str(jira_ticket.id),
508
+ object={
509
+ "url": incident.conversation.link,
510
+ "title": f"Slack conversation #{incident.conversation.name}",
511
+ },
512
+ )
513
+
514
+ # Add Jira bookmark to channel
515
+ incident.conversation.add_bookmark(
516
+ title="Jira ticket",
517
+ link=jira_ticket.url,
518
+ emoji=":jira_new:",
519
+ )
520
+
521
+ # Send incident announcement in channel
522
+ incident.conversation.send_message_and_save(
523
+ SlackMessageIncidentDeclaredAnnouncement(incident)
524
+ )
525
+ else:
526
+ # P4-P5: Send DM and raid_alert notifications
527
+ logger.info(f"UNIFIED FORM - Sending Slack alerts for P4-P5 incident {incident.id}")
528
+ alert_slack_new_jira_ticket(jira_ticket)
529
+
530
+ logger.info(f"UNIFIED FORM - Jira ticket creation complete for incident {incident.id}")
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  from typing import TYPE_CHECKING, Any
4
5
 
5
6
  from django import forms
@@ -11,6 +12,8 @@ from firefighter.incidents.models import IncidentCategory, Priority
11
12
  if TYPE_CHECKING:
12
13
  from firefighter.incidents.models import Incident
13
14
 
15
+ logger = logging.getLogger(__name__)
16
+
14
17
 
15
18
  class UpdateStatusForm(forms.Form):
16
19
  message = forms.CharField(
@@ -45,66 +48,99 @@ class UpdateStatusForm(forms.Form):
45
48
 
46
49
  # Dynamically adjust status choices based on incident requirements
47
50
  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"
51
+ self._set_status_choices(incident)
52
+
53
+ def _set_status_choices(self, incident: Incident) -> None:
54
+ """Set the status field choices based on the incident's current state and requirements."""
55
+ current_status = incident.status
56
+ status_field = self.fields["status"]
57
+
58
+ # Check if incident requires post-mortem (P1/P2 in PRD)
59
+ requires_postmortem = bool(
60
+ incident.priority
61
+ and incident.environment
62
+ and incident.priority.needs_postmortem
63
+ and incident.environment.value == "PRD"
64
+ )
65
+
66
+ allowed_statuses = self._get_allowed_statuses(current_status, requires_postmortem=requires_postmortem)
67
+
68
+ # If we got a list of enum values, convert to choices and include current status
69
+ if allowed_statuses:
70
+ if current_status not in allowed_statuses:
71
+ allowed_statuses.insert(0, current_status)
72
+ # Convert values to strings to match what Slack sends in form submissions
73
+ choices = [(str(s.value), s.label) for s in allowed_statuses]
74
+ status_field.choices = choices # type: ignore[attr-defined]
75
+ logger.debug(
76
+ f"Set status choices for incident #{incident.id}: {choices} "
77
+ f"(current_status={current_status}, requires_postmortem={requires_postmortem})"
58
78
  )
59
79
 
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]
80
+ def _get_allowed_statuses(
81
+ self, current_status: IncidentStatus, *, requires_postmortem: bool
82
+ ) -> list[IncidentStatus] | None:
83
+ """Get allowed status transitions based on current status and postmortem requirement.
84
+
85
+ Returns None if choices should be set directly (for default fallback cases).
86
+ """
87
+ status_field = self.fields["status"]
88
+
89
+ # For incidents requiring post-mortem (P1/P2 in PRD)
90
+ if requires_postmortem:
91
+ return self._get_postmortem_allowed_statuses(current_status, status_field)
92
+
93
+ # For P3+ incidents (no post-mortem needed)
94
+ return self._get_no_postmortem_allowed_statuses(current_status, status_field)
95
+
96
+ def _get_postmortem_allowed_statuses(
97
+ self, current_status: IncidentStatus, status_field: Any
98
+ ) -> list[IncidentStatus] | None:
99
+ """Get allowed statuses for incidents requiring postmortem."""
100
+ if current_status == IncidentStatus.OPEN:
101
+ return [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]
102
+ if current_status == IncidentStatus.INVESTIGATING:
103
+ return [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]
104
+ if current_status == IncidentStatus.MITIGATING:
105
+ return [IncidentStatus.MITIGATED]
106
+ if current_status == IncidentStatus.MITIGATED:
107
+ return [IncidentStatus.POST_MORTEM]
108
+ if current_status == IncidentStatus.POST_MORTEM:
109
+ return [IncidentStatus.CLOSED]
110
+
111
+ # Default: all statuses up to closed
112
+ self._set_default_choices(status_field, current_status, IncidentStatus.choices_lte(IncidentStatus.CLOSED))
113
+ return None
114
+
115
+ def _get_no_postmortem_allowed_statuses(
116
+ self, current_status: IncidentStatus, status_field: Any
117
+ ) -> list[IncidentStatus] | None:
118
+ """Get allowed statuses for incidents not requiring postmortem."""
119
+ if current_status == IncidentStatus.OPEN:
120
+ return [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]
121
+ if current_status == IncidentStatus.INVESTIGATING:
122
+ return [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]
123
+ if current_status == IncidentStatus.MITIGATING:
124
+ return [IncidentStatus.MITIGATED]
125
+ if current_status == IncidentStatus.MITIGATED:
126
+ return [IncidentStatus.CLOSED]
127
+
128
+ # Default fallback
129
+ self._set_default_choices(
130
+ status_field, current_status, IncidentStatus.choices_lte_skip_postmortem(IncidentStatus.CLOSED)
131
+ )
132
+ return None
133
+
134
+ def _set_default_choices(
135
+ self, status_field: Any, current_status: IncidentStatus, default_choices: Any
136
+ ) -> None:
137
+ """Set status field choices to default, ensuring current status is included."""
138
+ # Convert default_choices to string keys to match Slack form submissions
139
+ status_field.choices = [(str(choice[0]), choice[1]) for choice in default_choices]
140
+ existing_values = {choice[0] for choice in status_field.choices}
141
+ if str(current_status.value) not in existing_values:
142
+ # Insert current status at the beginning
143
+ status_field.choices = [(str(current_status.value), current_status.label), *status_field.choices]
108
144
 
109
145
  @staticmethod
110
146
  def requires_closure_reason(incident: Incident, target_status: IncidentStatus) -> bool:
@@ -77,8 +77,22 @@ class EnumChoiceField(TypedChoiceField):
77
77
  self.enum_class = enum_class
78
78
  if "choices" not in kwargs:
79
79
  kwargs["choices"] = enum_class.choices
80
+
81
+ # Customize error messages for better UX
82
+ if "error_messages" not in kwargs:
83
+ kwargs["error_messages"] = {}
84
+ if "invalid_choice" not in kwargs["error_messages"]:
85
+ kwargs["error_messages"]["invalid_choice"] = (
86
+ "The selected value is not valid. Please select a value from the dropdown list."
87
+ )
88
+
80
89
  super().__init__(*args, coerce=self.coerce_func, **kwargs)
81
90
 
91
+ def validate(self, value: Any) -> None:
92
+ """Log validation for debugging."""
93
+ logger.debug(f"EnumChoiceField.validate: value={value!r} (type={type(value).__name__}), choices={self.choices}")
94
+ return super().validate(value)
95
+
82
96
  def to_python(self, value: Any) -> int | str | Any:
83
97
  """Return a value from the enum class."""
84
98
  if value in self.empty_values:
@@ -582,6 +582,9 @@ class Incident(models.Model):
582
582
  ) -> IncidentUpdate:
583
583
  updated_fields: list[str] = []
584
584
 
585
+ # Save old priority BEFORE modifying the incident
586
+ old_priority = self.priority if priority_id is not None else None
587
+
585
588
  def _update_incident_field(
586
589
  incident: Incident, field_name: str, value: Any, updated_fields: list[str]
587
590
  ) -> None:
@@ -596,8 +599,6 @@ class Incident(models.Model):
596
599
  _update_incident_field(self, "description", description, updated_fields)
597
600
  _update_incident_field(self, "environment_id", environment_id, updated_fields)
598
601
 
599
- old_priority = self.priority if priority_id is not None else None
600
-
601
602
  if updated_fields:
602
603
  self.save(update_fields=[*updated_fields, "updated_at"])
603
604
 
firefighter/raid/apps.py CHANGED
@@ -17,7 +17,6 @@ class RaidConfig(AppConfig):
17
17
  import firefighter.raid.tasks
18
18
  import firefighter.raid.urls
19
19
  from firefighter.raid.signals import (
20
- incident_created,
21
20
  incident_updated,
22
21
  )
23
22
  from firefighter.slack.views.modals.open import INCIDENT_TYPES
@@ -1,7 +1,23 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import importlib
4
+ from typing import TYPE_CHECKING, Any
5
+
3
6
  import django.dispatch
4
7
 
5
8
  from firefighter.slack.signals.incident_closed import incident_closed_slack
6
9
 
10
+ if TYPE_CHECKING:
11
+ from firefighter.slack.signals import incident_updated
12
+
7
13
  incident_channel_done = django.dispatch.Signal()
14
+
15
+ __all__ = ["incident_channel_done", "incident_closed_slack", "incident_updated"]
16
+
17
+
18
+ def __getattr__(name: str) -> Any:
19
+ """Lazy import to avoid circular dependencies."""
20
+ if name == "incident_updated":
21
+ return importlib.import_module(".incident_updated", package="firefighter.slack.signals")
22
+ msg = f"module {__name__!r} has no attribute {name!r}"
23
+ raise AttributeError(msg)