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.
- firefighter/_version.py +2 -2
- firefighter/incidents/forms/edit.py +5 -3
- firefighter/incidents/forms/unified_incident.py +180 -56
- firefighter/incidents/forms/update_status.py +94 -58
- firefighter/incidents/forms/utils.py +14 -0
- firefighter/incidents/models/incident.py +3 -2
- firefighter/raid/apps.py +0 -1
- firefighter/slack/signals/__init__.py +16 -0
- firefighter/slack/signals/incident_updated.py +43 -1
- firefighter/slack/utils.py +43 -6
- firefighter/slack/views/modals/base_modal/form_utils.py +3 -1
- firefighter/slack/views/modals/downgrade_workflow.py +3 -1
- firefighter/slack/views/modals/edit.py +53 -7
- firefighter/slack/views/modals/opening/set_details.py +20 -0
- firefighter_fixtures/incidents/priorities.json +1 -1
- {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/RECORD +28 -29
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +160 -23
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +38 -60
- firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +35 -20
- firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +8 -6
- firefighter_tests/test_slack/test_signals_downgrade.py +147 -0
- firefighter_tests/test_slack/views/modals/test_edit.py +324 -0
- firefighter_tests/test_slack/views/modals/test_opening_unified.py +42 -0
- firefighter_tests/test_slack/views/modals/test_update_status.py +72 -2
- firefighter/raid/signals/incident_created.py +0 -129
- firefighter_tests/test_raid/test_p1_p3_jira_fields.py +0 -372
- firefighter_tests/test_raid/test_priority_mapping.py +0 -267
- {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/entry_points.txt +0 -0
- {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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
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
|
-
|
|
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.
|
|
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
|
-
) ->
|
|
291
|
-
"""Trigger
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
328
|
-
"
|
|
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: {
|
|
367
|
+
logger.info(f"UNIFIED FORM - jira_extra_fields extracted: {self._jira_extra_fields}")
|
|
332
368
|
|
|
333
|
-
# Use
|
|
369
|
+
# Use highest priority environment (lowest order value) for main environment field
|
|
334
370
|
if environments:
|
|
335
|
-
cleaned_data_copy["environment"] = environments
|
|
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
|
|
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
|
-
|
|
344
|
-
|
|
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=
|
|
350
|
-
impacts_data=impacts_data,
|
|
401
|
+
jira_extra_fields=self._jira_extra_fields,
|
|
402
|
+
impacts_data=impacts_data,
|
|
351
403
|
)
|
|
352
404
|
|
|
353
|
-
def
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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=
|
|
383
|
-
description=
|
|
384
|
-
priority=
|
|
459
|
+
title=incident.title,
|
|
460
|
+
description=description,
|
|
461
|
+
priority=incident.priority.value,
|
|
385
462
|
reporter=jira_user.id,
|
|
386
|
-
incident_category=
|
|
387
|
-
environments=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.
|
|
392
|
-
"seller_contract_id": self.
|
|
393
|
-
"zoho_desk_ticket_id": self.
|
|
394
|
-
"is_key_account": self.
|
|
395
|
-
"is_seller_in_golden_list": self.
|
|
396
|
-
"suggested_team_routing":
|
|
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
|
|
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
|
-
#
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
@@ -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)
|