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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. firefighter/_version.py +16 -3
  2. firefighter/api/serializers.py +17 -8
  3. firefighter/api/urls.py +8 -1
  4. firefighter/api/views/_base.py +1 -1
  5. firefighter/api/views/components.py +5 -5
  6. firefighter/api/views/incidents.py +9 -9
  7. firefighter/confluence/signals/incident_updated.py +2 -2
  8. firefighter/firefighter/settings/components/raid.py +3 -0
  9. firefighter/incidents/admin.py +24 -24
  10. firefighter/incidents/enums.py +22 -2
  11. firefighter/incidents/factories.py +14 -5
  12. firefighter/incidents/forms/close_incident.py +4 -4
  13. firefighter/incidents/forms/closure_reason.py +45 -0
  14. firefighter/incidents/forms/create_incident.py +4 -4
  15. firefighter/incidents/forms/unified_incident.py +406 -0
  16. firefighter/incidents/forms/update_status.py +91 -5
  17. firefighter/incidents/menus.py +2 -2
  18. firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
  19. firefighter/incidents/migrations/0009_update_sla.py +7 -5
  20. firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
  21. firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
  22. firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
  23. firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
  24. firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
  25. firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
  26. firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
  27. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  28. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  29. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  30. firefighter/incidents/models/__init__.py +1 -1
  31. firefighter/incidents/models/group.py +1 -1
  32. firefighter/incidents/models/incident.py +47 -20
  33. firefighter/incidents/models/{component.py → incident_category.py} +30 -29
  34. firefighter/incidents/models/incident_update.py +3 -3
  35. firefighter/incidents/static/css/main.min.css +1 -1
  36. firefighter/incidents/tables.py +9 -9
  37. firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
  38. firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
  39. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  40. firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
  41. firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
  42. firefighter/incidents/templates/pages/incident_detail.html +3 -3
  43. firefighter/incidents/urls.py +6 -6
  44. firefighter/incidents/views/components/details.py +9 -9
  45. firefighter/incidents/views/components/list.py +9 -9
  46. firefighter/incidents/views/reports.py +5 -5
  47. firefighter/incidents/views/users/details.py +2 -2
  48. firefighter/incidents/views/views.py +7 -7
  49. firefighter/jira_app/client.py +1 -1
  50. firefighter/logging/custom_json_formatter.py +2 -1
  51. firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
  52. firefighter/raid/admin.py +0 -11
  53. firefighter/raid/apps.py +9 -26
  54. firefighter/raid/client.py +5 -5
  55. firefighter/raid/forms.py +84 -213
  56. firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
  57. firefighter/raid/models.py +2 -21
  58. firefighter/raid/serializers.py +5 -4
  59. firefighter/raid/service.py +29 -27
  60. firefighter/raid/signals/incident_created.py +42 -15
  61. firefighter/raid/signals/incident_updated.py +3 -2
  62. firefighter/raid/utils.py +1 -1
  63. firefighter/raid/views/__init__.py +1 -1
  64. firefighter/slack/admin.py +8 -8
  65. firefighter/slack/management/commands/switch_test_users.py +272 -0
  66. firefighter/slack/messages/slack_messages.py +24 -9
  67. firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
  68. firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
  69. firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
  70. firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
  71. firefighter/slack/models/conversation.py +3 -3
  72. firefighter/slack/models/incident_channel.py +1 -1
  73. firefighter/slack/models/user.py +1 -1
  74. firefighter/slack/models/user_group.py +3 -3
  75. firefighter/slack/rules.py +2 -2
  76. firefighter/slack/signals/create_incident_conversation.py +6 -0
  77. firefighter/slack/signals/get_users.py +2 -2
  78. firefighter/slack/signals/incident_updated.py +8 -2
  79. firefighter/slack/utils.py +2 -2
  80. firefighter/slack/views/events/home.py +2 -2
  81. firefighter/slack/views/modals/__init__.py +4 -0
  82. firefighter/slack/views/modals/base_modal/form_utils.py +78 -0
  83. firefighter/slack/views/modals/close.py +18 -5
  84. firefighter/slack/views/modals/closure_reason.py +193 -0
  85. firefighter/slack/views/modals/open.py +83 -12
  86. firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
  87. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  88. firefighter/slack/views/modals/opening/select_impact.py +5 -2
  89. firefighter/slack/views/modals/opening/set_details.py +3 -2
  90. firefighter/slack/views/modals/postmortem.py +10 -2
  91. firefighter/slack/views/modals/update_status.py +32 -6
  92. firefighter/slack/views/modals/utils.py +51 -0
  93. firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
  94. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +2 -2
  95. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +133 -88
  96. firefighter_tests/conftest.py +4 -5
  97. firefighter_tests/test_api/test_api_landbot.py +1 -1
  98. firefighter_tests/test_firefighter/test_sso.py +146 -0
  99. firefighter_tests/test_incidents/test_enums.py +100 -0
  100. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  101. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  102. firefighter_tests/test_incidents/test_forms/test_form_utils.py +15 -15
  103. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  104. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  105. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  106. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  107. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  108. firefighter_tests/test_incidents/test_incident_urls.py +3 -3
  109. firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
  110. firefighter_tests/test_incidents/test_models/test_incident_model.py +70 -2
  111. firefighter_tests/test_raid/conftest.py +154 -0
  112. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  113. firefighter_tests/test_raid/test_priority_mapping.py +267 -0
  114. firefighter_tests/test_raid/test_raid_client.py +580 -0
  115. firefighter_tests/test_raid/test_raid_forms.py +552 -0
  116. firefighter_tests/test_raid/test_raid_models.py +185 -0
  117. firefighter_tests/test_raid/test_raid_serializers.py +507 -0
  118. firefighter_tests/test_raid/test_raid_service.py +442 -0
  119. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  120. firefighter_tests/test_raid/test_raid_views.py +196 -0
  121. firefighter_tests/test_slack/messages/__init__.py +0 -0
  122. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  123. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  124. firefighter_tests/test_slack/views/modals/test_close.py +71 -9
  125. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  126. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  127. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  128. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  129. firefighter_tests/test_slack/views/modals/test_update_status.py +331 -7
  130. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  131. firefighter/raid/views/open_normal.py +0 -139
  132. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  133. firefighter_fixtures/raid/area.json +0 -1
  134. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
  135. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
  136. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -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.FIXED
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.FIXED
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
- incident.status = IncidentStatus.OPEN
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": "component",
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": "component",
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
- "component": {
913
- "component": {
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
- invalid_component = deepcopy(valid_submission)
976
- invalid_component["view"]["state"]["values"]["component"]["component"][
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