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
@@ -44,9 +44,9 @@ class CheckCurrentIncidentsModal(
44
44
  .order_by("-created_at")
45
45
  .select_related(
46
46
  "priority",
47
- "component",
47
+ "incident_category",
48
48
  "environment",
49
- "component__group",
49
+ "incident_category__group",
50
50
  "conversation",
51
51
  )[:30]
52
52
  )
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from django import forms
7
+ from django.conf import settings
8
+ from slack_sdk.models.blocks.basic_components import MarkdownTextObject
9
+ from slack_sdk.models.blocks.blocks import ContextBlock, SectionBlock
10
+
11
+ from firefighter.incidents.forms.unified_incident import UnifiedIncidentForm
12
+ from firefighter.slack.views.modals.base_modal.form_utils import SlackForm
13
+ from firefighter.slack.views.modals.opening.set_details import SetIncidentDetails
14
+ from firefighter.slack.views.modals.opening.types import OpeningData
15
+
16
+ if TYPE_CHECKING:
17
+ from firefighter.slack.views.modals.base_modal.form_utils import (
18
+ SlackFormAttributesDict,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class UnifiedIncidentFormSlack(UnifiedIncidentForm):
25
+ """Slack version of UnifiedIncidentForm with Slack-specific field configurations."""
26
+
27
+ slack_fields: SlackFormAttributesDict = {
28
+ "title": {
29
+ "input": {
30
+ "multiline": False,
31
+ "placeholder": "Short, punchy description of what's happening.",
32
+ },
33
+ "block": {"hint": None},
34
+ },
35
+ "description": {
36
+ "input": {
37
+ "multiline": True,
38
+ "placeholder": "Help people responding to the incident. This will be posted to #tech-incidents and on our internal status page.\nThis description can be edited later.",
39
+ },
40
+ "block": {"hint": None},
41
+ },
42
+ "environment": {
43
+ "input": {
44
+ "placeholder": "Select environments",
45
+ },
46
+ },
47
+ "platform": {
48
+ "input": {
49
+ "placeholder": "Select platforms",
50
+ },
51
+ },
52
+ "priority": {
53
+ "input": {
54
+ "placeholder": "Select a priority",
55
+ },
56
+ "widget": {
57
+ "post_block": (
58
+ SectionBlock(
59
+ text=f"_<{settings.SLACK_SEVERITY_HELP_GUIDE_URL}|How to choose the priority?>_"
60
+ )
61
+ if settings.SLACK_SEVERITY_HELP_GUIDE_URL
62
+ else None
63
+ ),
64
+ "label_from_instance": lambda obj: f"{obj.emoji} {obj.name} - {obj.description}",
65
+ },
66
+ },
67
+ "incident_category": {
68
+ "input": {
69
+ "placeholder": "Select affected issue category",
70
+ },
71
+ },
72
+ "suggested_team_routing": {
73
+ "input": {
74
+ "placeholder": "Select feature team or train",
75
+ },
76
+ "widget": {
77
+ "post_block": ContextBlock(
78
+ elements=[
79
+ MarkdownTextObject(
80
+ text="Feature Team or Train that should own the issue. If you don't know access <https://manomano.atlassian.net/wiki/spaces/QRAFT/pages/3970335291/Teams+and+owners|here> for guidance."
81
+ ),
82
+ ]
83
+ )
84
+ },
85
+ },
86
+ "zendesk_ticket_id": {
87
+ "input": {
88
+ "placeholder": "Zendesk ticket ID (if applicable)",
89
+ },
90
+ "block": {
91
+ "hint": "Link this incident to an existing Zendesk customer ticket"
92
+ },
93
+ },
94
+ "seller_contract_id": {
95
+ "input": {
96
+ "placeholder": "Seller contract ID",
97
+ },
98
+ },
99
+ "zoho_desk_ticket_id": {
100
+ "input": {
101
+ "placeholder": "Zoho Desk ticket ID (if applicable)",
102
+ },
103
+ "block": {
104
+ "hint": "Link this incident to an existing Zoho Desk seller ticket"
105
+ },
106
+ },
107
+ }
108
+
109
+ def __init__(
110
+ self,
111
+ *args: Any,
112
+ impacts_data: dict[str, Any] | None = None,
113
+ response_type: str = "critical",
114
+ **kwargs: Any,
115
+ ) -> None:
116
+ """Initialize form with impact-based field visibility.
117
+
118
+ Args:
119
+ *args: Positional arguments passed to parent form
120
+ impacts_data: Dictionary of impact selections
121
+ response_type: "critical" or "normal"
122
+ **kwargs: Keyword arguments passed to parent form
123
+ """
124
+ super().__init__(*args, **kwargs)
125
+
126
+ # Store for later use
127
+ self._impacts_data = impacts_data or {}
128
+ self._response_type = response_type
129
+
130
+ # Make priority field hidden
131
+ self.fields["priority"].widget = forms.HiddenInput()
132
+
133
+ # Conditionally hide fields based on impacts and response_type
134
+ self._configure_field_visibility()
135
+
136
+ def _configure_field_visibility(self) -> None:
137
+ """Configure which fields should be visible based on impacts and response type."""
138
+ visible_fields = self.get_visible_fields_for_impacts(
139
+ self._impacts_data, self._response_type
140
+ )
141
+
142
+ # Remove fields that shouldn't be visible
143
+ fields_to_remove = [
144
+ field_name for field_name in self.fields if field_name not in visible_fields
145
+ ]
146
+
147
+ for field_name in fields_to_remove:
148
+ del self.fields[field_name]
149
+
150
+
151
+ class OpeningUnifiedModal(SetIncidentDetails[UnifiedIncidentFormSlack]):
152
+ """Unified modal for all incident types (P1-P5)."""
153
+
154
+ open_action: str = "open_incident_unified"
155
+ push_action: str = "push_incident_unified"
156
+ callback_id: str = "open_incident_unified"
157
+ id = "incident_unified"
158
+
159
+ title = "Incident Details"
160
+ form_class = UnifiedIncidentFormSlack
161
+
162
+ def get_form_class(self) -> Any:
163
+ """Return a SlackForm wrapper that passes impacts_data and response_type."""
164
+ form_class = self.form_class
165
+
166
+ # Create a custom form class that accepts our parameters
167
+ class ContextAwareForm(form_class): # type: ignore
168
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
169
+ # Extract context from kwargs
170
+ open_incident_context = kwargs.pop("open_incident_context", None)
171
+ if open_incident_context:
172
+ kwargs["impacts_data"] = open_incident_context.get("impact_form_data", {})
173
+ kwargs["response_type"] = open_incident_context.get("response_type", "critical")
174
+ super().__init__(*args, **kwargs)
175
+
176
+ # Return SlackForm wrapping the context-aware form
177
+ return SlackForm(ContextAwareForm)
178
+
179
+ def build_modal_fn(
180
+ self, open_incident_context: OpeningData | None = None, **kwargs: Any
181
+ ) -> Any:
182
+ """Build modal with impact-aware form."""
183
+ # Extract impacts and response type from context
184
+ if open_incident_context is None:
185
+ open_incident_context = OpeningData()
186
+
187
+ response_type = open_incident_context.get("response_type", "critical")
188
+
189
+ # Store in initial data so form can access it
190
+ details_form_data = open_incident_context.get("details_form_data")
191
+ if details_form_data is None:
192
+ details_form_data = {}
193
+ details_form_data["response_type"] = response_type
194
+
195
+ # Update context
196
+ open_incident_context["details_form_data"] = details_form_data
197
+
198
+ # Store context for get_form_class in kwargs
199
+ # Call parent build_modal_fn with open_incident_context in kwargs
200
+ return super().build_modal_fn(open_incident_context=open_incident_context, **kwargs)
201
+
202
+
203
+ modal_opening_unified = OpeningUnifiedModal()
@@ -139,13 +139,16 @@ class SelectImpactModal(
139
139
  impact_descriptions = ""
140
140
  if impact_form_data:
141
141
  for value in impact_form_data.values():
142
- if value.name != "NO" and value.description:
142
+ # Filter out "no impact" levels using the value field instead of name
143
+ if not ((hasattr(value, "value") and value.value == "NO") or value.name == "NO" or not value.description):
143
144
  if hasattr(value, "impact_type_id") and value.impact_type_id:
144
145
  impact_type = ImpactType.objects.get(pk=value.impact_type_id)
145
146
  if impact_type:
146
147
  impact_descriptions += f"\u00A0\u00A0 :exclamation: {impact_type} - {value}\n"
147
148
  for line in str(value.description).splitlines():
148
- impact_descriptions += f"\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0 {line}\n"
149
+ # Skip empty lines or lines with only dashes/whitespace
150
+ if line.strip() and line.strip() != "-":
151
+ impact_descriptions += f"\u00A0\u00A0\u00A0\u00A0\u00A0\u00A0 • {line}\n"
149
152
  return impact_descriptions
150
153
 
151
154
  def handle_modal_fn( # type: ignore
@@ -78,7 +78,7 @@ class SetIncidentDetails(ModalForm[T], Generic[T]):
78
78
  submit=self.submit_text[:24],
79
79
  close="Close details",
80
80
  callback_id=self.callback_id,
81
- blocks=self.get_form_class()(initial=details_form_data).slack_blocks(),
81
+ blocks=self.get_form_class()(initial=details_form_data, open_incident_context=open_incident_context, **kwargs).slack_blocks(),
82
82
  private_metadata=json.dumps(
83
83
  open_incident_context, cls=SlackFormJSONEncoder
84
84
  ),
@@ -99,7 +99,8 @@ class SetIncidentDetails(ModalForm[T], Generic[T]):
99
99
  priority = Priority.objects.get(pk=priority)
100
100
 
101
101
  slack_form = self.get_form_class()(
102
- data={**slack_view_submission_to_dict(body), "priority": priority}
102
+ data={**slack_view_submission_to_dict(body), "priority": priority},
103
+ open_incident_context=private_metadata,
103
104
  )
104
105
  form: T = slack_form.form
105
106
  if form.is_valid():
@@ -65,14 +65,22 @@ class PostMortemModal(
65
65
  )
66
66
 
67
67
  @staticmethod
68
- def handle_modal_fn(ack: Ack, incident: Incident) -> None: # type: ignore[override]
68
+ def handle_modal_fn(ack: Ack, body: dict[str, Any], incident: Incident) -> None: # type: ignore[override]
69
69
  if not apps.is_installed("firefighter.confluence"):
70
70
  ack(text="Confluence is not enabled!")
71
71
  return
72
72
  if hasattr(incident, "postmortem_for"):
73
73
  ack(text="Post-mortem has already been created.")
74
74
  return
75
- ack()
75
+
76
+ # Check if this modal was pushed on top of another modal
77
+ # If yes, clear the entire stack to avoid leaving stale modals visible
78
+ is_pushed = body.get("view", {}).get("previous_view_id") is not None
79
+ if is_pushed:
80
+ ack(response_action="clear")
81
+ else:
82
+ ack()
83
+
76
84
  PostMortem.objects.create_postmortem_for_incident(incident)
77
85
 
78
86
 
@@ -7,9 +7,11 @@ from django.conf import settings
7
7
  from slack_sdk.models.blocks.blocks import SectionBlock
8
8
  from slack_sdk.models.views import View
9
9
 
10
+ from firefighter.incidents.enums import IncidentStatus
10
11
  from firefighter.incidents.forms.update_status import UpdateStatusForm
11
12
  from firefighter.slack.slack_templating import slack_block_footer, slack_block_separator
12
13
  from firefighter.slack.views.modals.base_modal.base import ModalForm
14
+ from firefighter.slack.views.modals.utils import handle_update_status_close_request
13
15
 
14
16
  if TYPE_CHECKING:
15
17
  from slack_bolt.context.ack.ack import Ack
@@ -51,7 +53,7 @@ class UpdateStatusFormSlack(UpdateStatusForm):
51
53
  "label_from_instance": priority_label,
52
54
  },
53
55
  },
54
- "component": {
56
+ "incident_category": {
55
57
  "input": {
56
58
  "placeholder": "Select affected issue category",
57
59
  }
@@ -73,8 +75,9 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
73
75
  initial={
74
76
  "status": incident.status,
75
77
  "priority": incident.priority,
76
- "component": incident.component,
77
- }
78
+ "incident_category": incident.incident_category,
79
+ },
80
+ incident=incident,
78
81
  ).slack_blocks()
79
82
  blocks.append(slack_block_separator())
80
83
  blocks.append(slack_block_footer())
@@ -97,8 +100,9 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
97
100
  "initial": {
98
101
  "status": incident.status,
99
102
  "priority": incident.priority,
100
- "component": incident.component,
101
- }
103
+ "incident_category": incident.incident_category,
104
+ },
105
+ "incident": incident,
102
106
  },
