firefighter-incident 0.0.14__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.
- firefighter/_version.py +2 -2
- firefighter/api/serializers.py +9 -0
- firefighter/confluence/signals/incident_updated.py +2 -2
- firefighter/incidents/enums.py +22 -2
- firefighter/incidents/forms/closure_reason.py +45 -0
- firefighter/incidents/forms/unified_incident.py +406 -0
- firefighter/incidents/forms/update_status.py +87 -1
- firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
- firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
- firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
- firefighter/incidents/models/incident.py +32 -5
- firefighter/incidents/static/css/main.min.css +1 -1
- firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
- firefighter/incidents/views/reports.py +3 -3
- firefighter/raid/apps.py +9 -26
- firefighter/raid/client.py +2 -2
- firefighter/raid/forms.py +75 -238
- firefighter/raid/signals/incident_created.py +38 -13
- firefighter/raid/signals/incident_updated.py +3 -2
- firefighter/slack/messages/slack_messages.py +19 -4
- firefighter/slack/rules.py +1 -1
- firefighter/slack/signals/create_incident_conversation.py +6 -0
- firefighter/slack/signals/incident_updated.py +7 -1
- firefighter/slack/views/modals/__init__.py +4 -0
- firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
- firefighter/slack/views/modals/close.py +15 -2
- firefighter/slack/views/modals/closure_reason.py +193 -0
- firefighter/slack/views/modals/open.py +59 -12
- firefighter/slack/views/modals/opening/details/unified.py +203 -0
- firefighter/slack/views/modals/opening/set_details.py +3 -2
- firefighter/slack/views/modals/postmortem.py +10 -2
- firefighter/slack/views/modals/update_status.py +28 -2
- firefighter/slack/views/modals/utils.py +51 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +61 -37
- firefighter_tests/test_incidents/test_enums.py +100 -0
- firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
- firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
- firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
- firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
- firefighter_tests/test_raid/conftest.py +154 -0
- firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
- firefighter_tests/test_raid/test_raid_forms.py +10 -253
- firefighter_tests/test_raid/test_raid_signals.py +187 -0
- firefighter_tests/test_slack/messages/__init__.py +0 -0
- firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
- firefighter_tests/test_slack/views/modals/conftest.py +140 -0
- firefighter_tests/test_slack/views/modals/test_close.py +65 -3
- firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
- firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
- firefighter_tests/test_slack/views/modals/test_open.py +146 -2
- firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
- firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
- firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
- firefighter/raid/views/open_normal.py +0 -139
- firefighter/slack/views/modals/opening/details/critical.py +0 -88
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.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.15'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 15)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
firefighter/api/serializers.py
CHANGED
|
@@ -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.
|
|
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.
|
|
51
|
+
in {IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM}
|
|
52
52
|
and not incident.needs_postmortem
|
|
53
53
|
):
|
|
54
54
|
publish_fixed_next_actions(incident)
|
firefighter/incidents/enums.py
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
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.
|
|
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}
|