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.
Files changed (63) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/api/serializers.py +9 -0
  3. firefighter/confluence/signals/incident_updated.py +2 -2
  4. firefighter/incidents/enums.py +22 -2
  5. firefighter/incidents/forms/closure_reason.py +45 -0
  6. firefighter/incidents/forms/unified_incident.py +406 -0
  7. firefighter/incidents/forms/update_status.py +87 -1
  8. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  9. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  10. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  11. firefighter/incidents/models/incident.py +32 -5
  12. firefighter/incidents/static/css/main.min.css +1 -1
  13. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  14. firefighter/incidents/views/reports.py +3 -3
  15. firefighter/raid/apps.py +9 -26
  16. firefighter/raid/client.py +2 -2
  17. firefighter/raid/forms.py +75 -238
  18. firefighter/raid/signals/incident_created.py +38 -13
  19. firefighter/raid/signals/incident_updated.py +3 -2
  20. firefighter/slack/messages/slack_messages.py +19 -4
  21. firefighter/slack/rules.py +1 -1
  22. firefighter/slack/signals/create_incident_conversation.py +6 -0
  23. firefighter/slack/signals/incident_updated.py +7 -1
  24. firefighter/slack/views/modals/__init__.py +4 -0
  25. firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
  26. firefighter/slack/views/modals/close.py +15 -2
  27. firefighter/slack/views/modals/closure_reason.py +193 -0
  28. firefighter/slack/views/modals/open.py +59 -12
  29. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  30. firefighter/slack/views/modals/opening/set_details.py +3 -2
  31. firefighter/slack/views/modals/postmortem.py +10 -2
  32. firefighter/slack/views/modals/update_status.py +28 -2
  33. firefighter/slack/views/modals/utils.py +51 -0
  34. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +1 -1
  35. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +61 -37
  36. firefighter_tests/test_incidents/test_enums.py +100 -0
  37. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  38. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  39. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  40. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  41. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  42. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  43. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  44. firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
  45. firefighter_tests/test_raid/conftest.py +154 -0
  46. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  47. firefighter_tests/test_raid/test_raid_forms.py +10 -253
  48. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  49. firefighter_tests/test_slack/messages/__init__.py +0 -0
  50. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  51. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  52. firefighter_tests/test_slack/views/modals/test_close.py +65 -3
  53. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  54. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  55. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  56. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  57. firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
  58. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  59. firefighter/raid/views/open_normal.py +0 -139
  60. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  61. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
  62. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
  63. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
firefighter/raid/apps.py CHANGED
@@ -20,35 +20,18 @@ class RaidConfig(AppConfig):
20
20
  incident_created,
21
21
  incident_updated,
22
22
  )
23
- from firefighter.raid.views.open_normal import (
24
- OpeningRaidCustomerModal,
25
- OpeningRaidDocumentationRequestModal,
26
- OpeningRaidFeatureRequestModal,
27
- OpeningRaidInternalModal,
28
- OpeningRaidSellerModal,
29
- )
30
23
  from firefighter.slack.views.modals.open import INCIDENT_TYPES
24
+ from firefighter.slack.views.modals.opening.details.unified import (
25
+ OpeningUnifiedModal,
26
+ )
31
27
 
28
+ # Use unified form for all normal incidents (P4-P5)
29
+ # This replaces the previous 5 separate forms (Customer/Seller/Internal/Doc/Feature)
30
+ # STEP 3 (incident type selection) will be automatically hidden since len() == 1
32
31
  INCIDENT_TYPES["normal"] = {
33
- "CUSTOMER": {
34
- "label": "Customer",
35
- "slack_form": OpeningRaidCustomerModal,
36
- },
37
- "SELLER": {
38
- "label": "Seller",
39
- "slack_form": OpeningRaidSellerModal,
40
- },
41
- "INTERNAL": {
42
- "label": "Internal",
43
- "slack_form": OpeningRaidInternalModal,
44
- },
45
- "DOCUMENTATION_REQUEST": {
46
- "label": "Documentation request",
47
- "slack_form": OpeningRaidDocumentationRequestModal,
48
- },
49
- "FEATURE_REQUEST": {
50
- "label": "Feature request",
51
- "slack_form": OpeningRaidFeatureRequestModal,
32
+ "normal": {
33
+ "label": "Normal",
34
+ "slack_form": OpeningUnifiedModal,
52
35
  },
53
36
  }
54
37
 
@@ -77,9 +77,9 @@ class RaidJiraClient(JiraClient):
77
77
  f"Seller link to TOOLBOX: {TOOLBOX_URL}?seller_id={seller_contract_id}"
78
78
  )
