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.
- firefighter/_version.py +16 -3
- firefighter/api/serializers.py +17 -8
- firefighter/api/urls.py +8 -1
- firefighter/api/views/_base.py +1 -1
- firefighter/api/views/components.py +5 -5
- firefighter/api/views/incidents.py +9 -9
- firefighter/confluence/signals/incident_updated.py +2 -2
- firefighter/firefighter/settings/components/raid.py +3 -0
- firefighter/incidents/admin.py +24 -24
- firefighter/incidents/enums.py +22 -2
- firefighter/incidents/factories.py +14 -5
- firefighter/incidents/forms/close_incident.py +4 -4
- firefighter/incidents/forms/closure_reason.py +45 -0
- firefighter/incidents/forms/create_incident.py +4 -4
- firefighter/incidents/forms/unified_incident.py +406 -0
- firefighter/incidents/forms/update_status.py +91 -5
- firefighter/incidents/menus.py +2 -2
- firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
- firefighter/incidents/migrations/0009_update_sla.py +7 -5
- firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
- firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
- firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
- firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
- firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
- firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
- firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
- 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/__init__.py +1 -1
- firefighter/incidents/models/group.py +1 -1
- firefighter/incidents/models/incident.py +47 -20
- firefighter/incidents/models/{component.py → incident_category.py} +30 -29
- firefighter/incidents/models/incident_update.py +3 -3
- firefighter/incidents/static/css/main.min.css +1 -1
- firefighter/incidents/tables.py +9 -9
- firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
- firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
- firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
- firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
- firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
- firefighter/incidents/templates/pages/incident_detail.html +3 -3
- firefighter/incidents/urls.py +6 -6
- firefighter/incidents/views/components/details.py +9 -9
- firefighter/incidents/views/components/list.py +9 -9
- firefighter/incidents/views/reports.py +5 -5
- firefighter/incidents/views/users/details.py +2 -2
- firefighter/incidents/views/views.py +7 -7
- firefighter/jira_app/client.py +1 -1
- firefighter/logging/custom_json_formatter.py +2 -1
- firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
- firefighter/raid/admin.py +0 -11
- firefighter/raid/apps.py +9 -26
- firefighter/raid/client.py +5 -5
- firefighter/raid/forms.py +84 -213
- firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
- firefighter/raid/models.py +2 -21
- firefighter/raid/serializers.py +5 -4
- firefighter/raid/service.py +29 -27
- firefighter/raid/signals/incident_created.py +42 -15
- firefighter/raid/signals/incident_updated.py +3 -2
- firefighter/raid/utils.py +1 -1
- firefighter/raid/views/__init__.py +1 -1
- firefighter/slack/admin.py +8 -8
- firefighter/slack/management/commands/switch_test_users.py +272 -0
- firefighter/slack/messages/slack_messages.py +24 -9
- firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
- firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
- firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
- firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
- firefighter/slack/models/conversation.py +3 -3
- firefighter/slack/models/incident_channel.py +1 -1
- firefighter/slack/models/user.py +1 -1
- firefighter/slack/models/user_group.py +3 -3
- firefighter/slack/rules.py +2 -2
- firefighter/slack/signals/create_incident_conversation.py +6 -0
- firefighter/slack/signals/get_users.py +2 -2
- firefighter/slack/signals/incident_updated.py +8 -2
- firefighter/slack/utils.py +2 -2
- firefighter/slack/views/events/home.py +2 -2
- firefighter/slack/views/modals/__init__.py +4 -0
- firefighter/slack/views/modals/base_modal/form_utils.py +78 -0
- firefighter/slack/views/modals/close.py +18 -5
- firefighter/slack/views/modals/closure_reason.py +193 -0
- firefighter/slack/views/modals/open.py +83 -12
- firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
- firefighter/slack/views/modals/opening/details/unified.py +203 -0
- firefighter/slack/views/modals/opening/select_impact.py +5 -2
- 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 +32 -6
- firefighter/slack/views/modals/utils.py +51 -0
- firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +2 -2
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +133 -88
- firefighter_tests/conftest.py +4 -5
- firefighter_tests/test_api/test_api_landbot.py +1 -1
- firefighter_tests/test_firefighter/test_sso.py +146 -0
- 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_form_utils.py +15 -15
- 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_incident_urls.py +3 -3
- firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +70 -2
- 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_priority_mapping.py +267 -0
- firefighter_tests/test_raid/test_raid_client.py +580 -0
- firefighter_tests/test_raid/test_raid_forms.py +552 -0
- firefighter_tests/test_raid/test_raid_models.py +185 -0
- firefighter_tests/test_raid/test_raid_serializers.py +507 -0
- firefighter_tests/test_raid/test_raid_service.py +442 -0
- firefighter_tests/test_raid/test_raid_signals.py +187 -0
- firefighter_tests/test_raid/test_raid_views.py +196 -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 +71 -9
- 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 +331 -7
- 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_fixtures/raid/area.json +0 -1
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
- {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.
|
|
30
|
-
slack_conversations: QuerySet[Conversation] = incident.
|
|
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 "
|
|
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.
|
|
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,
|
firefighter/slack/utils.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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", "
|
|
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: *
|
|
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.
|
|
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
|
)
|
|
@@ -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 == "
|
|
233
|
-
update_kwargs["
|
|
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
|
-
"
|
|
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.
|
|
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":
|
|
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
|
-
**
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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!
|