firefighter-incident 0.0.14__py3-none-any.whl → 0.0.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- firefighter/_version.py +2 -2
- firefighter/api/serializers.py +9 -0
- firefighter/confluence/signals/incident_updated.py +2 -2
- firefighter/incidents/enums.py +22 -2
- firefighter/incidents/forms/closure_reason.py +45 -0
- firefighter/incidents/forms/unified_incident.py +406 -0
- firefighter/incidents/forms/update_status.py +87 -1
- firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
- firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
- firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
- firefighter/incidents/models/incident.py +32 -5
- firefighter/incidents/static/css/main.min.css +1 -1
- firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
- firefighter/incidents/views/reports.py +3 -3
- firefighter/raid/apps.py +9 -26
- firefighter/raid/client.py +2 -2
- firefighter/raid/forms.py +75 -238
- firefighter/raid/signals/incident_created.py +38 -13
- firefighter/raid/signals/incident_updated.py +3 -2
- firefighter/slack/messages/slack_messages.py +19 -4
- firefighter/slack/rules.py +1 -1
- firefighter/slack/signals/create_incident_conversation.py +6 -0
- firefighter/slack/signals/incident_updated.py +7 -1
- firefighter/slack/views/modals/__init__.py +4 -0
- firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
- firefighter/slack/views/modals/close.py +15 -2
- firefighter/slack/views/modals/closure_reason.py +193 -0
- firefighter/slack/views/modals/open.py +59 -12
- firefighter/slack/views/modals/opening/details/unified.py +203 -0
- firefighter/slack/views/modals/opening/set_details.py +3 -2
- firefighter/slack/views/modals/postmortem.py +10 -2
- firefighter/slack/views/modals/update_status.py +28 -2
- firefighter/slack/views/modals/utils.py +51 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +61 -37
- firefighter_tests/test_incidents/test_enums.py +100 -0
- firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
- firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
- firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
- firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
- firefighter_tests/test_raid/conftest.py +154 -0
- firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
- firefighter_tests/test_raid/test_raid_forms.py +10 -253
- firefighter_tests/test_raid/test_raid_signals.py +187 -0
- firefighter_tests/test_slack/messages/__init__.py +0 -0
- firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
- firefighter_tests/test_slack/views/modals/conftest.py +140 -0
- firefighter_tests/test_slack/views/modals/test_close.py +65 -3
- firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
- firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
- firefighter_tests/test_slack/views/modals/test_open.py +146 -2
- firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
- firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
- firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
- firefighter/raid/views/open_normal.py +0 -139
- firefighter/slack/views/modals/opening/details/critical.py +0 -88
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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":
|
|
56
|
+
"slack_form": OpeningUnifiedModal,
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
"normal": {
|
|
60
60
|
"normal": {
|
|
61
61
|
"label": "Normal",
|
|
62
|
-
"slack_form":
|
|
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
|
-
**
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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()
|