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
@@ -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)
@@ -346,6 +351,55 @@ class SlackForm(Generic[T]):
346
351
  field_name = field_name[:254]
347
352
  return SelectElement(action_id=field_name, **slack_input_kwargs)
348
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
+
349
403
  @classmethod
350
404
  def _process_model_user_field(
351
405
  cls,
@@ -527,6 +581,15 @@ def slack_view_submission_to_dict(
527
581
  )
528
582
  elif input_field.get("type") == "static_select":
529
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)
530
593
  elif input_field.get("type") == "users_select":
531
594
  user_id = get_in(
532
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
  )
@@ -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,13 +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
57
  },
58
58
  },
59
59
  "normal": {
60
60
  "normal": {
61
61
  "label": "Normal",
62
- "slack_form": OpeningCriticalModal,
62
+ "slack_form": OpeningUnifiedModal, # Will be overridden by RAID app
63
63
  },
64
64
  },
65
65
  }
@@ -310,17 +310,34 @@ class OpenModal(SlackModal):
310
310
  if open_incident_context.get("response_type") == "critical":
311
311
  slack_msg = None
312
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
+
313
330
  incident = Incident(
314
331
  status=IncidentStatus.OPEN, # type: ignore
315
332
  created_by=user,
316
- **details_form.cleaned_data,
333
+ **cleaned_data_copy,
317
334
  )
318
335
  users_list: set[User] = {*incident.build_invite_list(), user}
319
336
  slack_msg = f"> :slack: A dedicated Slack channel will be created, and around {len(users_list)} responders will be invited to help.\n"
320
337
 
321
338
  if slack_msg is None:
322
339
  slack_msg = "> :slack: A dedicated Slack channel will be created, and responders will be invited to help.\n"
323
- 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."
324
341
  if not is_during_office_hours(timezone.now()):
325
342
  text += "\n> :pagerduty: If you need it, you'll be able to escalate the incident to our 24/7 on-call response teams."
326
343
  else:
@@ -367,7 +384,9 @@ class OpenModal(SlackModal):
367
384
  details_form_class,
368
385
  details_form,
369
386
  ) = self._validate_details_form(
370
- 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,
371
390
  )
372
391
 
373
392
  return (
@@ -381,6 +400,7 @@ class OpenModal(SlackModal):
381
400
  def _validate_details_form(
382
401
  details_form_modal_class: type[SetIncidentDetails[Any]] | None,
383
402
  details_form_data: dict[str, Any],
403
+ open_incident_context: OpeningData,
384
404
  ) -> tuple[
385
405
  bool, type[CreateIncidentFormBase] | None, CreateIncidentFormBase | None
386
406
  ]:
@@ -394,7 +414,19 @@ class OpenModal(SlackModal):
394
414
  if not details_form_class:
395
415
  return False, None, None
396
416
 
397
- 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
+ )
398
430
  is_valid = details_form.is_valid()
399
431
 
400
432
  return is_valid, details_form_class, details_form
@@ -598,8 +630,17 @@ class OpenModal(SlackModal):
598
630
  details_form_modal_class.form_class
599
631
  )
600
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
+
601
642
  details_form: CreateIncidentFormBase = details_form_class(
602
- details_form_data_raw
643
+ details_form_data_raw, **form_kwargs
603
644
  )
604
645
  details_form.is_valid()
605
646
  ack()
@@ -607,10 +648,16 @@ class OpenModal(SlackModal):
607
648
  if hasattr(details_form, "trigger_incident_workflow") and callable(
608
649
  details_form.trigger_incident_workflow
609
650
  ):
610
- details_form.trigger_incident_workflow(
611
- creator=user,
612
- impacts_data=data.get("impact_form_data") or {},
613
- )
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)
614
661
  except: # noqa: E722
615
662
  logger.exception("Error triggering incident workflow")
