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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. firefighter/_version.py +16 -3
  2. firefighter/api/serializers.py +17 -8
  3. firefighter/api/urls.py +8 -1
  4. firefighter/api/views/_base.py +1 -1
  5. firefighter/api/views/components.py +5 -5
  6. firefighter/api/views/incidents.py +9 -9
  7. firefighter/confluence/signals/incident_updated.py +2 -2
  8. firefighter/firefighter/settings/components/raid.py +3 -0
  9. firefighter/incidents/admin.py +24 -24
  10. firefighter/incidents/enums.py +22 -2
  11. firefighter/incidents/factories.py +14 -5
  12. firefighter/incidents/forms/close_incident.py +4 -4
  13. firefighter/incidents/forms/closure_reason.py +45 -0
  14. firefighter/incidents/forms/create_incident.py +4 -4
  15. firefighter/incidents/forms/unified_incident.py +406 -0
  16. firefighter/incidents/forms/update_status.py +91 -5
  17. firefighter/incidents/menus.py +2 -2
  18. firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
  19. firefighter/incidents/migrations/0009_update_sla.py +7 -5
  20. firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
  21. firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
  22. firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
  23. firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
  24. firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
  25. firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
  26. firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
  27. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  28. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  29. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  30. firefighter/incidents/models/__init__.py +1 -1
  31. firefighter/incidents/models/group.py +1 -1
  32. firefighter/incidents/models/incident.py +47 -20
  33. firefighter/incidents/models/{component.py → incident_category.py} +30 -29
  34. firefighter/incidents/models/incident_update.py +3 -3
  35. firefighter/incidents/static/css/main.min.css +1 -1
  36. firefighter/incidents/tables.py +9 -9
  37. firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
  38. firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
  39. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  40. firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
  41. firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
  42. firefighter/incidents/templates/pages/incident_detail.html +3 -3
  43. firefighter/incidents/urls.py +6 -6
  44. firefighter/incidents/views/components/details.py +9 -9
  45. firefighter/incidents/views/components/list.py +9 -9
  46. firefighter/incidents/views/reports.py +5 -5
  47. firefighter/incidents/views/users/details.py +2 -2
  48. firefighter/incidents/views/views.py +7 -7
  49. firefighter/jira_app/client.py +1 -1
  50. firefighter/logging/custom_json_formatter.py +2 -1
  51. firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
  52. firefighter/raid/admin.py +0 -11
  53. firefighter/raid/apps.py +9 -26
  54. firefighter/raid/client.py +5 -5
  55. firefighter/raid/forms.py +84 -213
  56. firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
  57. firefighter/raid/models.py +2 -21
  58. firefighter/raid/serializers.py +5 -4
  59. firefighter/raid/service.py +29 -27
  60. firefighter/raid/signals/incident_created.py +42 -15
  61. firefighter/raid/signals/incident_updated.py +3 -2
  62. firefighter/raid/utils.py +1 -1
  63. firefighter/raid/views/__init__.py +1 -1
  64. firefighter/slack/admin.py +8 -8
  65. firefighter/slack/management/commands/switch_test_users.py +272 -0
  66. firefighter/slack/messages/slack_messages.py +24 -9
  67. firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
  68. firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
  69. firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
  70. firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
  71. firefighter/slack/models/conversation.py +3 -3
  72. firefighter/slack/models/incident_channel.py +1 -1
  73. firefighter/slack/models/user.py +1 -1
  74. firefighter/slack/models/user_group.py +3 -3
  75. firefighter/slack/rules.py +2 -2
  76. firefighter/slack/signals/create_incident_conversation.py +6 -0
  77. firefighter/slack/signals/get_users.py +2 -2
  78. firefighter/slack/signals/incident_updated.py +8 -2
  79. firefighter/slack/utils.py +2 -2
  80. firefighter/slack/views/events/home.py +2 -2
  81. firefighter/slack/views/modals/__init__.py +4 -0
  82. firefighter/slack/views/modals/base_modal/form_utils.py +78 -0
  83. firefighter/slack/views/modals/close.py +18 -5
  84. firefighter/slack/views/modals/closure_reason.py +193 -0
  85. firefighter/slack/views/modals/open.py +83 -12
  86. firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
  87. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  88. firefighter/slack/views/modals/opening/select_impact.py +5 -2
  89. firefighter/slack/views/modals/opening/set_details.py +3 -2
  90. firefighter/slack/views/modals/postmortem.py +10 -2
  91. firefighter/slack/views/modals/update_status.py +32 -6
  92. firefighter/slack/views/modals/utils.py +51 -0
  93. firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
  94. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +2 -2
  95. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +133 -88
  96. firefighter_tests/conftest.py +4 -5
  97. firefighter_tests/test_api/test_api_landbot.py +1 -1
  98. firefighter_tests/test_firefighter/test_sso.py +146 -0
  99. firefighter_tests/test_incidents/test_enums.py +100 -0
  100. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  101. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  102. firefighter_tests/test_incidents/test_forms/test_form_utils.py +15 -15
  103. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  104. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  105. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  106. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  107. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  108. firefighter_tests/test_incidents/test_incident_urls.py +3 -3
  109. firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
  110. firefighter_tests/test_incidents/test_models/test_incident_model.py +70 -2
  111. firefighter_tests/test_raid/conftest.py +154 -0
  112. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  113. firefighter_tests/test_raid/test_priority_mapping.py +267 -0
  114. firefighter_tests/test_raid/test_raid_client.py +580 -0
  115. firefighter_tests/test_raid/test_raid_forms.py +552 -0
  116. firefighter_tests/test_raid/test_raid_models.py +185 -0
  117. firefighter_tests/test_raid/test_raid_serializers.py +507 -0
  118. firefighter_tests/test_raid/test_raid_service.py +442 -0
  119. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  120. firefighter_tests/test_raid/test_raid_views.py +196 -0
  121. firefighter_tests/test_slack/messages/__init__.py +0 -0
  122. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  123. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  124. firefighter_tests/test_slack/views/modals/test_close.py +71 -9
  125. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  126. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  127. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  128. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  129. firefighter_tests/test_slack/views/modals/test_update_status.py +331 -7
  130. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  131. firefighter/raid/views/open_normal.py +0 -139
  132. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  133. firefighter_fixtures/raid/area.json +0 -1
  134. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
  135. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
  136. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -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