103
107
  )
104
108
  if slack_form is None:
@@ -107,9 +111,31 @@ class UpdateStatusModal(ModalForm[UpdateStatusFormSlack]):
107
111
  if len(form.cleaned_data) == 0:
108
112
  # XXX We should have a prompt for empty forms
109
113
  return
114
+
115
+ # Check if user is trying to close and needs a closure reason
116
+ if "status" in form.changed_data:
117
+ target_status = form.cleaned_data["status"]
118
+ if handle_update_status_close_request(ack, body, incident, target_status):
119
+ return
120
+
121
+ # If trying to close, validate that incident can be closed
122
+ if target_status == IncidentStatus.CLOSED:
123
+ can_close, reasons = incident.can_be_closed
124
+ if not can_close:
125
+ # Build error message from reasons
126
+ error_messages = [reason[1] for reason in reasons]
127
+ error_text = "\n".join([f"• {msg}" for msg in error_messages])
128
+ ack(
129
+ response_action="errors",
130
+ errors={
131
+ "status": f"Cannot close this incident:\n{error_text}"
132
+ }
133
+ )
134
+ return
135
+
110
136
  update_kwargs: dict[str, Any] = {}
111
137
  for changed_key in form.changed_data:
112
- if changed_key in {"component", "priority"}:
138
+ if changed_key in {"incident_category", "priority"}:
113
139
  update_kwargs[f"{changed_key}_id"] = form.cleaned_data[changed_key].id