616
663
  # XXX warn the user via DM!
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from django import forms
7
+ from django.conf import settings
8
+ from slack_sdk.models.blocks.basic_components import MarkdownTextObject
9
+ from slack_sdk.models.blocks.blocks import ContextBlock, SectionBlock
10
+
11
+ from firefighter.incidents.forms.unified_incident import UnifiedIncidentForm
12
+ from firefighter.slack.views.modals.base_modal.form_utils import SlackForm
13
+ from firefighter.slack.views.modals.opening.set_details import SetIncidentDetails
14
+ from firefighter.slack.views.modals.opening.types import OpeningData
15
+
16
+ if TYPE_CHECKING:
17
+ from firefighter.slack.views.modals.base_modal.form_utils import (
18
+ SlackFormAttributesDict,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class UnifiedIncidentFormSlack(UnifiedIncidentForm):
25
+ """Slack version of UnifiedIncidentForm with Slack-specific field configurations."""
26
+
27
+ slack_fields: SlackFormAttributesDict = {
28
+ "title": {
29
+ "input": {
30
+ "multiline": False,
31
+ "placeholder": "Short, punchy description of what's happening.",
32
+ },
33
+ "block": {"hint": None},
34
+ },
35
+ "description": {
36
+ "input": {
37
+ "multiline": True,
38
+ "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.",
39
+ },
40
+ "block": {"hint": None},
41
+ },
42
+ "environment": {
43
+ "input": {
44
+ "placeholder": "Select environments",
45
+ },
46
+ },
47
+ "platform": {
48
+ "input": {
49
+ "placeholder": "Select platforms",
50
+ },
51
+ },
52
+ "priority": {
53
+ "input": {
54
+ "placeholder": "Select a priority",
55
+ },
56
+ "widget": {
57
+ "post_block": (
58
+ SectionBlock(
59
+ text=f"_<{settings.SLACK_SEVERITY_HELP_GUIDE_URL}|How to choose the priority?>_"
60
+ )
61
+ if settings.SLACK_SEVERITY_HELP_GUIDE_URL
62
+ else None
63
+ ),
64
+ "label_from_instance": lambda obj: f"{obj.emoji} {obj.name} - {obj.description}",
65
+ },
66
+ },
67
+ "incident_category": {
68
+ "input": {
69
+ "placeholder": "Select affected issue category",
70
+ },
71
+ },
72
+ "suggested_team_routing": {
73
+ "input": {
74
+ "placeholder": "Select feature team or train",
75
+ },
76
+ "widget": {
77
+ "post_block": ContextBlock(
78
+ elements=[
79
+ MarkdownTextObject(
80
+ text="Feature Team or Train that should own the issue. If you don't know access <https://manomano.atlassian.net/wiki/spaces/QRAFT/pages/3970335291/Teams+and+owners|here> for guidance."
81
+ ),
82
+ ]
83
+ )
84
+ },
85
+ },
86
+ "zendesk_ticket_id": {
87
+ "input": {
88
+ "placeholder": "Zendesk ticket ID (if applicable)",
89
+ },
90
+ "block": {
91
+ "hint": "Link this incident to an existing Zendesk customer ticket"
92
+ },
93
+ },
94
+ "seller_contract_id": {
95
+ "input": {
96
+ "placeholder": "Seller contract ID",
97
+ },
98
+ },
99
+ "zoho_desk_ticket_id": {
100
+ "input": {
101
+ "placeholder": "Zoho Desk ticket ID (if applicable)",
102
+ },
103
+ "block": {
104
+ "hint": "Link this incident to an existing Zoho Desk seller ticket"
105
+ },
106
+ },
107
+ }
108
+
109
+ def __init__(
110
+ self,
111
+ *args: Any,
112
+ impacts_data: dict[str, Any] | None = None,
113
+ response_type: str = "critical",
114
+ **kwargs: Any,
115
+ ) -> None:
116
+ """Initialize form with impact-based field visibility.
117
+
118
+ Args:
119
+ *args: Positional arguments passed to parent form
120
+ impacts_data: Dictionary of impact selections
121
+ response_type: "critical" or "normal"
122
+ **kwargs: Keyword arguments passed to parent form
123
+ """
124
+ super().__init__(*args, **kwargs)
125
+
126
+ # Store for later use
127
+ self._impacts_data = impacts_data or {}
128
+ self._response_type = response_type
129
+
130
+ # Make priority field hidden
131
+ self.fields["priority"].widget = forms.HiddenInput()
132
+
133
+ # Conditionally hide fields based on impacts and response_type
134
+ self._configure_field_visibility()
135
+
136
+ def _configure_field_visibility(self) -> None:
137
+ """Configure which fields should be visible based on impacts and response type."""
138
+ visible_fields = self.get_visible_fields_for_impacts(
139
+ self._impacts_data, self._response_type
140
+ )
141
+
142
+ # Remove fields that shouldn't be visible
143
+ fields_to_remove = [
144
+ field_name for field_name in self.fields if field_name not in visible_fields
145
+ ]
146
+
147
+ for field_name in fields_to_remove:
148
+ del self.fields[field_name]
149
+
150
+
151
+ class OpeningUnifiedModal(SetIncidentDetails[UnifiedIncidentFormSlack]):
152
+ """Unified modal for all incident types (P1-P5)."""
153
+
154
+ open_action: str = "open_incident_unified"
155
+ push_action: str = "push_incident_unified"
156
+ callback_id: str = "open_incident_unified"
157
+ id = "incident_unified"
158
+
159
+ title = "Incident Details"
160
+ form_class = UnifiedIncidentFormSlack
161
+
162
+ def get_form_class(self) -> Any:
163
+ """Return a SlackForm wrapper that passes impacts_data and response_type."""
164
+ form_class = self.form_class
165
+
166
+ # Create a custom form class that accepts our parameters
167
+ class ContextAwareForm(form_class): # type: ignore
168
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
169
+ # Extract context from kwargs
170
+ open_incident_context = kwargs.pop("open_incident_context", None)
171
+ if open_incident_context:
172
+ kwargs["impacts_data"] = open_incident_context.get("impact_form_data", {})
173
+ kwargs["response_type"] = open_incident_context.get("response_type", "critical")
174
+ super().__init__(*args, **kwargs)
175
+
176
+ # Return SlackForm wrapping the context-aware form
177
+ return SlackForm(ContextAwareForm)
178
+
179
+ def build_modal_fn(
180
+ self, open_incident_context: OpeningData | None = None, **kwargs: Any
181
+ ) -> Any:
182
+ """Build modal with impact-aware form."""
183
+ # Extract impacts and response type from context
184
+ if open_incident_context is None:
185
+ open_incident_context = OpeningData()
186
+
187
+ response_type = open_incident_context.get("response_type", "critical")
188
+
189
+ # Store in initial data so form can access it
190
+ details_form_data = open_incident_context.get("details_form_data")
191
+ if details_form_data is None:
192
+ details_form_data = {}
193
+ details_form_data["response_type"] = response_type
194
+
195
+ # Update context
196
+ open_incident_context["details_form_data"] = details_form_data
197
+
198
+ # Store context for get_form_class in kwargs
199
+ # Call parent build_modal_fn with open_incident_context in kwargs
200
+ return super().build_modal_fn(open_incident_context=open_incident_context, **kwargs)
201
+
202
+
203
+ modal_opening_unified = OpeningUnifiedModal()