@@ -26,8 +26,8 @@ logger = logging.getLogger(__name__)
26
26
  def get_invites_from_slack(incident: Incident, **_kwargs: Any) -> Iterable[User]:
27
27
  """New version using cached users instead of querying Slack API."""
28
28
  # Prepare sub-queries
29
- slack_usergroups: QuerySet[UserGroup] = incident.component.usergroups.all()
30
- slack_conversations: QuerySet[Conversation] = incident.component.conversations.all()
29
+ slack_usergroups: QuerySet[UserGroup] = incident.incident_category.usergroups.all()
30
+ slack_conversations: QuerySet[Conversation] = incident.incident_category.conversations.all()
31
31
 
32
32
  # We make sure to exclude the bot user, and avoid duplicates with distinct()
33
33
  # Also make sure that all users have related SlackUser and a slack_id
@@ -44,7 +44,7 @@ def incident_updated_update_status_handler(
44
44
  # Update topic if needed
45
45
  if (
46
46
  "priority_id" in updated_fields
47
- or "component_id" in updated_fields
47
+ or "incident_category_id" in updated_fields
48
48
  or "_status" in updated_fields
49
49
  ):
50
50
  incident.conversation.set_incident_channel_topic()
@@ -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,
@@ -97,9 +97,9 @@ def channel_name_from_incident(incident: Incident) -> str:
97
97
  )
98
98
  date_formatted = localtime(incident.created_at).strftime("%Y%m%d")
99
99
  if incident.environment is not None and incident.environment.value != "PRD":
100
- topic = f"{date_formatted}-{str(incident.id)[:8]}-{incident.environment.value}-{incident.component.name}"
100
+ topic = f"{date_formatted}-{str(incident.id)[:8]}-{incident.environment.value}-{incident.incident_category.name}"
101
101
  else:
102
- topic = f"{date_formatted}-{str(incident.id)[:8]}-{incident.component.name}"
102
+ topic = f"{date_formatted}-{str(incident.id)[:8]}-{incident.incident_category.name}"
103
103
 
104
104
  # Strip non-alphanumeric characters, cut at 80 chars
105
105
  # XXX django.utils.text.slugify should be used instead
@@ -57,7 +57,7 @@ def update_home_tab(
57
57
  Incident.objects.filter(_status__lt=IncidentStatus.CLOSED.value)
58
58
  .order_by("-id")
59
59
  .select_related(
60
- "priority", "component", "environment", "component__group", "conversation"
60
+ "priority", "incident_category", "environment", "incident_category__group", "conversation"
61
61
  )[:30]
62
62
  )