114
140
  if changed_key in {"description", "title", "message", "status"}:
115
141
  update_kwargs[changed_key] = form.cleaned_data[changed_key]
@@ -0,0 +1,51 @@
1
+ """Utilities for modal handling to avoid circular imports."""
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from firefighter.incidents.enums import IncidentStatus
7
+ from firefighter.incidents.forms.update_status import UpdateStatusForm
8
+ from firefighter.slack.views.modals.closure_reason import modal_closure_reason
9
+
10
+ if TYPE_CHECKING:
11
+ from slack_sdk.models.views import View
12
+
13
+ from firefighter.incidents.models.incident import Incident
14
+
15
+
16
+ def get_close_modal_view(body: dict[str, Any], incident: Incident, **kwargs: Any) -> View | None:
17
+ """Get the appropriate modal view for closing an incident.
18
+
19
+ This function determines whether to show the closure reason modal
20
+ or delegate to the normal close modal.
21
+ """
22
+ # Check if closure reason is required
23
+ if UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.CLOSED):
24
+ return modal_closure_reason.build_modal_fn(body, incident, **kwargs)
25
+
26
+ # Return None to indicate normal close modal should be used
27
+ return None
28
+
29
+
30
+ def handle_close_modal_callback(ack: Any, body: dict[str, Any], incident: Incident, user: Any) -> bool | None:
31
+ """Handle modal callback, delegating to closure reason modal if needed."""
32
+ # Check if this is a closure reason modal callback
33
+ if body.get("view", {}).get("callback_id") == "incident_closure_reason":
34
+ return modal_closure_reason.handle_modal_fn(ack, body, incident, user)
35
+
36
+ # Return None to indicate normal handling should continue
37
+ return None
38
+
39
+
40
+ def handle_update_status_close_request(ack: Any, body: dict[str, Any], incident: Incident, target_status: IncidentStatus) -> bool:
41
+ """Handle update status request to close incident, showing reason modal if needed.
42
+
43
+ Returns True if the request was handled (reason modal shown), False otherwise.
44
+ """
45
+ if (target_status == IncidentStatus.CLOSED and
46
+ UpdateStatusForm.requires_closure_reason(incident, target_status)):
47
+ # Show closure reason modal instead
48
+ ack(response_action="push", view=modal_closure_reason.build_modal_fn(body, incident))
49
+ return True
50
+
51
+ return False