79
79
  extra_args["customfield_10908"] = str(seller_contract_id)
80
- if is_seller_in_golden_list:
80
+ if is_seller_in_golden_list is True:
81
81
  labels = [*labels, "goldenList"]
82
- if is_key_account:
82
+ if is_key_account is True:
83
83
  labels = [*labels, "keyAccount"]
84
84
  if suggested_team_routing and suggested_team_routing != "SBI":
85
85
  description_addendum.append(
firefighter/raid/forms.py CHANGED
@@ -3,15 +3,11 @@ from __future__ import annotations
3
3
  import logging
4
4
  from typing import TYPE_CHECKING, Any, Never
5
5
 
6
- from django import forms
7
6
  from django.conf import settings
8
7
  from django.db import models
9
8
  from slack_sdk.errors import SlackApiError
10
9
 
11
- from firefighter.incidents.forms.create_incident import CreateIncidentFormBase
12
10
  from firefighter.incidents.forms.select_impact import SelectImpactForm
13
- from firefighter.incidents.forms.utils import GroupedModelChoiceField
14
- from firefighter.incidents.models import IncidentCategory
15
11
  from firefighter.incidents.models.priority import Priority
16
12
  from firefighter.jira_app.client import (
17
13
  JiraAPIError,
@@ -23,16 +19,7 @@ from firefighter.raid.messages import (
23
19
  SlackMessageRaidCreatedIssue,
24
20
  SlackMessageRaidModifiedIssue,
25
21
  )
26
- from firefighter.raid.models import FeatureTeam, JiraTicket
27
- from firefighter.raid.service import (
28
- CustomerIssueData,
29
- create_issue_customer,
30
- create_issue_documentation_request,
31
- create_issue_feature_request,
32
- create_issue_internal,
33
- create_issue_seller,
34
- get_jira_user_from_user,
35
- )
22
+ from firefighter.raid.models import JiraTicket
36
23
  from firefighter.raid.utils import get_domain_from_email
37
24
  from firefighter.slack.models.conversation import Conversation
38
25
 
@@ -63,230 +50,11 @@ def initial_priority() -> Priority:
63
50
  return Priority.objects.get(default=True)
64
51
 
65
52
 
66
- class CreateNormalIncidentFormBase(CreateIncidentFormBase):
67
- platform = forms.ChoiceField(
68
- label="Platform",
69
- choices=PlatformChoices.choices,
70
- )
71
- title = forms.CharField(
72
- label="Title",
73
- max_length=128,
74
- min_length=10,
75
- widget=forms.TextInput(attrs={"placeholder": "What's going on?"}),
76
- )
77
- description = forms.CharField(
78
- label="Summary",
79
- min_length=10,
80
- max_length=1200,
81
- )
82
- suggested_team_routing = forms.ModelChoiceField(
83
- queryset=FeatureTeam.objects.only("name").order_by("name"),
84
- label="Feature Team or Train",
85
- required=True,
86
- )
87
- priority = forms.ModelChoiceField(
88
- label="Priority",
89
- queryset=Priority.objects.filter(enabled_create=True),
90
- initial=initial_priority,
91
- widget=forms.HiddenInput(),
92
- )
93
-
94
- field_order = [
95
- "incident_category",
96
- "platform",
97
- "title",
98
- "description",
99
- "seller_contract_id",
100
- "is_key_account",
101
- "is_seller_in_golden_list",
102
- "zoho_desk_ticket_id",
103
- "zendesk_ticket_id",
104
- "suggested_team_routing",
105
- ]
106
-
107
- def trigger_incident_workflow(
108
- self,
109
- creator: User,
110
- impacts_data: dict[str, ImpactLevel],
111
- *args: Any,
112
- **kwargs: Any,
113
- ) -> Any:
114
- raise NotImplementedError
115
-
116
-
117
- class CreateNormalCustomerIncidentForm(CreateNormalIncidentFormBase):
118
- incident_category = GroupedModelChoiceField(
119
- choices_groupby="group",
120
- queryset=IncidentCategory.objects.all().select_related("group").order_by("group__order", "name"),
121
- label="Incident category"
122
- )
123
- zendesk_ticket_id = forms.CharField(
124
- label="Zendesk Ticket ID", max_length=128, min_length=2, required=False
125
- )
126
-
127
- # XXX business impact: infer from impact/add in impact modal?
128
- def trigger_incident_workflow(
129
- self,
130
- creator: User,
131
- impacts_data: dict[str, ImpactLevel],
132
- *args: Never,
133
- **kwargs: Never,
134
- ) -> None:
135
- jira_user: JiraUser = get_jira_user_from_user(creator)
136
- customer_data = CustomerIssueData(
137
- priority=self.cleaned_data["priority"].value,
138
- labels=[""],
139
- platform=self.cleaned_data["platform"],
140
- business_impact=str(get_business_impact(impacts_data)),
141
- team_to_be_routed=self.cleaned_data["suggested_team_routing"],
142
- area=None,
143
- zendesk_ticket_id=self.cleaned_data["zendesk_ticket_id"],
144
- incident_category=self.cleaned_data["incident_category"].name,
145
- )
146
- issue_data = create_issue_customer(
147
- title=self.cleaned_data["title"],
148
- description=self.cleaned_data["description"],
149
- reporter=jira_user.id,
150
- issue_data=customer_data,
151
- )
152
- process_jira_issue(
153
- issue_data, creator, jira_user=jira_user, impacts_data=impacts_data
154
- )
155
-
156
-
157
- class CreateRaidDocumentationRequestIncidentForm(CreateNormalIncidentFormBase):
158
- incident_category = GroupedModelChoiceField(
159
- choices_groupby="group",
160
- queryset=IncidentCategory.objects.all().select_related("group").order_by("group__order", "name"),
161
- label="Incident category"
162
- )
163
-
164
- def trigger_incident_workflow(
165
- self,
166
- creator: User,
167
- impacts_data: dict[str, ImpactLevel],
168
- *args: Never,
169
- **kwargs: Never,
170
- ) -> None:
171
- jira_user: JiraUser = get_jira_user_from_user(creator)
172
- issue_data = create_issue_documentation_request(
173
- title=self.cleaned_data["title"],
174
- description=self.cleaned_data["description"],
175
- priority=self.cleaned_data["priority"].value,
176
- reporter=jira_user.id,
177
- platform=self.cleaned_data["platform"],
178
- labels=[""],
179
- )
180
-
181
- process_jira_issue(
182
- issue_data, creator, jira_user=jira_user, impacts_data=impacts_data
183
- )
184
-
185
-
186
- class CreateRaidFeatureRequestIncidentForm(CreateNormalIncidentFormBase):
187
- incident_category = GroupedModelChoiceField(
188
- choices_groupby="group",
189
- queryset=IncidentCategory.objects.all().select_related("group").order_by("group__order", "name"),
190
- label="Incident category"
191
- )
192
-
193
- def trigger_incident_workflow(
194
- self,
195
- creator: User,
196
- impacts_data: dict[str, ImpactLevel],
197
- *args: Never,
198
- **kwargs: Never,
199
- ) -> None:
200
- jira_user: JiraUser = get_jira_user_from_user(creator)
201
- issue_data = create_issue_feature_request(
202
- title=self.cleaned_data["title"],
203
- description=self.cleaned_data["description"],
204
- priority=self.cleaned_data["priority"].value,
205
- reporter=jira_user.id,
206
- platform=self.cleaned_data["platform"],
207
- labels=[""],
208
- )
209
-
210
- process_jira_issue(
211
- issue_data, creator, jira_user=jira_user, impacts_data=impacts_data
212
- )
213
-
214
-
215
- class CreateRaidInternalIncidentForm(CreateNormalIncidentFormBase):
216
- incident_category = GroupedModelChoiceField(
217
- choices_groupby="group",
218
- queryset=IncidentCategory.objects.all().select_related("group").order_by("group__order", "name"),
219
- label="Incident category"
220
- )
221
-
222
- def trigger_incident_workflow(
223
- self,
224
- creator: User,
225
- impacts_data: dict[str, ImpactLevel],
226
- *args: Never,
227
- **kwargs: Never,
228
- ) -> None:
229
- jira_user: JiraUser = get_jira_user_from_user(creator)
230
- issue_data = create_issue_internal(
231
- title=self.cleaned_data["title"],
232
- description=self.cleaned_data["description"],
233
- priority=self.cleaned_data["priority"].value,
234
- reporter=jira_user.id,
235
- platform=self.cleaned_data["platform"],
236
- business_impact=str(get_business_impact(impacts_data)),
237
- team_to_be_routed=self.cleaned_data["suggested_team_routing"],
238
- incident_category=self.cleaned_data["incident_category"].name,
239
- labels=[""],
240
- )
241
-
242
- process_jira_issue(
243
- issue_data, creator, jira_user=jira_user, impacts_data=impacts_data
244
- )
245
-
246
-
247
- class RaidCreateIncidentSellerForm(CreateNormalIncidentFormBase):
248
- incident_category = GroupedModelChoiceField(
249
- choices_groupby="group",
250
- queryset=IncidentCategory.objects.all().select_related("group").order_by("group__order", "name"),
251
- label="Incident category"
252
- )
253
- seller_contract_id = forms.CharField(
254
- label="Seller Contract ID", max_length=128, min_length=0
255
- )
256
- is_key_account = forms.BooleanField(label="Is it a Key Account?", required=False)
257
- is_seller_in_golden_list = forms.BooleanField(
258
- label="Is the seller in the Golden List?", required=False
259
- )
260
- zoho_desk_ticket_id = forms.CharField(
261
- required=False, label="Zoho Desk Ticket ID", max_length=128, min_length=1
262
- )
263
-
264
- def trigger_incident_workflow(
265
- self,
266
- creator: User,
267
- impacts_data: dict[str, ImpactLevel],
268
- *args: Never,
269
- **kwargs: Never,
270
- ) -> None:
271
- jira_user: JiraUser = get_jira_user_from_user(creator)
272
- issue_data = create_issue_seller(
273
- title=self.cleaned_data["title"],
274
- description=self.cleaned_data["description"],
275
- priority=self.cleaned_data["priority"].value,
276
- reporter=jira_user.id,
277
- platform=self.cleaned_data["platform"],
278
- business_impact=str(get_business_impact(impacts_data)),
279
- team_to_be_routed=self.cleaned_data["suggested_team_routing"],
280
- incident_category=self.cleaned_data["incident_category"].name,
281
- zoho_desk_ticket_id=self.cleaned_data["zoho_desk_ticket_id"],
282
- is_key_account=self.cleaned_data["is_key_account"],
283
- is_seller_in_golden_list=self.cleaned_data["is_seller_in_golden_list"],
284
- seller_contract_id=self.cleaned_data["seller_contract_id"],
285
- labels=[""],
286
- )
287
- process_jira_issue(
288
- issue_data, creator, jira_user=jira_user, impacts_data=impacts_data
289
- )
53
+ # NOTE: Incident creation forms have been unified and moved to:
54
+ # firefighter.incidents.forms.unified_incident.UnifiedIncidentForm
55
+ # This handles all incident types (P1-P5) with dynamic field visibility.
56
+ # The following utility functions remain here as they are used by the unified form
57
+ # and by JIRA webhook handlers.
290
58
 
291
59
 
292
60
  def process_jira_issue(
@@ -476,6 +244,75 @@ def get_business_impact(impacts_data: dict[str, ImpactLevel]) -> str | None:
476
244
  return impact_form.business_impact_new
477
245
 
478
246
 
247
+ def prepare_jira_fields(
248
+ *,
249
+ title: str,
250
+ description: str,
251
+ priority: int,
252
+ reporter: str,
253
+ incident_category: str,
254
+ environments: list[str],
255
+ platforms: list[str],
256
+ impacts_data: dict[str, ImpactLevel],
257
+ optional_fields: dict[str, Any] | None = None,
258
+ ) -> dict[str, Any]:
259
+ """Prepare all fields for jira_client.create_issue().
260
+
261
+ This function centralizes Jira field preparation for both P1-P3 and P4-P5 incidents,
262
+ ensuring all custom fields are properly passed.
263
+
264
+ Args:
265
+ title: Incident title
266
+ description: Incident description
267
+ priority: Priority value (1-5)
268
+ reporter: Jira user account ID
269
+ incident_category: Category name
270
+ environments: List of environment values (e.g. ["PRD", "STG"])
271
+ platforms: List of platform values (e.g. ["platform-FR", "platform-DE"])
272
+ impacts_data: Dictionary of impact data for business_impact computation
273
+ optional_fields: Optional dictionary containing:
274
+ - zendesk_ticket_id: Zendesk ticket ID (customer-specific)
275
+ - seller_contract_id: Seller contract ID (seller-specific)
276
+ - zoho_desk_ticket_id: Zoho Desk ticket ID (seller-specific)
277
+ - is_key_account: Key account flag (seller-specific)
278
+ - is_seller_in_golden_list: Golden list flag (seller-specific)
279
+ - suggested_team_routing: Suggested team routing (P4-P5 only)
280
+
281
+ Returns:
282
+ Dictionary of kwargs ready for jira_client.create_issue()
283
+ """
284
+ business_impact = get_business_impact(impacts_data)
285
+ platform = platforms[0] if platforms else PlatformChoices.ALL.value
286
+
287
+ # Extract optional fields with defaults
288
+ opt = optional_fields or {}
289
+ zendesk_ticket_id = opt.get("zendesk_ticket_id", "")
290
+ seller_contract_id = opt.get("seller_contract_id", "")
291
+ zoho_desk_ticket_id = opt.get("zoho_desk_ticket_id", "")
292
+ is_key_account = opt.get("is_key_account")
293
+ is_seller_in_golden_list = opt.get("is_seller_in_golden_list")
294
+ suggested_team_routing = opt.get("suggested_team_routing")
295
+
296
+ return {
297
+ "issuetype": "Incident",
298
+ "summary": title,
299
+ "description": description,
300
+ "priority": priority,
301
+ "reporter": reporter,
302
+ "assignee": None,
303
+ "incident_category": incident_category,
304
+ "environments": environments, # ✅ Always pass environments list
305
+ "platform": platform,
306
+ "business_impact": business_impact,
307
+ "zendesk_ticket_id": zendesk_ticket_id,
308
+ "seller_contract_id": seller_contract_id,
309
+ "zoho_desk_ticket_id": zoho_desk_ticket_id,
310
+ "is_key_account": is_key_account if is_key_account is not None else False,
311
+ "is_seller_in_golden_list": is_seller_in_golden_list if is_seller_in_golden_list is not None else False,
312
+ "suggested_team_routing": suggested_team_routing,
313
+ }
314
+
315
+
479
316
  def get_partner_alert_conversations(user_domain: str) -> QuerySet[Conversation]:
480
317
  # Get the right channel from tags
481
318
  return Conversation.objects.filter(tag__contains=f"raid_alert__{user_domain}")
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
- from typing import TYPE_CHECKING, Any, Never
4
+ from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from django.conf import settings
7
7
  from django.dispatch.dispatcher import receiver
8
8
 
9
9
  from firefighter.jira_app.client import JiraAPIError, JiraUserNotFoundError
10
10
  from firefighter.raid.client import client
11
+ from firefighter.raid.forms import prepare_jira_fields
11
12
  from firefighter.raid.models import JiraTicket
12
13
  from firefighter.raid.service import get_jira_user_from_user
13
14
  from firefighter.slack.messages.slack_messages import (
@@ -26,31 +27,55 @@ APP_DISPLAY_NAME: str = settings.APP_DISPLAY_NAME
26
27
 
27
28
  @receiver(signal=incident_channel_done)
28
29
  def create_ticket(
29
- sender: Any, incident: Incident, channel: IncidentChannel, **kwargs: Never
30
+ sender: Any, incident: Incident, channel: IncidentChannel, **kwargs: Any
30
31
  ) -> JiraTicket:
31
32
  # pylint: disable=unused-argument
32
33
 
34
+ # Extract jira_extra_fields and impacts_data from kwargs (passed from unified incident form)
35
+ jira_extra_fields = kwargs.get("jira_extra_fields", {})
36
+ impacts_data = kwargs.get("impacts_data", {})
37
+ logger.info(f"CREATE_TICKET - kwargs keys: {list(kwargs.keys())}")
38
+ logger.info(f"CREATE_TICKET - jira_extra_fields received: {jira_extra_fields}")
39
+
33
40
  jira_user = get_jira_user_from_user(incident.created_by)
34
41
  account_id = jira_user.id
35
- # XXX Better description
36
- # XXX Custom field with FireFighter ID/link?
37
- # XXX Set affected environment custom field
38
- # XXX Set custom field impacted area to group/domain?
42
+
39
43
  # Map Impact priority (1-5) to JIRA priority (1-5), fallback to P1 for invalid values
40
44
  priority: int = incident.priority.value if 1 <= incident.priority.value <= 5 else 1
41
- issue = client.create_issue(
42
- issuetype="Incident",
43
- summary=incident.title,
44
- description=f"""{incident.description}\n
45
+
46
+ # Build enhanced description with incident metadata
47
+ description = f"""{incident.description}\n
45
48
  \n
46
49
  🧯 This incident has been created for a critical incident. Links below to Slack and {APP_DISPLAY_NAME}.\n
47
50
  📦 Incident category: {incident.incident_category.name} ({incident.incident_category.group.name})\n
48
- {incident.priority.emoji} Priority: {incident.priority.name}\n""",
49
- assignee=None,
50
- reporter=account_id,
51
+ {incident.priority.emoji} Priority: {incident.priority.name}\n"""
52
+
53
+ # Prepare all Jira fields using the common function
54
+ # P1-P3 use first environment only (for backward compatibility)
55
+ environments = jira_extra_fields.get("environments", [incident.environment.value])
56
+ platforms = jira_extra_fields.get("platforms", ["platform-All"])
57
+
58
+ jira_fields = prepare_jira_fields(
59
+ title=incident.title,
60
+ description=description,
51
61
  priority=priority,
62
+ reporter=account_id,
52
63
  incident_category=incident.incident_category.name,
64
+ environments=[environments[0]] if environments else [incident.environment.value], # P1-P3: first only
65
+ platforms=platforms,
66
+ impacts_data=impacts_data,
67
+ optional_fields={
68
+ "zendesk_ticket_id": jira_extra_fields.get("zendesk_ticket_id", ""),
69
+ "seller_contract_id": jira_extra_fields.get("seller_contract_id", ""),
70
+ "zoho_desk_ticket_id": jira_extra_fields.get("zoho_desk_ticket_id", ""),
71
+ "is_key_account": jira_extra_fields.get("is_key_account"),
72
+ "is_seller_in_golden_list": jira_extra_fields.get("is_seller_in_golden_list"),
73
+ "suggested_team_routing": jira_extra_fields.get("suggested_team_routing"),
74
+ },
53
75
  )
76
+
77
+ # Create Jira issue with all prepared fields
78
+ issue = client.create_issue(**jira_fields)
54
79
  issue_id = issue.get("id")
55
80
  if issue_id is None:
56
81
  logger.error(f"Could not create Jira ticket for incident {incident.id}")
@@ -24,10 +24,11 @@ def incident_updated_close_ticket_when_mitigated_or_postmortem(
24
24
  updated_fields: list[str],
25
25
  **kwargs: Any,
26
26
  ) -> None:
27
- # Close Jira ticket if mitigated or postmortem
27
+ # Close Jira ticket if mitigated, postmortem, or closed
28
28
  if "_status" in updated_fields and incident_update.status in {
29
- IncidentStatus.FIXED,
29
+ IncidentStatus.MITIGATED,
30
30
  IncidentStatus.POST_MORTEM,
31
+ IncidentStatus.CLOSED,
31
32
  }:
32
33
  if not hasattr(incident, "jira_ticket") or incident.jira_ticket is None:
33
34
  logger.warning(
@@ -217,6 +217,21 @@ class SlackMessageIncidentDeclaredAnnouncement(SlackMessageSurface):
217
217
  ]
218
218
  if hasattr(self.incident, "jira_ticket") and self.incident.jira_ticket:
219
219
  fields.append(f":jira_new: <{self.incident.jira_ticket.url}|*Jira ticket*>")
220
+
221
+ # Add custom fields if present
222
+ if hasattr(self.incident, "custom_fields") and self.incident.custom_fields:
223
+ custom_fields = self.incident.custom_fields
224
+ if custom_fields.get("zendesk_ticket_id"):
225
+ fields.append(f":ticket: *Zendesk Ticket:* {custom_fields['zendesk_ticket_id']}")
226
+ if custom_fields.get("seller_contract_id"):
227
+ fields.append(f":memo: *Seller Contract:* {custom_fields['seller_contract_id']}")
228
+ if custom_fields.get("zoho_desk_ticket_id"):
229
+ fields.append(f":ticket: *Zoho Desk Ticket:* {custom_fields['zoho_desk_ticket_id']}")
230
+ if custom_fields.get("is_key_account") is True:
231
+ fields.append(":star: *Key Account*")
232
+ if custom_fields.get("is_seller_in_golden_list") is True:
233
+ fields.append(":medal: *Golden List Seller*")
234
+
220
235
  blocks: list[Block] = [
221
236
  SectionBlock(
222
237
  text=f"{self.incident.priority.emoji} {self.incident.priority.name} - A new incident has been declared:"
@@ -442,7 +457,7 @@ class SlackMessageIncidentStatusUpdated(SlackMessageSurface):
442
457
 
443
458
  if self.in_channel:
444
459
  self.title_text = "A new incident update has been posted"
445
- elif incident.status == IncidentStatus.FIXED and status_changed:
460
+ elif incident.status == IncidentStatus.MITIGATED and status_changed:
446
461
  self.title_text = f":large_green_circle: Incident #{incident.slack_channel_name} has been {incident.status.label}. :large_green_circle:"
447
462
  elif old_priority is not None and old_priority.value > 3:
448
463
  self.title_text = f"Incident #{incident.slack_channel_name} has escalated from {old_priority.name} to {incident.priority.name}."
@@ -509,7 +524,7 @@ class SlackMessageIncidentStatusUpdated(SlackMessageSurface):
509
524
  value=str(self.incident.id),
510
525
  action_id=UpdateStatusModal.open_action,
511
526
  )
512
- if self.in_channel
527
+ if self.in_channel and self.incident.status != IncidentStatus.CLOSED
513
528
  else None
514
529
  ),
515
530
  ),
@@ -691,7 +706,7 @@ class SlackMessageDeployWarning(SlackMessageSurface):
691
706
  blocks = [
692
707
  HeaderBlock(
693
708
  text=PlainTextObject(
694
- text=f":warning: Deploy warning {'(Mitigated) ' if self.incident.status == IncidentStatus.FIXED else ''}:warning:",
709
+ text=f":warning: Deploy warning {'(Mitigated) ' if self.incident.status == IncidentStatus.MITIGATED else ''}:warning:",
695
710
  emoji=True,
696
711
  )
697
712
  ),
@@ -702,7 +717,7 @@ class SlackMessageDeployWarning(SlackMessageSurface):
702
717
  ),
703
718
  ]
704
719
 
705
- if self.incident.status >= IncidentStatus.FIXED:
720
+ if self.incident.status >= IncidentStatus.MITIGATED:
706
721
  blocks.extend(
707
722
  [
708
723
  SectionBlock(
@@ -31,7 +31,7 @@ def should_publish_in_general_channel(
31
31
  ):
32
32
  # If it has just been Mitigated
33
33
  if (
34
- incident.status == IncidentStatus.FIXED
34
+ incident.status == IncidentStatus.MITIGATED
35
35
  and incident_update is not None
36
36
  and incident_update.status is not None
37
37
  ):
@@ -45,7 +45,12 @@ def create_incident_slack_conversation(
45
45
  Args:
46
46
  incident (Incident): The incident to open. It should be saved before calling this function, and have its first incident update created.
47
47
 
48
+ Kwargs:
49
+ jira_extra_fields (dict): Optional dictionary of customer/seller fields for Jira ticket
50
+
48
51
  """
52
+ # Extract jira_extra_fields from kwargs
53
+ jira_extra_fields = _kwargs.get("jira_extra_fields", {})
49
54
  channel: IncidentChannel | None = IncidentChannel.objects.create_incident_channel(
50
55
  incident=incident
51
56
  )
@@ -131,5 +136,6 @@ def create_incident_slack_conversation(
131
136
  sender=__name__,
132
137
  incident=incident,
133
138
  channel=channel,
139
+ jira_extra_fields=jira_extra_fields,
134
140
  )
135
141
  return None
@@ -159,6 +159,11 @@ def incident_key_events_updated_handler(
159
159
  **kwargs: Any,
160
160
  ) -> None:
161
161
  logger.info("Received incident_key_events_updated signal")
162
+ # Skip key events for incidents closed directly
163
+ if incident.closure_reason:
164
+ logger.info(f"Skipping key events update for incident {incident.id} (direct closure)")
165
+ return
166
+
162
167
  # Everything in Slack views trigger the Slack handshake, so we delay the import
163
168
  from firefighter.slack.views.modals.key_event_message import ( # noqa: PLC0415
164
169
  SlackMessageKeyEvents,
@@ -196,7 +201,8 @@ def publish_status_update(
196
201
  if (
197
202
  incident.ask_for_milestones
198
203
  and status_changed
199
- and incident.status >= IncidentStatus.FIXED
204
+ and incident.status >= IncidentStatus.MITIGATED
205
+ and not incident.closure_reason # Don't show key events for direct closures
200
206
  ):
201
207
  from firefighter.slack.views.modals.key_event_message import ( # noqa: PLC0415
202
208
  SlackMessageKeyEvents,
@@ -3,6 +3,10 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from firefighter.slack.views.modals.close import CloseModal, modal_close
6
+ from firefighter.slack.views.modals.closure_reason import (
7
+ ClosureReasonModal,
8
+ modal_closure_reason,
9
+ )
6
10
  from firefighter.slack.views.modals.downgrade_workflow import (
7
11
  DowngradeWorkflowModal,
8
12
  modal_dowgrade_workflow,