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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Fixtures for unified modal tests."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from firefighter.incidents.models import Environment, Priority
|
|
7
|
+
from firefighter.incidents.models.impact import ImpactLevel, ImpactType, LevelChoices
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def priority_factory(db):
|
|
12
|
+
"""Factory to create Priority instances."""
|
|
13
|
+
|
|
14
|
+
def _create(**kwargs):
|
|
15
|
+
value = kwargs.get("value", 1)
|
|
16
|
+
name = kwargs.get("name", f"P{value}")
|
|
17
|
+
set_as_default = kwargs.get("default", False)
|
|
18
|
+
|
|
19
|
+
# If default=True, clear any other defaults first
|
|
20
|
+
if set_as_default:
|
|
21
|
+
Priority.objects.filter(default=True).update(default=False)
|
|
22
|
+
|
|
23
|
+
defaults = {
|
|
24
|
+
"emoji": "🔴",
|
|
25
|
+
"order": value,
|
|
26
|
+
"default": set_as_default,
|
|
27
|
+
"enabled_create": True,
|
|
28
|
+
"enabled_update": True,
|
|
29
|
+
"needs_postmortem": value <= 2, # P1-P2 need postmortem
|
|
30
|
+
}
|
|
31
|
+
# Remove name and value from kwargs if present
|
|
32
|
+
kwargs_copy = kwargs.copy()
|
|
33
|
+
kwargs_copy.pop("name", None)
|
|
34
|
+
kwargs_copy.pop("value", None)
|
|
35
|
+
defaults.update(kwargs_copy)
|
|
36
|
+
|
|
37
|
+
priority, created = Priority.objects.get_or_create(
|
|
38
|
+
name=name,
|
|
39
|
+
value=value,
|
|
40
|
+
defaults=defaults,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# If already exists and we want it as default, just set that
|
|
44
|
+
if not created and set_as_default:
|
|
45
|
+
priority.default = True
|
|
46
|
+
priority.save(update_fields=["default"])
|
|
47
|
+
|
|
48
|
+
return priority
|
|
49
|
+
|
|
50
|
+
return _create
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def environment_factory(db):
|
|
55
|
+
"""Factory to create Environment instances."""
|
|
56
|
+
|
|
57
|
+
def _create(**kwargs):
|
|
58
|
+
value = kwargs.get("value", "TST")
|
|
59
|
+
set_as_default = kwargs.get("default", False)
|
|
60
|
+
|
|
61
|
+
# If default=True, clear any other defaults first
|
|
62
|
+
if set_as_default:
|
|
63
|
+
Environment.objects.filter(default=True).update(default=False)
|
|
64
|
+
|
|
65
|
+
defaults = {
|
|
66
|
+
"description": f"Environment {value}",
|
|
67
|
+
"order": 1,
|
|
68
|
+
"default": set_as_default,
|
|
69
|
+
}
|
|
70
|
+
# Remove value and default from kwargs if present
|
|
71
|
+
kwargs_copy = kwargs.copy()
|
|
72
|
+
kwargs_copy.pop("value", None)
|
|
73
|
+
kwargs_copy.pop("default", None)
|
|
74
|
+
defaults.update(kwargs_copy)
|
|
75
|
+
|
|
76
|
+
environment, created = Environment.objects.get_or_create(
|
|
77
|
+
value=value,
|
|
78
|
+
defaults=defaults,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# If already exists and we want it as default, just set that
|
|
82
|
+
if not created and set_as_default:
|
|
83
|
+
environment.default = True
|
|
84
|
+
environment.save(update_fields=["default"])
|
|
85
|
+
|
|
86
|
+
return environment
|
|
87
|
+
|
|
88
|
+
return _create
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pytest.fixture
|
|
92
|
+
def impact_level_factory(db):
|
|
93
|
+
"""Factory to create ImpactLevel instances."""
|
|
94
|
+
|
|
95
|
+
def _create(**kwargs):
|
|
96
|
+
# Handle impact__name syntax by extracting nested parameters
|
|
97
|
+
impact_type_data = {}
|
|
98
|
+
keys_to_remove = []
|
|
99
|
+
for key in list(kwargs.keys()):
|
|
100
|
+
if key.startswith("impact__"):
|
|
101
|
+
nested_key = key.split("__", 1)[1]
|
|
102
|
+
impact_type_data[nested_key] = kwargs[key]
|
|
103
|
+
keys_to_remove.append(key)
|
|
104
|
+
for key in keys_to_remove:
|
|
105
|
+
kwargs.pop(key)
|
|
106
|
+
|
|
107
|
+
# Create or get ImpactType
|
|
108
|
+
impact_type = kwargs.pop("impact_type", None)
|
|
109
|
+
if isinstance(impact_type, ImpactType):
|
|
110
|
+
pass # Already have ImpactType instance
|
|
111
|
+
elif impact_type_data:
|
|
112
|
+
impact_type_name = impact_type_data.get("name", "Test Impact")
|
|
113
|
+
impact_type, _ = ImpactType.objects.get_or_create(name=impact_type_name, defaults={
|
|
114
|
+
"emoji": "📊",
|
|
115
|
+
"help_text": f"Test {impact_type_name} impact",
|
|
116
|
+
"value": impact_type_name.lower().replace(" ", "_"),
|
|
117
|
+
"order": 10,
|
|
118
|
+
})
|
|
119
|
+
else:
|
|
120
|
+
impact_type_name = "Test Impact"
|
|
121
|
+
impact_type, _ = ImpactType.objects.get_or_create(name=impact_type_name, defaults={
|
|
122
|
+
"emoji": "📊",
|
|
123
|
+
"help_text": "Test impact",
|
|
124
|
+
"value": "test_impact",
|
|
125
|
+
"order": 10,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
# Handle value parameter
|
|
129
|
+
value = kwargs.pop("value", LevelChoices.LOW)
|
|
130
|
+
|
|
131
|
+
defaults = {
|
|
132
|
+
"impact_type": impact_type,
|
|
133
|
+
"value": value,
|
|
134
|
+
"name": value.label if hasattr(value, "label") else "Test Level",
|
|
135
|
+
"emoji": "📊",
|
|
136
|
+
}
|
|
137
|
+
defaults.update(kwargs)
|
|
138
|
+
return ImpactLevel.objects.create(**defaults)
|
|
139
|
+
|
|
140
|
+
return _create
|
|
@@ -27,7 +27,7 @@ class TestCloseModal:
|
|
|
27
27
|
new_callable=PropertyMock(return_value=(True, [])),
|
|
28
28
|
)
|
|
29
29
|
incident = IncidentFactory.build()
|
|
30
|
-
incident.status = IncidentStatus.
|
|
30
|
+
incident.status = IncidentStatus.MITIGATED
|
|
31
31
|
|
|
32
32
|
# Act
|
|
33
33
|
res = modal.build_modal_fn(incident=incident, body={})
|
|
@@ -54,7 +54,7 @@ class TestCloseModal:
|
|
|
54
54
|
return_value=(True, []),
|
|
55
55
|
new_callable=mocker.PropertyMock,
|
|
56
56
|
)
|
|
57
|
-
incident.status = IncidentStatus.
|
|
57
|
+
incident.status = IncidentStatus.MITIGATED
|
|
58
58
|
incident.title = "This is the title"
|
|
59
59
|
incident.description = "This is the description"
|
|
60
60
|
|
|
@@ -72,7 +72,8 @@ class TestCloseModal:
|
|
|
72
72
|
def test_close_modal_build_cant_close(incident: Incident) -> None:
|
|
73
73
|
# Arrange
|
|
74
74
|
modal = CloseModal()
|
|
75
|
-
|
|
75
|
+
# Use MITIGATING (Mitigating) status - cannot close from this status without going through MITIGATED
|
|
76
|
+
incident.status = IncidentStatus.MITIGATING
|
|
76
77
|
|
|
77
78
|
# Act
|
|
78
79
|
res = modal.build_modal_fn(incident=incident, body={})
|
|
@@ -90,6 +91,67 @@ class TestCloseModal:
|
|
|
90
91
|
"This incident can't be closed yet." in values["blocks"][0]["text"]["text"]
|
|
91
92
|
)
|
|
92
93
|
|
|
94
|
+
@staticmethod
|
|
95
|
+
def test_close_modal_build_shows_mitigated_status_requirement(
|
|
96
|
+
mocker: MockerFixture, incident: Incident
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Test that the modal shows STATUS_NOT_MITIGATED error with proper references.
|
|
99
|
+
|
|
100
|
+
This test covers lines 151, 154, 159 in close.py where MITIGATED.label is used.
|
|
101
|
+
"""
|
|
102
|
+
# Arrange
|
|
103
|
+
modal = CloseModal()
|
|
104
|
+
incident.status = IncidentStatus.INVESTIGATING
|
|
105
|
+
|
|
106
|
+
# Mock requires_closure_reason to return False so we bypass the closure reason modal
|
|
107
|
+
mocker.patch(
|
|
108
|
+
"firefighter.slack.views.modals.utils.UpdateStatusForm.requires_closure_reason",
|
|
109
|
+
return_value=False
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Mock can_be_closed to return False with STATUS_NOT_MITIGATED reason
|
|
113
|
+
mocker.patch.object(
|
|
114
|
+
Incident,
|
|
115
|
+
"can_be_closed",
|
|
116
|
+
new_callable=PropertyMock(return_value=(False, [("STATUS_NOT_MITIGATED", "Status not mitigated")])),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Act
|
|
120
|
+
res = modal.build_modal_fn(incident=incident, body={})
|
|
121
|
+
|
|
122
|
+
# Assert
|
|
123
|
+
assert res.to_dict()
|
|
124
|
+
values = res.to_dict()
|
|
125
|
+
assert "blocks" in values
|
|
126
|
+
|
|
127
|
+
# Convert blocks to text for easier searching
|
|
128
|
+
blocks_text = str(values["blocks"])
|
|
129
|
+
|
|
130
|
+
# Verify that "Mitigated" (the label) appears in the error message
|
|
131
|
+
# Line 154: text=f":warning: *Status is not _{IncidentStatus.MITIGATED.label}_* :warning:\n"
|
|
132
|
+
# Line 159: text=f"You can only close an incident when its status is _{IncidentStatus.MITIGATED.label}_ or _{IncidentStatus.POST_MORTEM.label}_..."
|
|
133
|
+
assert "Mitigated" in blocks_text
|
|
134
|
+
assert "Post-mortem" in blocks_text or "Post Mortem" in blocks_text
|
|
135
|
+
|
|
136
|
+
# Verify the error message structure
|
|
137
|
+
assert "This incident can't be closed yet." in values["blocks"][0]["text"]["text"]
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def test_close_modal_build_shows_closure_reason_from_open(incident: Incident) -> None:
|
|
141
|
+
# Arrange
|
|
142
|
+
modal = CloseModal()
|
|
143
|
+
incident.status = IncidentStatus.OPEN
|
|
144
|
+
|
|
145
|
+
# Act
|
|
146
|
+
res = modal.build_modal_fn(incident=incident, body={})
|
|
147
|
+
|
|
148
|
+
# Assert
|
|
149
|
+
assert res.to_dict()
|
|
150
|
+
values = res.to_dict()
|
|
151
|
+
assert "blocks" in values
|
|
152
|
+
# Should show closure reason form
|
|
153
|
+
assert "Closure Reason Required" in values["blocks"][0]["text"]["text"]
|
|
154
|
+
|
|
93
155
|
@staticmethod
|
|
94
156
|
def test_submit_empty_bodied_form() -> None:
|
|
95
157
|
modal = CloseModal()
|
|
@@ -220,13 +282,13 @@ valid_submission = {
|
|
|
220
282
|
},
|
|
221
283
|
{
|
|
222
284
|
"type": "input",
|
|
223
|
-
"block_id": "
|
|
285
|
+
"block_id": "incident_category",
|
|
224
286
|
"label": {"type": "plain_text", "text": "Issue category", "emoji": True},
|
|
225
287
|
"optional": False,
|
|
226
288
|
"dispatch_action": False,
|
|
227
289
|
"element": {
|
|
228
290
|
"type": "static_select",
|
|
229
|
-
"action_id": "
|
|
291
|
+
"action_id": "incident_category",
|
|
230
292
|
"placeholder": {
|
|
231
293
|
"type": "plain_text",
|
|
232
294
|
"text": "Select affected issue category",
|
|
@@ -909,8 +971,8 @@ valid_submission = {
|
|
|
909
971
|
"value": "This is a valid description.",
|
|
910
972
|
}
|
|
911
973
|
},
|
|
912
|
-
"
|
|
913
|
-
"
|
|
974
|
+
"incident_category": {
|
|
975
|
+
"incident_category": {
|
|
914
976
|
"type": "static_select",
|
|
915
977
|
"selected_option": {
|
|
916
978
|
"text": {
|
|
@@ -972,7 +1034,7 @@ valid_submission = {
|
|
|
972
1034
|
invalid_title = deepcopy(valid_submission)
|
|
973
1035
|
invalid_title["view"]["state"]["values"]["title"]["title"]["value"] = "short" # type: ignore
|
|
974
1036
|
|
|
975
|
-
|
|
976
|
-
|
|
1037
|
+
invalid_incident_category = deepcopy(valid_submission)
|
|
1038
|
+
invalid_incident_category["view"]["state"]["values"]["incident_category"]["incident_category"][
|
|
977
1039
|
"selected_option"
|
|
978
1040
|
]["value"] = "notauuid-d445-4fc9-92eb-e742ee14fd4a" # type: ignore
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Tests for ClosureReasonModal - Slack modal for closing incidents with a reason."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from slack_sdk.errors import SlackApiError
|
|
9
|
+
|
|
10
|
+
from firefighter.incidents.enums import ClosureReason, IncidentStatus
|
|
11
|
+
from firefighter.incidents.factories import IncidentFactory, UserFactory
|
|
12
|
+
from firefighter.slack.views.modals.closure_reason import ClosureReasonModal
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.django_db
|
|
16
|
+
class TestClosureReasonModalMessageTabDisabled:
|
|
17
|
+
"""Test ClosureReasonModal handles messages_tab_disabled gracefully."""
|
|
18
|
+
|
|
19
|
+
def test_closure_reason_handles_messages_tab_disabled(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
20
|
+
"""Test that messages_tab_disabled error is handled gracefully with warning log."""
|
|
21
|
+
# Create test data
|
|
22
|
+
user = UserFactory.build()
|
|
23
|
+
user.save()
|
|
24
|
+
incident = IncidentFactory.build(_status=IncidentStatus.INVESTIGATING, created_by=user)
|
|
25
|
+
incident.save()
|
|
26
|
+
|
|
27
|
+
# Create modal and mock
|
|
28
|
+
modal = ClosureReasonModal()
|
|
29
|
+
ack = MagicMock()
|
|
30
|
+
|
|
31
|
+
# Create valid form submission
|
|
32
|
+
body = {
|
|
33
|
+
"view": {
|
|
34
|
+
"state": {
|
|
35
|
+
"values": {
|
|
36
|
+
"closure_reason": {
|
|
37
|
+
"select_closure_reason": {
|
|
38
|
+
"selected_option": {"value": ClosureReason.CANCELLED}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"closure_reference": {
|
|
42
|
+
"input_closure_reference": {"value": ""}
|
|
43
|
+
},
|
|
44
|
+
"closure_message": {
|
|
45
|
+
"input_closure_message": {"value": "Test closure message"}
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"private_metadata": str(incident.id),
|
|
50
|
+
},
|
|
51
|
+
"user": {"id": "U123456"},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Mock respond to raise messages_tab_disabled error
|
|
55
|
+
slack_error_response = MagicMock()
|
|
56
|
+
slack_error_response.get.return_value = "messages_tab_disabled"
|
|
57
|
+
|
|
58
|
+
with patch("firefighter.slack.views.modals.closure_reason.respond") as mock_respond:
|
|
59
|
+
mock_respond.side_effect = SlackApiError(
|
|
60
|
+
message="The request to the Slack API failed.",
|
|
61
|
+
response=slack_error_response
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Execute
|
|
65
|
+
result = modal.handle_modal_fn(
|
|
66
|
+
ack=ack,
|
|
67
|
+
body=body,
|
|
68
|
+
incident=incident,
|
|
69
|
+
user=user
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Assertions
|
|
73
|
+
assert result is True # Incident should still be closed successfully
|
|
74
|
+
|
|
75
|
+
# Verify incident was closed
|
|
76
|
+
incident.refresh_from_db()
|
|
77
|
+
assert incident.status == IncidentStatus.CLOSED
|
|
78
|
+
assert incident.closure_reason == ClosureReason.CANCELLED
|
|
79
|
+
|
|
80
|
+
# Verify warning was logged
|
|
81
|
+
assert any(
|
|
82
|
+
"Cannot send DM to user" in record.message and record.levelname == "WARNING"
|
|
83
|
+
for record in caplog.records
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def test_closure_reason_reraises_other_slack_errors(self) -> None:
|
|
87
|
+
"""Test that other Slack API errors are re-raised."""
|
|
88
|
+
# Create test data
|
|
89
|
+
user = UserFactory.build()
|
|
90
|
+
user.save()
|
|
91
|
+
incident = IncidentFactory.build(_status=IncidentStatus.INVESTIGATING, created_by=user)
|
|
92
|
+
incident.save()
|
|
93
|
+
|
|
94
|
+
# Create modal and mock
|
|
95
|
+
modal = ClosureReasonModal()
|
|
96
|
+
ack = MagicMock()
|
|
97
|
+
|
|
98
|
+
# Create valid form submission
|
|
99
|
+
body = {
|
|
100
|
+
"view": {
|
|
101
|
+
"state": {
|
|
102
|
+
"values": {
|
|
103
|
+
"closure_reason": {
|
|
104
|
+
"select_closure_reason": {
|
|
105
|
+
"selected_option": {"value": ClosureReason.CANCELLED}
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"closure_reference": {
|
|
109
|
+
"input_closure_reference": {"value": ""}
|
|
110
|
+
},
|
|
111
|
+
"closure_message": {
|
|
112
|
+
"input_closure_message": {"value": "Test closure message"}
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"private_metadata": str(incident.id),
|
|
117
|
+
},
|
|
118
|
+
"user": {"id": "U123456"},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Mock respond to raise different Slack error
|
|
122
|
+
slack_error_response = MagicMock()
|
|
123
|
+
slack_error_response.get.return_value = "channel_not_found"
|
|
124
|
+
|
|
125
|
+
with patch("firefighter.slack.views.modals.closure_reason.respond") as mock_respond:
|
|
126
|
+
mock_respond.side_effect = SlackApiError(
|
|
127
|
+
message="The request to the Slack API failed.",
|
|
128
|
+
response=slack_error_response
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Execute and expect exception
|
|
132
|
+
with pytest.raises(SlackApiError):
|
|
133
|
+
modal.handle_modal_fn(
|
|
134
|
+
ack=ack,
|
|
135
|
+
body=body,
|
|
136
|
+
incident=incident,
|
|
137
|
+
user=user
|
|
138
|
+
)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Tests for form_utils.py support of multiple choice fields."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from django import forms
|
|
6
|
+
|
|
7
|
+
from firefighter.incidents.models import Environment
|
|
8
|
+
from firefighter.slack.views.modals.base_modal.form_utils import (
|
|
9
|
+
SlackForm,
|
|
10
|
+
slack_view_submission_to_dict,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.mark.django_db
|
|
15
|
+
class TestMultipleChoiceFields:
|
|
16
|
+
"""Test that SlackForm correctly handles ModelMultipleChoiceField and MultipleChoiceField."""
|
|
17
|
+
|
|
18
|
+
def test_model_multiple_choice_field_with_initial_list(self, environment_factory):
|
|
19
|
+
"""Test ModelMultipleChoiceField with list of model instances as initial."""
|
|
20
|
+
class TestForm(forms.Form):
|
|
21
|
+
environments = forms.ModelMultipleChoiceField(
|
|
22
|
+
queryset=Environment.objects.all(),
|
|
23
|
+
required=True,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Create test environments
|
|
27
|
+
env1 = environment_factory(value="PRD", default=True)
|
|
28
|
+
env2 = environment_factory(value="STG", default=True)
|
|
29
|
+
default_envs = [env1, env2]
|
|
30
|
+
|
|
31
|
+
slack_form = SlackForm(TestForm)(initial={"environments": default_envs})
|
|
32
|
+
blocks = slack_form.slack_blocks()
|
|
33
|
+
|
|
34
|
+
# Should generate blocks without errors
|
|
35
|
+
assert len(blocks) > 0
|
|
36
|
+
|
|
37
|
+
# Find the environment block
|
|
38
|
+
env_block = next(b for b in blocks if b.block_id == "environments")
|
|
39
|
+
assert env_block is not None
|
|
40
|
+
|
|
41
|
+
# Check it's a multi-select element
|
|
42
|
+
assert env_block.element.type == "multi_static_select"
|
|
43
|
+
|
|
44
|
+
# Check initial options
|
|
45
|
+
if default_envs:
|
|
46
|
+
assert len(env_block.element.initial_options) == len(default_envs)
|
|
47
|
+
|
|
48
|
+
def test_model_multiple_choice_field_with_callable_initial(self, environment_factory):
|
|
49
|
+
"""Test ModelMultipleChoiceField with callable returning list."""
|
|
50
|
+
# Create test environments
|
|
51
|
+
environment_factory(value="PRD", default=True)
|
|
52
|
+
environment_factory(value="STG", default=True)
|
|
53
|
+
|
|
54
|
+
def get_default_envs():
|
|
55
|
+
return list(Environment.objects.filter(default=True))
|
|
56
|
+
|
|
57
|
+
class TestForm(forms.Form):
|
|
58
|
+
environments = forms.ModelMultipleChoiceField(
|
|
59
|
+
queryset=Environment.objects.all(),
|
|
60
|
+
initial=get_default_envs,
|
|
61
|
+
required=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
slack_form = SlackForm(TestForm)()
|
|
65
|
+
blocks = slack_form.slack_blocks()
|
|
66
|
+
|
|
67
|
+
# Should generate blocks without errors
|
|
68
|
+
assert len(blocks) > 0
|
|
69
|
+
|
|
70
|
+
def test_multiple_choice_field_with_initial_list(self):
|
|
71
|
+
"""Test MultipleChoiceField with list of values as initial."""
|
|
72
|
+
|
|
73
|
+
class TestForm(forms.Form):
|
|
74
|
+
platforms = forms.MultipleChoiceField(
|
|
75
|
+
choices=[
|
|
76
|
+
("FR", "France"),
|
|
77
|
+
("DE", "Germany"),
|
|
78
|
+
("UK", "United Kingdom"),
|
|
79
|
+
],
|
|
80
|
+
initial=["FR", "UK"],
|
|
81
|
+
required=True,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
slack_form = SlackForm(TestForm)()
|
|
85
|
+
blocks = slack_form.slack_blocks()
|
|
86
|
+
|
|
87
|
+
# Should generate blocks without errors
|
|
88
|
+
assert len(blocks) > 0
|
|
89
|
+
|
|
90
|
+
# Find the platforms block
|
|
91
|
+
platform_block = next(b for b in blocks if b.block_id == "platforms")
|
|
92
|
+
assert platform_block is not None
|
|
93
|
+
|
|
94
|
+
# Check it's a multi-select element
|
|
95
|
+
assert platform_block.element.type == "multi_static_select"
|
|
96
|
+
|
|
97
|
+
# Check initial options
|
|
98
|
+
assert len(platform_block.element.initial_options) == 2
|
|
99
|
+
|
|
100
|
+
def test_multiple_choice_field_empty_initial(self):
|
|
101
|
+
"""Test MultipleChoiceField with no initial value."""
|
|
102
|
+
|
|
103
|
+
class TestForm(forms.Form):
|
|
104
|
+
platforms = forms.MultipleChoiceField(
|
|
105
|
+
choices=[
|
|
106
|
+
("FR", "France"),
|
|
107
|
+
("DE", "Germany"),
|
|
108
|
+
],
|
|
109
|
+
required=False,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
slack_form = SlackForm(TestForm)()
|
|
113
|
+
blocks = slack_form.slack_blocks()
|
|
114
|
+
|
|
115
|
+
assert len(blocks) > 0
|
|
116
|
+
|
|
117
|
+
# Find the platforms block
|
|
118
|
+
platform_block = next(b for b in blocks if b.block_id == "platforms")
|
|
119
|
+
|
|
120
|
+
# Should have no initial_options
|
|
121
|
+
assert not hasattr(platform_block.element, "initial_options") or not platform_block.element.initial_options
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.mark.django_db
|
|
125
|
+
class TestSlackViewSubmissionToDict:
|
|
126
|
+
"""Test parsing of multi-select responses from Slack."""
|
|
127
|
+
|
|
128
|
+
def test_multi_static_select_parsing(self):
|
|
129
|
+
"""Test that multi_static_select responses are parsed as lists."""
|
|
130
|
+
body = {
|
|
131
|
+
"view": {
|
|
132
|
+
"state": {
|
|
133
|
+
"values": {
|
|
134
|
+
"environment": {
|
|
135
|
+
"environment": {
|
|
136
|
+
"type": "multi_static_select",
|
|
137
|
+
"selected_options": [
|
|
138
|
+
{"value": "uuid-1"},
|
|
139
|
+
{"value": "uuid-2"},
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
result = slack_view_submission_to_dict(body)
|
|
149
|
+
|
|
150
|
+
assert "environment" in result
|
|
151
|
+
assert isinstance(result["environment"], list)
|
|
152
|
+
assert result["environment"] == ["uuid-1", "uuid-2"]
|
|
153
|
+
|
|
154
|
+
def test_multi_static_select_empty(self):
|
|
155
|
+
"""Test that empty multi_static_select returns empty list."""
|
|
156
|
+
body = {
|
|
157
|
+
"view": {
|
|
158
|
+
"state": {
|
|
159
|
+
"values": {
|
|
160
|
+
"environment": {
|
|
161
|
+
"environment": {
|
|
162
|
+
"type": "multi_static_select",
|
|
163
|
+
"selected_options": [],
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
result = slack_view_submission_to_dict(body)
|
|
172
|
+
|
|
173
|
+
assert "environment" in result
|
|
174
|
+
assert isinstance(result["environment"], list)
|
|
175
|
+
assert result["environment"] == []
|
|
176
|
+
|
|
177
|
+
def test_checkboxes_checked(self):
|
|
178
|
+
"""Test that checked checkboxes (BooleanField) return True."""
|
|
179
|
+
body = {
|
|
180
|
+
"view": {
|
|
181
|
+
"state": {
|
|
182
|
+
"values": {
|
|
183
|
+
"is_key_account": {
|
|
184
|
+
"is_key_account": {
|
|
185
|
+
"type": "checkboxes",
|
|
186
|
+
"selected_options": [
|
|
187
|
+
{"value": "True"},
|
|
188
|
+
],
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
result = slack_view_submission_to_dict(body)
|
|
197
|
+
|
|
198
|
+
assert "is_key_account" in result
|
|
199
|
+
assert result["is_key_account"] is True
|
|
200
|
+
|
|
201
|
+
def test_checkboxes_unchecked(self):
|
|
202
|
+
"""Test that unchecked checkboxes (BooleanField) return False."""
|
|
203
|
+
body = {
|
|
204
|
+
"view": {
|
|
205
|
+
"state": {
|
|
206
|
+
"values": {
|
|
207
|
+
"is_key_account": {
|
|
208
|
+
"is_key_account": {
|
|
209
|
+
"type": "checkboxes",
|
|
210
|
+
"selected_options": [],
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
result = slack_view_submission_to_dict(body)
|
|
219
|
+
|
|
220
|
+
assert "is_key_account" in result
|
|
221
|
+
assert result["is_key_account"] is False
|
|
222
|
+
|
|
223
|
+
def test_multiple_checkboxes(self):
|
|
224
|
+
"""Test that multiple checkboxes are parsed correctly."""
|
|
225
|
+
body = {
|
|
226
|
+
"view": {
|
|
227
|
+
"state": {
|
|
228
|
+
"values": {
|
|
229
|
+
"is_key_account": {
|
|
230
|
+
"is_key_account": {
|
|
231
|
+
"type": "checkboxes",
|
|
232
|
+
"selected_options": [{"value": "True"}],
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
"is_seller_in_golden_list": {
|
|
236
|
+
"is_seller_in_golden_list": {
|
|
237
|
+
"type": "checkboxes",
|
|
238
|
+
"selected_options": [], # Unchecked
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
result = slack_view_submission_to_dict(body)
|
|
247
|
+
|
|
248
|
+
assert result["is_key_account"] is True
|
|
249
|
+
assert result["is_seller_in_golden_list"] is False
|