63
63
  blocks: list[Block] = [
@@ -148,7 +148,7 @@ def _home_incident_element(
148
148
  text=f":rotating_light: *Priority:* {incident.priority.emoji} {incident.priority.name}"
149
149
  ),
150
150
  MarkdownTextObject(
151
- text=f":package: *Issue category:* {incident.component.group.name} - {incident.component.name}"
151
+ text=f":package: *Incident category:* {incident.incident_category.group.name} - {incident.incident_category.name}"
152
152
  ),
153
153
  MarkdownTextObject(
154
154
  text=f":speaking_head_in_silhouette: *Last update:* {date_time(incident.updated_at)}"
@@ -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,
@@ -26,6 +26,7 @@ from slack_sdk.models.blocks.block_elements import (
26
26
  InputInteractiveElement,
27
27
  PlainTextInputElement,
28
28
  SelectElement,
29
+ StaticMultiSelectElement,
29
30
  UserSelectElement,
30
31
  )
31
32
  from slack_sdk.models.blocks.blocks import ActionsBlock, Block, InputBlock, SectionBlock
@@ -178,6 +179,10 @@ class SlackForm(Generic[T]):
178
179
  return self._process_slack_model_grouped_choice_field(
179
180
  field_name, f, slack_input_kwargs
180
181
  )
182
+ case forms.ModelMultipleChoiceField() | forms.MultipleChoiceField():
183
+ return self._process_slack_multiple_choice_field(
184
+ field_name, f, slack_input_kwargs
185
+ )
181
186
  case forms.ModelChoiceField() | forms.ChoiceField() | EnumChoiceField():
182
187
  if (
183
188
  isinstance(f, forms.ModelChoiceField)
@@ -336,10 +341,65 @@ class SlackForm(Generic[T]):
336
341
  ):
337
342
  slack_input_kwargs["options"].append(slack_input_kwargs["initial_option"])
338
343
 
344
+ # Ensure we have at least one option for Slack API
345
+ if not slack_input_kwargs["options"]:
346
+ slack_input_kwargs["options"] = [
347
+ SafeOption(label="Please select an option", value="__placeholder__")
348
+ ]
349
+
339
350
  field_name = f"{field_name}___{f.initial}{datetime.now().timestamp()}" # noqa: DTZ005
340
351
  field_name = field_name[:254]
341
352
  return SelectElement(action_id=field_name, **slack_input_kwargs)
342
353
 
354
+ @classmethod
355
+ def _process_slack_multiple_choice_field(
356
+ cls,
357
+ field_name: str,
358
+ f: forms.ModelMultipleChoiceField | forms.MultipleChoiceField, # type: ignore[type-arg]
359
+ slack_input_kwargs: dict[str, Any],
360
+ ) -> InputInteractiveElement:
361
+ """Process multiple choice fields (ModelMultipleChoiceField, MultipleChoiceField)."""
362
+ if not isinstance(f, forms.ModelMultipleChoiceField | forms.MultipleChoiceField):
363
+ err_msg = f"Field {field_name} is not a ModelMultipleChoiceField or MultipleChoiceField" # type: ignore[unreachable]
364
+ raise TypeError(err_msg)
365
+
366
+ # Handle initial values (list of objects or values)
367
+ if f.initial:
368
+ initial_options: list[SafeOption] = []
369
+ # f.initial can be a list, queryset, or callable
370
+ initial_value = f.initial() if callable(f.initial) else f.initial
371
+
372
+ if isinstance(f, forms.ModelMultipleChoiceField):
373
+ # For ModelMultipleChoiceField, initial is a list/queryset of model instances
374
+ initial_options.extend(SafeOption(
375
+ label=f.label_from_instance(obj),
376
+ value=str(obj.pk),
377
+ ) for obj in initial_value)
378
+ elif isinstance(f, forms.MultipleChoiceField):
379
+ # For MultipleChoiceField, initial is a list of choice values
380
+ for val in initial_value:
381
+ choice_label = str(next(c[1] for c in f.choices if c[0] == val))
382
+ initial_options.append(
383
+ SafeOption(label=choice_label, value=str(val))
384
+ )
385
+
386
+ if initial_options:
387
+ slack_input_kwargs["initial_options"] = initial_options
388
+
389
+ # Build all options
390
+ slack_input_kwargs["options"] = [
391
+ SafeOption(label=str(c[1]), value=str(c[0]))
392
+ for c in filter(lambda co: co[0] != "", f.choices)
393
+ ]
394
+
395
+ # Ensure we have at least one option for Slack API
396
+ if not slack_input_kwargs["options"]:
397
+ slack_input_kwargs["options"] = [
398
+ SafeOption(label="Please select an option", value="__placeholder__")
399
+ ]
400
+
401
+ return StaticMultiSelectElement(action_id=field_name, **slack_input_kwargs)
402
+
343
403
  @classmethod
344
404
  def _process_model_user_field(
345
405
  cls,
@@ -401,6 +461,15 @@ class SlackForm(Generic[T]):
401
461
  OptionGroup(label=str(group_name), options=subgroup)
402
462
  )
403
463
 
464
+ # Ensure we have at least one option group for Slack API
465
+ if not slack_input_kwargs["option_groups"]:
466
+ slack_input_kwargs["option_groups"] = [
467
+ OptionGroup(
468
+ label="No options available",
469
+ options=[SafeOption(label="Please select an option", value="__placeholder__")]
470
+ )
471
+ ]
472
+
404
473
  return SelectElement(action_id=field_name, **slack_input_kwargs)
405
474
 
406
475
  @classmethod
@@ -512,6 +581,15 @@ def slack_view_submission_to_dict(
512
581
  )
513
582
  elif input_field.get("type") == "static_select":
514
583
  data[action_id_stripped] = get_in(input_field, ["selected_option", "value"])
584
+ elif input_field.get("type") == "multi_static_select":
585
+ # Handle multiple selections - return list of values
586
+ selected_options = input_field.get("selected_options", [])
587
+ data[action_id_stripped] = [opt.get("value") for opt in selected_options]
588
+ elif input_field.get("type") == "checkboxes":
589
+ # Handle checkboxes (BooleanField) - return True if "True" is in selected_options
590
+ selected_options = input_field.get("selected_options", [])
591
+ # For BooleanField, we have only one option with value="True"
592
+ data[action_id_stripped] = any(opt.get("value") == "True" for opt in selected_options)
515
593
  elif input_field.get("type") == "users_select":
516
594
  user_id = get_in(
517
595
  input_field,
@@ -25,6 +25,10 @@ from firefighter.slack.views.modals.base_modal.mixins import (
25
25
  )
26
26
  from firefighter.slack.views.modals.postmortem import PostMortemModal
27
27
  from firefighter.slack.views.modals.update_status import UpdateStatusModal
28
+ from firefighter.slack.views.modals.utils import (
29
+ get_close_modal_view,
30
+ handle_close_modal_callback,
31
+ )
28
32
 
29
33
  if TYPE_CHECKING:
30
34
  from slack_bolt.context.ack.ack import Ack
@@ -77,6 +81,11 @@ class CloseModal(
77
81
  def build_modal_fn(
78
82
  self, body: dict[str, Any], incident: Incident, **kwargs: Any
79
83
  ) -> View:
84
+ # Check if closure reason modal should be shown instead
85
+ closure_view = get_close_modal_view(body, incident, **kwargs)
86
+ if closure_view:
87
+ return closure_view
88
+
80
89
  can_be_closed, reasons = incident.can_be_closed
81
90
  if not can_be_closed:
82
91
  reason_blocks: list[Block] = []
@@ -142,12 +151,12 @@ class CloseModal(
142
151
  elif reason[0] == "STATUS_NOT_MITIGATED":
143
152
  reason_blocks += [
144
153
  SectionBlock(
145
- text=f":warning: *Status is not _{IncidentStatus.FIXED.label}_* :warning:\n"
154
+ text=f":warning: *Status is not _{IncidentStatus.MITIGATED.label}_* :warning:\n"
146
155
  ),
147
156
  ContextBlock(
148
157
  elements=[
149
158
  MarkdownTextObject(
150
- text=f"You can only close an incident when its status is _{IncidentStatus.FIXED.label}_ or _{IncidentStatus.POST_MORTEM.label}_. The _{IncidentStatus.POST_MORTEM.label}_ status is not mandatory for this incident."
159
+ text=f"You can only close an incident when its status is _{IncidentStatus.MITIGATED.label}_ or _{IncidentStatus.POST_MORTEM.label}_. The _{IncidentStatus.POST_MORTEM.label}_ status is not mandatory for this incident."
151
160
  )
152
161
  ]
153
162
  ),
@@ -220,6 +229,10 @@ class CloseModal(
220
229
  self, ack: Ack, body: dict[str, Any], incident: Incident, user: User
221
230
  ) -> bool | None:
222
231
  """Handle response from /incident close modal."""
232
+ # Check if this should be handled by closure reason modal
233
+ closure_result = handle_close_modal_callback(ack, body, incident, user)
234
+ if closure_result is not None:
235
+ return closure_result
223
236
  slack_form = self.handle_form_errors(
224
237
  ack, body, forms_kwargs={"initial": self._get_initial_form_values(incident)}
225
238
  )
@@ -229,8 +242,8 @@ class CloseModal(
229
242
  # If fields haven't changed, don't include them in the update.
230
243
  update_kwargs = {}
231
244
  for changed_key in form.changed_data:
232
- if changed_key == "component":
233
- update_kwargs["component_id"] = form.cleaned_data[changed_key].id
245
+ if changed_key == "incident_category":
246
+ update_kwargs["incident_category_id"] = form.cleaned_data[changed_key].id
234
247
  if changed_key in {"description", "title", "message"}:
235
248
  update_kwargs[changed_key] = form.cleaned_data[changed_key]
236
249
  # Check can close
@@ -258,7 +271,7 @@ class CloseModal(
258
271
  return {
259
272
  "title": incident.title,
260
273
  "description": incident.description,
261
- "component": incident.component,
274
+ "incident_category": incident.incident_category,
262
275
  }
263
276
 
264
277
  @staticmethod
@@ -0,0 +1,193 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from slack_sdk.errors import SlackApiError
7
+ from slack_sdk.models.blocks.basic_components import MarkdownTextObject, Option
8
+ from slack_sdk.models.blocks.block_elements import (
9
+ PlainTextInputElement,
10
+ StaticSelectElement,
11
+ )
12
+ from slack_sdk.models.blocks.blocks import (
13
+ Block,
14
+ ContextBlock,
15
+ DividerBlock,
16
+ InputBlock,
17
+ SectionBlock,
18
+ )
19
+ from slack_sdk.models.views import View
20
+
21
+ from firefighter.incidents.enums import ClosureReason, IncidentStatus
22
+ from firefighter.slack.slack_templating import slack_block_footer, slack_block_separator
23
+ from firefighter.slack.utils import respond
24
+ from firefighter.slack.views.modals.base_modal.base import SlackModal
25
+ from firefighter.slack.views.modals.base_modal.mixins import (
26
+ IncidentSelectableModalMixin,
27
+ )
28
+
29
+ if TYPE_CHECKING:
30
+ from slack_bolt.context.ack.ack import Ack
31
+
32
+ from firefighter.incidents.models.incident import Incident, User
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+
37
+ class ClosureReasonModal(IncidentSelectableModalMixin, SlackModal):
38
+ """Modal for closing an incident with a mandatory reason from early statuses."""
39
+
40
+ open_action: str = "closure_reason_incident"
41
+ open_shortcut = "closure_reason_incident"
42
+ callback_id: str = "incident_closure_reason"
43
+
44
+ def build_modal_fn(
45
+ self, body: dict[str, Any], incident: Incident, **kwargs: Any # noqa: ARG002
46
+ ) -> View:
47
+ """Build the closure reason modal."""
48
+ # Build closure reason options (exclude RESOLVED)
49
+ closure_options = [
50
+ Option(
51
+ value=choice[0],
52
+ label=choice[1],
53
+ )
54
+ for choice in ClosureReason.choices
55
+ if choice[0] != ClosureReason.RESOLVED
56
+ ]
57
+
58
+ blocks: list[Block] = [
59
+ SectionBlock(
60
+ text=f"*Closure Reason Required for Incident #{incident.id}*\n_{incident.title}_"
61
+ ),
62
+ ContextBlock(
63
+ elements=[
64
+ MarkdownTextObject(
65
+ text=f"⚠️ This incident is currently in *{incident.status.label}* status.\nA closure reason is required to close incidents from this status."
66
+ )
67
+ ]
68
+ ),
69
+ DividerBlock(),
70
+ InputBlock(
71
+ block_id="closure_reason",
72
+ label="Closure Reason",
73
+ element=StaticSelectElement(
74
+ action_id="select_closure_reason",
75
+ options=closure_options,
76
+ placeholder="Select a reason...",
77
+ ),
78
+ hint="Why is this incident being closed directly?",
79
+ ),
80
+ InputBlock(
81
+ block_id="closure_reference",
82
+ label="Reference (optional)",
83
+ element=PlainTextInputElement(
84
+ action_id="input_closure_reference",
85
+ placeholder="e.g., #1234 or https://...",
86
+ max_length=100,
87
+ ),
88
+ hint="Related incident ID or external reference",
89
+ optional=True,
90
+ ),
91
+ InputBlock(
92
+ block_id="closure_message",
93
+ label="Closure Message",
94
+ element=PlainTextInputElement(
95
+ action_id="input_closure_message",
96
+ multiline=True,
97
+ placeholder="Brief explanation of why this incident is being closed...",
98
+ ),
99
+ hint="This message will be added to the incident timeline",
100
+ ),
101
+ slack_block_separator(),
102
+ slack_block_footer(),
103
+ ]
104
+
105
+ return View(
106
+ type="modal",
107
+ title=f"Close #{incident.id}"[:24],
108
+ submit="Close Incident",
109
+ callback_id=self.callback_id,
110
+ private_metadata=str(incident.id),
111
+ blocks=blocks,
112
+ )
113
+
114
+ def handle_modal_fn( # type: ignore[override]
115
+ self, ack: Ack, body: dict[str, Any], incident: Incident, user: User
116
+ ) -> bool | None:
117
+ """Handle the closure reason modal submission."""
118
+ # Clear ALL modals in the stack (not just this one)
119
+ # This ensures the underlying "Update Status" modal is also closed
120
+ ack(response_action="clear")
121
+
122
+ # Extract form values
123
+ state_values = body["view"]["state"]["values"]
124
+ closure_reason = state_values["closure_reason"]["select_closure_reason"][
125
+ "selected_option"
126
+ ]["value"]
127
+ closure_reference = (
128
+ state_values["closure_reference"]["input_closure_reference"].get("value", "")
129
+ or ""
130
+ )
131
+ message = state_values["closure_message"]["input_closure_message"]["value"]
132
+
133
+ try:
134
+ # Update incident with closure fields
135
+ incident.closure_reason = closure_reason
136
+ incident.closure_reference = closure_reference
137
+ incident.save(update_fields=["closure_reason", "closure_reference"])
138
+
139
+ # Create incident update with closure
140
+ incident.create_incident_update(
141
+ created_by=user,
142
+ status=IncidentStatus.CLOSED,
143
+ message=message,
144
+ event_type="closure_reason",
145
+ )
146
+
147
+ except Exception:
148
+ logger.exception(
149
+ "Error closing incident #%s with reason", incident.id
150
+ )
151
+ respond(
152
+ body=body,
153
+ text=f"❌ Failed to close incident #{incident.id}",
154
+ )
155
+ return False
156
+ else:
157
+ # Send confirmation message
158
+ try:
159
+ respond(
160
+ body=body,
161
+ text=(
162
+ f"✅ Incident #{incident.id} has been closed.\n"
163
+ f"*Reason:* {ClosureReason(closure_reason).label}\n"
164
+ f"*Message:* {message}"
165
+ + (f"\n*Reference:* {closure_reference}" if closure_reference else "")
166
+ ),
167
+ )
168
+ except SlackApiError as e:
169
+ if e.response.get("error") == "messages_tab_disabled":
170
+ logger.warning(
171
+ "Cannot send DM to user %s - messages tab disabled",
172
+ user.email,
173
+ )
174
+ else:
175
+ # Re-raise for other Slack API errors
176
+ raise
177
+
178
+ logger.info(
179
+ "Incident #%s closed with reason by %s: %s",
180
+ incident.id,
181
+ user.email,
182
+ closure_reason,
183
+ )
184
+ return True
185
+
186
+ def get_select_modal_title(self) -> str:
187
+ return "Close with Reason"
188
+
189
+ def get_select_title(self) -> str:
190
+ return "Select an incident to close with a specific reason"
191
+
192
+
193
+ modal_closure_reason = ClosureReasonModal()
@@ -33,7 +33,7 @@ from firefighter.slack.views.modals.base_modal.modal_utils import update_modal
33
33
  from firefighter.slack.views.modals.opening.check_current_incidents import (
34
34
  CheckCurrentIncidentsModal,
35
35
  )
36
- from firefighter.slack.views.modals.opening.details.critical import OpeningCriticalModal
36
+ from firefighter.slack.views.modals.opening.details.unified import OpeningUnifiedModal
37
37
  from firefighter.slack.views.modals.opening.types import OpeningData, ResponseType
38
38
 
39
39
  if TYPE_CHECKING:
@@ -53,7 +53,13 @@ INCIDENT_TYPES: dict[ResponseType, dict[str, dict[str, Any]]] = {
53
53
  "critical": {
54
54
  "critical": {
55
55
  "label": "Critical",
56
- "slack_form": OpeningCriticalModal,
56
+ "slack_form": OpeningUnifiedModal,
57
+ },
58
+ },
59
+ "normal": {
60
+ "normal": {
61
+ "label": "Normal",
62
+ "slack_form": OpeningUnifiedModal, # Will be overridden by RAID app
57
63
  },
58
64
  },
59
65
  }
@@ -224,6 +230,20 @@ class OpenModal(SlackModal):
224
230
  ) -> list[Block]:
225
231
  if details_form_modal_class is None:
226
232
  return []
233
+
234
+ # Check if we need incident type selection and if it's been done
235
+ response_type = open_incident_context.get("response_type")
236
+ incident_type_value = open_incident_context.get("incident_type")
237
+
238
+ # If there are multiple incident types and none is selected, don't show details button yet
239
+ if (
240
+ response_type
241
+ and response_type in INCIDENT_TYPES
242
+ and len(INCIDENT_TYPES[response_type]) > 1
243
+ and incident_type_value is None
244
+ ):
245
+ return []
246
+
227
247
  return [
228
248
  SectionBlock(
229
249
  text=f"{'✅' if details_form_done else '📝'} Finally, add incident details",
@@ -290,17 +310,34 @@ class OpenModal(SlackModal):
290
310
  if open_incident_context.get("response_type") == "critical":
291
311
  slack_msg = None
292
312
  if details_form_done and details_form_class and details_form:
313
+ # Create a copy of cleaned_data to avoid modifying the form
314
+ cleaned_data_copy = details_form.cleaned_data.copy()
315
+
316
+ # Extract first environment from QuerySet (UnifiedIncidentForm uses ModelMultipleChoiceField)
317
+ environments = cleaned_data_copy.pop("environment", [])
318
+ if environments:
319
+ cleaned_data_copy["environment"] = environments[0] if hasattr(environments, "__getitem__") else environments.first()
320
+
321
+ # Remove fields that are not part of Incident model
322
+ cleaned_data_copy.pop("platform", None)
323
+ cleaned_data_copy.pop("zendesk_ticket_id", None)
324
+ cleaned_data_copy.pop("seller_contract_id", None)
325
+ cleaned_data_copy.pop("zoho_desk_ticket_id", None)
326
+ cleaned_data_copy.pop("is_key_account", None)
327
+ cleaned_data_copy.pop("is_seller_in_golden_list", None)
328
+ cleaned_data_copy.pop("suggested_team_routing", None)
329
+
293
330
  incident = Incident(
294
331
  status=IncidentStatus.OPEN, # type: ignore
295
332
  created_by=user,
296
- **details_form.cleaned_data,
333
+ **cleaned_data_copy,
297
334
  )
298
335
  users_list: set[User] = {*incident.build_invite_list(), user}
299
336
  slack_msg = f"> :slack: A dedicated Slack channel will be created, and around {len(users_list)} responders will be invited to help.\n"
300
337
 
301
338
  if slack_msg is None:
302
339
  slack_msg = "> :slack: A dedicated Slack channel will be created, and responders will be invited to help.\n"
303
- text = "> :jira_new: An associated Jira ticket will also be created."
340
+ text = slack_msg + "> :jira_new: An associated Jira ticket will also be created."
304
341
  if not is_during_office_hours(timezone.now()):
305
342
  text += "\n> :pagerduty: If you need it, you'll be able to escalate the incident to our 24/7 on-call response teams."
306
343
  else:
@@ -347,7 +384,9 @@ class OpenModal(SlackModal):
347
384
  details_form_class,
348
385
  details_form,
349
386
  ) = self._validate_details_form(
350
- details_form_modal_class, open_incident_context["details_form_data"]
387
+ details_form_modal_class,
388
+ open_incident_context["details_form_data"],
389
+ open_incident_context,
351
390
  )
352
391
 
353
392
  return (
@@ -361,6 +400,7 @@ class OpenModal(SlackModal):
361
400
  def _validate_details_form(
362
401
  details_form_modal_class: type[SetIncidentDetails[Any]] | None,
363
402
  details_form_data: dict[str, Any],
403
+ open_incident_context: OpeningData,
364
404
  ) -> tuple[
365
405
  bool, type[CreateIncidentFormBase] | None, CreateIncidentFormBase | None
366
406
  ]:
@@ -374,7 +414,19 @@ class OpenModal(SlackModal):
374
414
  if not details_form_class:
375
415
  return False, None, None
376
416
 
377
- details_form: CreateIncidentFormBase = details_form_class(details_form_data)
417
+ # Pass impacts_data and response_type to form if it supports them (UnifiedIncidentForm)
418
+ # Check if __init__ accepts these parameters
419
+ import inspect # noqa: PLC0415
420
+ init_params = inspect.signature(details_form_class.__init__).parameters
421
+ form_kwargs: dict[str, Any] = {}
422
+ if "impacts_data" in init_params:
423
+ form_kwargs["impacts_data"] = open_incident_context.get("impact_form_data") or {}
424
+ if "response_type" in init_params:
425
+ form_kwargs["response_type"] = open_incident_context.get("response_type", "critical")
426
+
427
+ details_form: CreateIncidentFormBase = details_form_class(
428
+ details_form_data, **form_kwargs
429
+ )
378
430
  is_valid = details_form.is_valid()
379
431
 
380
432
  return is_valid, details_form_class, details_form
@@ -510,7 +562,8 @@ class OpenModal(SlackModal):
510
562
  """Format a single impact value into description text."""
511
563
  # Handle object with name and description attributes (impact levels)
512
564
  if hasattr(value, "name") and hasattr(value, "description"):
513
- if value.name == "NO" or not value.description:
565
+ # Filter out "no impact" levels using the value field instead of name
566
+ if (hasattr(value, "value") and value.value == "NO") or value.name == "NO" or not value.description:
514
567
  return ""
515
568
 
516
569
  description = ""
@@ -548,6 +601,9 @@ class OpenModal(SlackModal):
548
601
  return incident_types[next(iter(incident_types.keys()))].get("slack_form")
549
602
  if incident_types and incident_type_value is not None:
550
603
  return incident_types[incident_type_value].get("slack_form")
604
+ # Fallback for "normal" response type when no specific incident type is selected
605
+ if response_type == "normal" and incident_types:
606
+ return incident_types[next(iter(incident_types.keys()))].get("slack_form")
551
607
  logger.debug(
552
608
  f"No incident type found for {open_incident_context}. No fallback."
553
609
  )
@@ -574,8 +630,17 @@ class OpenModal(SlackModal):
574
630
  details_form_modal_class.form_class
575
631
  )
576
632
  if details_form_class:
633
+ # Pass impacts_data and response_type to form if it supports them (UnifiedIncidentForm)
634
+ import inspect # noqa: PLC0415
635
+ init_params = inspect.signature(details_form_class.__init__).parameters
636
+ form_kwargs: dict[str, Any] = {}
637
+ if "impacts_data" in init_params:
638
+ form_kwargs["impacts_data"] = data.get("impact_form_data") or {}
639
+ if "response_type" in init_params:
640
+ form_kwargs["response_type"] = data.get("response_type", "critical")
641
+
577
642
  details_form: CreateIncidentFormBase = details_form_class(
578
- details_form_data_raw
643
+ details_form_data_raw, **form_kwargs
579
644
  )
580
645
  details_form.is_valid()
581
646
  ack()
@@ -583,10 +648,16 @@ class OpenModal(SlackModal):
583
648
  if hasattr(details_form, "trigger_incident_workflow") and callable(
584
649
  details_form.trigger_incident_workflow
585
650
  ):
586
- details_form.trigger_incident_workflow(
587
- creator=user,
588
- impacts_data=data.get("impact_form_data") or {},
589
- )
651
+ # Pass response_type to trigger_incident_workflow if it accepts it
652
+ trigger_params = inspect.signature(details_form.trigger_incident_workflow).parameters
653
+ workflow_kwargs: dict[str, Any] = {
654
+ "creator": user,
655
+ "impacts_data": data.get("impact_form_data") or {},
656
+ }
657
+ if "response_type" in trigger_params:
658
+ workflow_kwargs["response_type"] = data.get("response_type", "critical")
659
+
660
+ details_form.trigger_incident_workflow(**workflow_kwargs)
590
661
  except: # noqa: E722
591
662
  logger.exception("Error triggering incident workflow")
592
663
  # XXX warn the user via DM!