firefighter-incident 0.0.13__py3-none-any.whl → 0.0.14__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 +8 -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/firefighter/settings/components/raid.py +3 -0
- firefighter/incidents/admin.py +24 -24
- firefighter/incidents/factories.py +14 -5
- firefighter/incidents/forms/close_incident.py +4 -4
- firefighter/incidents/forms/create_incident.py +4 -4
- firefighter/incidents/forms/update_status.py +4 -4
- 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/models/__init__.py +1 -1
- firefighter/incidents/models/group.py +1 -1
- firefighter/incidents/models/incident.py +15 -15
- firefighter/incidents/models/{component.py → incident_category.py} +30 -29
- firefighter/incidents/models/incident_update.py +3 -3
- 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/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 +2 -2
- 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/client.py +3 -3
- firefighter/raid/forms.py +53 -19
- 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 +4 -2
- firefighter/raid/utils.py +1 -1
- firefighter/raid/views/__init__.py +1 -1
- firefighter/raid/views/open_normal.py +2 -2
- firefighter/slack/admin.py +8 -8
- firefighter/slack/management/commands/switch_test_users.py +272 -0
- firefighter/slack/messages/slack_messages.py +5 -5
- 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 +1 -1
- firefighter/slack/signals/get_users.py +2 -2
- firefighter/slack/signals/incident_updated.py +1 -1
- firefighter/slack/utils.py +2 -2
- firefighter/slack/views/events/home.py +2 -2
- firefighter/slack/views/modals/base_modal/form_utils.py +15 -0
- firefighter/slack/views/modals/close.py +3 -3
- firefighter/slack/views/modals/open.py +25 -1
- firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
- firefighter/slack/views/modals/opening/details/critical.py +1 -1
- firefighter/slack/views/modals/opening/select_impact.py +5 -2
- firefighter/slack/views/modals/update_status.py +4 -4
- firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/METADATA +2 -2
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/RECORD +98 -77
- 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_forms/test_form_utils.py +15 -15
- 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 +2 -2
- 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 +795 -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_views.py +196 -0
- firefighter_tests/test_slack/views/modals/test_close.py +6 -6
- firefighter_tests/test_slack/views/modals/test_update_status.py +4 -4
- firefighter_fixtures/raid/area.json +0 -1
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/entry_points.txt +0 -0
- {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/licenses/LICENSE +0 -0
firefighter/raid/serializers.py
CHANGED
|
@@ -114,7 +114,7 @@ class LandbotIssueRequestSerializer(serializers.ModelSerializer[JiraTicket]):
|
|
|
114
114
|
),
|
|
115
115
|
help_text="List of labels to be added to the ticket. Labels cannot contain spaces and must not exceed 255 characters.",
|
|
116
116
|
)
|
|
117
|
-
|
|
117
|
+
incident_category = serializers.CharField(
|
|
118
118
|
max_length=128,
|
|
119
119
|
write_only=True,
|
|
120
120
|
allow_null=True,
|
|
@@ -131,7 +131,8 @@ class LandbotIssueRequestSerializer(serializers.ModelSerializer[JiraTicket]):
|
|
|
131
131
|
],
|
|
132
132
|
)
|
|
133
133
|
priority = serializers.IntegerField(
|
|
134
|
-
min_value=1, max_value=
|
|
134
|
+
min_value=1, max_value=5, write_only=True, allow_null=True,
|
|
135
|
+
help_text="Priority level 1-5 (1=Critical, 2=High, 3=Medium, 4=Low, 5=Lowest)"
|
|
135
136
|
)
|
|
136
137
|
business_impact = serializers.ChoiceField(
|
|
137
138
|
write_only=True, choices=["High", "Medium", "Low"], allow_null=True
|
|
@@ -198,7 +199,7 @@ class LandbotIssueRequestSerializer(serializers.ModelSerializer[JiraTicket]):
|
|
|
198
199
|
seller_contract_id=validated_data["seller_contract_id"],
|
|
199
200
|
zoho_desk_ticket_id=validated_data["zoho"],
|
|
200
201
|
platform=validated_data["platform"],
|
|
201
|
-
|
|
202
|
+
incident_category=validated_data["incident_category"],
|
|
202
203
|
business_impact=validated_data["business_impact"],
|
|
203
204
|
environments=validated_data["environments"],
|
|
204
205
|
suggested_team_routing=validated_data["suggested_team_routing"],
|
|
@@ -239,7 +240,7 @@ class LandbotIssueRequestSerializer(serializers.ModelSerializer[JiraTicket]):
|
|
|
239
240
|
"zoho",
|
|
240
241
|
"platform",
|
|
241
242
|
"reporter_email",
|
|
242
|
-
"
|
|
243
|
+
"incident_category",
|
|
243
244
|
"labels",
|
|
244
245
|
"environments",
|
|
245
246
|
"issue_type",
|
firefighter/raid/service.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from typing import TYPE_CHECKING
|
|
5
6
|
|
|
6
7
|
from django.conf import settings
|
|
@@ -133,7 +134,7 @@ def create_issue_internal(
|
|
|
133
134
|
platform: str,
|
|
134
135
|
business_impact: str | None,
|
|
135
136
|
team_to_be_routed: str | None,
|
|
136
|
-
|
|
137
|
+
incident_category: str | None,
|
|
137
138
|
) -> JiraObject:
|
|
138
139
|
"""Creates a Jira Incident Issue of type Internal.
|
|
139
140
|
|
|
@@ -146,7 +147,7 @@ def create_issue_internal(
|
|
|
146
147
|
platform (str): Platform of the issue
|
|
147
148
|
business_impact (str): Business impact of the issue
|
|
148
149
|
team_to_be_routed (str): Team to be routed
|
|
149
|
-
|
|
150
|
+
incident_category (str): Incident category of the issue
|
|
150
151
|
"""
|
|
151
152
|
issue = jira_client.create_issue(
|
|
152
153
|
issuetype="Incident",
|
|
@@ -159,23 +160,30 @@ def create_issue_internal(
|
|
|
159
160
|
platform=platform,
|
|
160
161
|
business_impact=business_impact,
|
|
161
162
|
suggested_team_routing=team_to_be_routed,
|
|
162
|
-
|
|
163
|
+
incident_category=incident_category,
|
|
163
164
|
)
|
|
164
165
|
check_issue_id(issue, title=title, reporter=reporter)
|
|
165
166
|
return issue
|
|
166
167
|
|
|
167
168
|
|
|
169
|
+
@dataclass
|
|
170
|
+
class CustomerIssueData:
|
|
171
|
+
"""Data container for customer issue creation parameters."""
|
|
172
|
+
priority: int | None
|
|
173
|
+
labels: list[str] | None
|
|
174
|
+
platform: str
|
|
175
|
+
business_impact: str | None
|
|
176
|
+
team_to_be_routed: str | None
|
|
177
|
+
area: str | None
|
|
178
|
+
zendesk_ticket_id: str | None
|
|
179
|
+
incident_category: str | None = None
|
|
180
|
+
|
|
181
|
+
|
|
168
182
|
def create_issue_customer(
|
|
169
183
|
title: str,
|
|
170
184
|
description: str,
|
|
171
185
|
reporter: str,
|
|
172
|
-
|
|
173
|
-
labels: list[str] | None,
|
|
174
|
-
platform: str,
|
|
175
|
-
business_impact: str | None,
|
|
176
|
-
team_to_be_routed: str | None,
|
|
177
|
-
area: str | None,
|
|
178
|
-
zendesk_ticket_id: str | None,
|
|
186
|
+
issue_data: CustomerIssueData,
|
|
179
187
|
) -> JiraObject:
|
|
180
188
|
"""Creates a Jira Incident issue of type Customer.
|
|
181
189
|
|
|
@@ -183,13 +191,7 @@ def create_issue_customer(
|
|
|
183
191
|
title (str): Summary of the issue
|
|
184
192
|
description (str): Description of the issue
|
|
185
193
|
reporter (str): Jira account id of the reporter
|
|
186
|
-
|
|
187
|
-
labels (list[str]): Labels to add to the issue
|
|
188
|
-
platform (str): Platform of the issue
|
|
189
|
-
business_impact (str): Business impact of the issue
|
|
190
|
-
team_to_be_routed (str): Team to be routed
|
|
191
|
-
area (str): Area of the issue
|
|
192
|
-
zendesk_ticket_id (str): Zendesk ticket id
|
|
194
|
+
issue_data (CustomerIssueData): Container with issue parameters
|
|
193
195
|
"""
|
|
194
196
|
issue = jira_client.create_issue(
|
|
195
197
|
issuetype="Incident",
|
|
@@ -197,13 +199,13 @@ def create_issue_customer(
|
|
|
197
199
|
description=description,
|
|
198
200
|
assignee=None,
|
|
199
201
|
reporter=reporter,
|
|
200
|
-
priority=priority,
|
|
201
|
-
labels=labels,
|
|
202
|
-
platform=platform,
|
|
203
|
-
business_impact=business_impact,
|
|
204
|
-
suggested_team_routing=team_to_be_routed,
|
|
205
|
-
|
|
206
|
-
|
|
202
|
+
priority=issue_data.priority,
|
|
203
|
+
labels=issue_data.labels,
|
|
204
|
+
platform=issue_data.platform,
|
|
205
|
+
business_impact=issue_data.business_impact,
|
|
206
|
+
suggested_team_routing=issue_data.team_to_be_routed,
|
|
207
|
+
zendesk_ticket_id=issue_data.zendesk_ticket_id,
|
|
208
|
+
incident_category=issue_data.incident_category,
|
|
207
209
|
)
|
|
208
210
|
check_issue_id(issue, title=title, reporter=reporter)
|
|
209
211
|
return issue
|
|
@@ -218,7 +220,7 @@ def create_issue_seller( # noqa: PLR0913, PLR0917
|
|
|
218
220
|
platform: str,
|
|
219
221
|
business_impact: str | None,
|
|
220
222
|
team_to_be_routed: str | None,
|
|
221
|
-
|
|
223
|
+
incident_category: str | None,
|
|
222
224
|
seller_contract_id: str | None,
|
|
223
225
|
is_key_account: bool | None, # noqa: FBT001
|
|
224
226
|
is_seller_in_golden_list: bool | None, # noqa: FBT001
|
|
@@ -235,7 +237,7 @@ def create_issue_seller( # noqa: PLR0913, PLR0917
|
|
|
235
237
|
platform (str): Platform of the issue
|
|
236
238
|
business_impact (str): Business impact of the issue
|
|
237
239
|
team_to_be_routed (str): Team to be routed
|
|
238
|
-
|
|
240
|
+
incident_category (str): Incident category of the issue
|
|
239
241
|
seller_contract_id (str): Seller contract id
|
|
240
242
|
is_key_account (bool): Is key account
|
|
241
243
|
is_seller_in_golden_list (bool): Is seller in golden list
|
|
@@ -252,11 +254,11 @@ def create_issue_seller( # noqa: PLR0913, PLR0917
|
|
|
252
254
|
platform=platform,
|
|
253
255
|
business_impact=business_impact,
|
|
254
256
|
suggested_team_routing=team_to_be_routed,
|
|
255
|
-
area=area,
|
|
256
257
|
seller_contract_id=seller_contract_id,
|
|
257
258
|
is_key_account=is_key_account,
|
|
258
259
|
is_seller_in_golden_list=is_seller_in_golden_list,
|
|
259
260
|
zoho_desk_ticket_id=zoho_desk_ticket_id,
|
|
261
|
+
incident_category=incident_category,
|
|
260
262
|
)
|
|
261
263
|
check_issue_id(issue, title=title, reporter=reporter)
|
|
262
264
|
return issue
|
|
@@ -36,18 +36,20 @@ def create_ticket(
|
|
|
36
36
|
# XXX Custom field with FireFighter ID/link?
|
|
37
37
|
# XXX Set affected environment custom field
|
|
38
38
|
# XXX Set custom field impacted area to group/domain?
|
|
39
|
-
|
|
39
|
+
# Map Impact priority (1-5) to JIRA priority (1-5), fallback to P1 for invalid values
|
|
40
|
+
priority: int = incident.priority.value if 1 <= incident.priority.value <= 5 else 1
|
|
40
41
|
issue = client.create_issue(
|
|
41
42
|
issuetype="Incident",
|
|
42
43
|
summary=incident.title,
|
|
43
44
|
description=f"""{incident.description}\n
|
|
44
45
|
\n
|
|
45
46
|
🧯 This incident has been created for a critical incident. Links below to Slack and {APP_DISPLAY_NAME}.\n
|
|
46
|
-
📦
|
|
47
|
+
📦 Incident category: {incident.incident_category.name} ({incident.incident_category.group.name})\n
|
|
47
48
|
{incident.priority.emoji} Priority: {incident.priority.name}\n""",
|
|
48
49
|
assignee=None,
|
|
49
50
|
reporter=account_id,
|
|
50
51
|
priority=priority,
|
|
52
|
+
incident_category=incident.incident_category.name,
|
|
51
53
|
)
|
|
52
54
|
issue_id = issue.get("id")
|
|
53
55
|
if issue_id is None:
|
firefighter/raid/utils.py
CHANGED
|
@@ -33,7 +33,7 @@ def get_domain_from_email(email: str) -> str:
|
|
|
33
33
|
if email.count("@") != 1:
|
|
34
34
|
msg = f"Invalid email: {email}"
|
|
35
35
|
raise ValueError(msg)
|
|
36
|
-
domain = email.
|
|
36
|
+
domain = email.rsplit("@", maxsplit=1)[-1]
|
|
37
37
|
if not domain:
|
|
38
38
|
msg = f"Invalid email: {email}"
|
|
39
39
|
raise ValueError(msg)
|
|
@@ -37,7 +37,7 @@ if TYPE_CHECKING:
|
|
|
37
37
|
"zoho": "https://crmplus.zoho.eu/mycrmlink/index.do/cxapp/agent/mycompany/all/tickets/details/123456789",
|
|
38
38
|
"platform": "FR",
|
|
39
39
|
"reporter_email": "john.doe@mycompany.com",
|
|
40
|
-
"
|
|
40
|
+
"incident_category": "Payment Processing",
|
|
41
41
|
"project": "SBI",
|
|
42
42
|
"labels": ["originBot", "ProductsMerge"],
|
|
43
43
|
"environments": ["PRD", "STG"],
|
firefighter/slack/admin.py
CHANGED
|
@@ -81,7 +81,7 @@ class ConversationAdmin(admin.ModelAdmin[Conversation]):
|
|
|
81
81
|
list_max_show_all = 500
|
|
82
82
|
search_fields = ["channel_id", "name", "tag"]
|
|
83
83
|
|
|
84
|
-
autocomplete_fields = ["
|
|
84
|
+
autocomplete_fields = ["incident_categories", "members"]
|
|
85
85
|
|
|
86
86
|
def get_queryset(self, request: HttpRequest) -> QuerySet[Conversation]:
|
|
87
87
|
"""Restrict the queryset to only include conversations that are not IncidentChannels. Incident channels are managed in the IncidentChannelAdmin."""
|
|
@@ -155,7 +155,7 @@ class IncidentChannelAdmin(ConversationAdmin):
|
|
|
155
155
|
]
|
|
156
156
|
list_max_show_all = 500
|
|
157
157
|
search_fields = ["channel_id", "name", "incident__id"]
|
|
158
|
-
exclude = ("
|
|
158
|
+
exclude = ("incident_categories",)
|
|
159
159
|
readonly_fields = ("members",)
|
|
160
160
|
|
|
161
161
|
def get_queryset(self, request: HttpRequest) -> QuerySet[IncidentChannel]:
|
|
@@ -208,7 +208,7 @@ class UserGroupAdmin(admin.ModelAdmin[UserGroup]):
|
|
|
208
208
|
"updated_at",
|
|
209
209
|
)
|
|
210
210
|
|
|
211
|
-
autocomplete_fields = ["
|
|
211
|
+
autocomplete_fields = ["incident_categories", "members"]
|
|
212
212
|
search_fields = ["name", "handle", "description", "usergroup_id", "tag"]
|
|
213
213
|
|
|
214
214
|
fieldsets = (
|
|
@@ -226,7 +226,7 @@ class UserGroupAdmin(admin.ModelAdmin[UserGroup]):
|
|
|
226
226
|
)
|
|
227
227
|
},
|
|
228
228
|
),
|
|
229
|
-
(_("Firefighter attributes"), {"fields": ("tag", "
|
|
229
|
+
(_("Firefighter attributes"), {"fields": ("tag", "incident_categories", "created_at", "updated_at")}),
|
|
230
230
|
)
|
|
231
231
|
|
|
232
232
|
def save_model(
|
|
@@ -317,7 +317,7 @@ class UserGroupAdmin(admin.ModelAdmin[UserGroup]):
|
|
|
317
317
|
|
|
318
318
|
|
|
319
319
|
class UserGroupInline(admin.StackedInline[UserGroup, Any]):
|
|
320
|
-
model = UserGroup.
|
|
320
|
+
model = UserGroup.incident_categories.through # type: ignore[assignment]
|
|
321
321
|
show_change_link = True
|
|
322
322
|
extra = 0
|
|
323
323
|
verbose_name = "Slack User Group"
|
|
@@ -344,7 +344,7 @@ class IncidentChannelInline(admin.StackedInline[IncidentChannel, Any]):
|
|
|
344
344
|
|
|
345
345
|
|
|
346
346
|
class ConversationInline(admin.StackedInline[Conversation, Any]):
|
|
347
|
-
model: type[Conversation] = Conversation.
|
|
347
|
+
model: type[Conversation] = Conversation.incident_categories.through # type: ignore[assignment]
|
|
348
348
|
extra = 0
|
|
349
349
|
verbose_name = "Slack Conversation"
|
|
350
350
|
show_change_link = True
|
|
@@ -356,8 +356,8 @@ class ConversationInline(admin.StackedInline[Conversation, Any]):
|
|
|
356
356
|
|
|
357
357
|
# Add inlines to incidents models
|
|
358
358
|
firefighter.incidents.admin.user_inlines.append(SlackUserInline)
|
|
359
|
-
firefighter.incidents.admin.
|
|
360
|
-
firefighter.incidents.admin.
|
|
359
|
+
firefighter.incidents.admin.incident_category_inlines.append(UserGroupInline)
|
|
360
|
+
firefighter.incidents.admin.incident_category_inlines.append(ConversationInline)
|
|
361
361
|
firefighter.incidents.admin.incident_inlines.append(IncidentChannelInline)
|
|
362
362
|
|
|
363
363
|
# Register Slack models
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Django management command to switch Slack user IDs for test environment.
|
|
2
|
+
|
|
3
|
+
This command:
|
|
4
|
+
1. Fetches all users from the test Slack workspace
|
|
5
|
+
2. Generates a mapping file based on email addresses
|
|
6
|
+
3. Updates the database with test Slack user IDs
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
18
|
+
from slack_sdk import WebClient
|
|
19
|
+
from slack_sdk.errors import SlackApiError
|
|
20
|
+
|
|
21
|
+
from firefighter.incidents.models.user import User
|
|
22
|
+
from firefighter.slack.models.user import SlackUser
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Command(BaseCommand):
|
|
28
|
+
help = "Generate and apply Slack user mapping for test environment"
|
|
29
|
+
|
|
30
|
+
def add_arguments(self, parser: Any) -> None:
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--generate-only",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Only generate the mapping file, don't apply changes",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--mapping-file",
|
|
38
|
+
type=str,
|
|
39
|
+
default="slack_test_mapping.json",
|
|
40
|
+
help="Path to the mapping file (default: slack_test_mapping.json)",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--dry-run",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Show what would be changed without making actual changes",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--restore",
|
|
49
|
+
action="store_true",
|
|
50
|
+
help="Restore original Slack IDs from mapping file",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _raise_mapping_file_not_found(self, mapping_file: Path) -> None:
|
|
54
|
+
"""Raise error when mapping file is not found."""
|
|
55
|
+
error_msg = f"Mapping file {mapping_file} not found"
|
|
56
|
+
raise CommandError(error_msg)
|
|
57
|
+
|
|
58
|
+
def _raise_slack_token_not_configured(self) -> None:
|
|
59
|
+
"""Raise error when Slack token is not configured."""
|
|
60
|
+
raise CommandError("SLACK_BOT_TOKEN not configured")
|
|
61
|
+
|
|
62
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
63
|
+
"""Main command handler."""
|
|
64
|
+
mapping_file = Path(options["mapping_file"])
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
if options["restore"]:
|
|
68
|
+
# Restore original Slack IDs from mapping file
|
|
69
|
+
if not mapping_file.exists():
|
|
70
|
+
self._raise_mapping_file_not_found(mapping_file)
|
|
71
|
+
|
|
72
|
+
self.stdout.write("📄 Loading mapping from file...")
|
|
73
|
+
slack_mapping = self._load_mapping_file(mapping_file)
|
|
74
|
+
|
|
75
|
+
self.stdout.write("🔄 Restoring original Slack IDs...")
|
|
76
|
+
updated_count = self._restore_mapping(slack_mapping, dry_run=options["dry_run"])
|
|
77
|
+
|
|
78
|
+
if options["dry_run"]:
|
|
79
|
+
self.stdout.write(
|
|
80
|
+
self.style.WARNING(f"🔍 DRY RUN: Would restore {updated_count} users")
|
|
81
|
+
)
|
|
82
|
+
else:
|
|
83
|
+
self.stdout.write(
|
|
84
|
+
self.style.SUCCESS(f"✅ Restored {updated_count} users to original Slack IDs")
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if not settings.SLACK_BOT_TOKEN:
|
|
89
|
+
self._raise_slack_token_not_configured()
|
|
90
|
+
|
|
91
|
+
# Step 1: Generate mapping by querying test Slack workspace
|
|
92
|
+
self.stdout.write("🔍 Fetching users from test Slack workspace...")
|
|
93
|
+
slack_mapping = self._generate_slack_mapping()
|
|
94
|
+
|
|
95
|
+
# Step 2: Save mapping to file
|
|
96
|
+
self._save_mapping_file(slack_mapping, mapping_file)
|
|
97
|
+
self.stdout.write(
|
|
98
|
+
self.style.SUCCESS(f"✅ Mapping file saved to {mapping_file}")
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if options["generate_only"]:
|
|
102
|
+
self.stdout.write("📄 Generation complete. Use --apply to update database.")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Step 3: Apply mapping to database
|
|
106
|
+
self.stdout.write("📝 Applying mapping to database...")
|
|
107
|
+
updated_count = self._apply_mapping(slack_mapping, dry_run=options["dry_run"])
|
|
108
|
+
|
|
109
|
+
if options["dry_run"]:
|
|
110
|
+
self.stdout.write(
|
|
111
|
+
self.style.WARNING(f"🔍 DRY RUN: Would update {updated_count} users")
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
self.stdout.write(
|
|
115
|
+
self.style.SUCCESS(f"✅ Updated {updated_count} users with test Slack IDs")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
except SlackApiError as e:
|
|
119
|
+
error_msg = f"Slack API error: {e.response['error']}"
|
|
120
|
+
raise CommandError(error_msg) from e
|
|
121
|
+
except Exception as e:
|
|
122
|
+
error_msg = f"Error: {e}"
|
|
123
|
+
raise CommandError(error_msg) from e
|
|
124
|
+
|
|
125
|
+
def _generate_slack_mapping(self) -> dict[str, dict[str, Any]]:
|
|
126
|
+
"""Generate mapping by querying test Slack workspace."""
|
|
127
|
+
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
|
128
|
+
|
|
129
|
+
# Get all users from test Slack workspace
|
|
130
|
+
try:
|
|
131
|
+
response = client.users_list()
|
|
132
|
+
slack_users = response["members"]
|
|
133
|
+
except SlackApiError as e:
|
|
134
|
+
error_msg = f"Failed to fetch Slack users: {e.response['error']}"
|
|
135
|
+
raise CommandError(error_msg) from e
|
|
136
|
+
|
|
137
|
+
# Create mapping: email -> test_slack_id
|
|
138
|
+
slack_email_to_id = {}
|
|
139
|
+
for slack_user in slack_users:
|
|
140
|
+
if slack_user.get("deleted") or slack_user.get("is_bot"):
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
profile = slack_user.get("profile", {})
|
|
144
|
+
email = profile.get("email")
|
|
145
|
+
if email:
|
|
146
|
+
slack_email_to_id[email.lower()] = slack_user["id"]
|
|
147
|
+
|
|
148
|
+
self.stdout.write(f"📧 Found {len(slack_email_to_id)} users with emails in test Slack")
|
|
149
|
+
|
|
150
|
+
# Get all users from database
|
|
151
|
+
db_users = User.objects.exclude(email__isnull=True).exclude(email="")
|
|
152
|
+
|
|
153
|
+
# Generate mapping for users that exist in both places
|
|
154
|
+
mapping = {}
|
|
155
|
+
matched_count = 0
|
|
156
|
+
not_found_count = 0
|
|
157
|
+
|
|
158
|
+
for db_user in db_users:
|
|
159
|
+
if db_user.email and db_user.email.lower() in slack_email_to_id:
|
|
160
|
+
test_slack_id = slack_email_to_id[db_user.email.lower()]
|
|
161
|
+
# Get current slack_id from SlackUser relation
|
|
162
|
+
current_slack_id = None
|
|
163
|
+
if hasattr(db_user, "slack_user") and db_user.slack_user:
|
|
164
|
+
current_slack_id = db_user.slack_user.slack_id
|
|
165
|
+
|
|
166
|
+
mapping[db_user.email] = {
|
|
167
|
+
"original_slack_id": current_slack_id,
|
|
168
|
+
"test_slack_id": test_slack_id,
|
|
169
|
+
"name": f"{db_user.first_name} {db_user.last_name}".strip(),
|
|
170
|
+
}
|
|
171
|
+
matched_count += 1
|
|
172
|
+
else:
|
|
173
|
+
not_found_count += 1
|
|
174
|
+
|
|
175
|
+
self.stdout.write(f"🎯 Matched {matched_count} users between database and test Slack")
|
|
176
|
+
if not_found_count > 0:
|
|
177
|
+
self.stdout.write(f"⚠️ {not_found_count} database users not found in test Slack")
|
|
178
|
+
return mapping
|
|
179
|
+
|
|
180
|
+
def _save_mapping_file(self, mapping: dict[str, Any], file_path: Path) -> None:
|
|
181
|
+
"""Save mapping to JSON file."""
|
|
182
|
+
with file_path.open("w", encoding="utf-8") as f:
|
|
183
|
+
json.dump(mapping, f, indent=2, ensure_ascii=False)
|
|
184
|
+
|
|
185
|
+
def _load_mapping_file(self, file_path: Path) -> dict[str, Any]:
|
|
186
|
+
"""Load mapping from JSON file."""
|
|
187
|
+
with file_path.open(encoding="utf-8") as f:
|
|
188
|
+
return json.load(f)
|
|
189
|
+
|
|
190
|
+
def _apply_mapping(self, mapping: dict[str, Any], *, dry_run: bool = False) -> int:
|
|
191
|
+
"""Apply the mapping to update database users."""
|
|
192
|
+
updated_count = 0
|
|
193
|
+
|
|
194
|
+
for email, user_data in mapping.items():
|
|
195
|
+
test_slack_id = user_data["test_slack_id"]
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
user = User.objects.get(email=email)
|
|
199
|
+
|
|
200
|
+
# Get current slack_id from SlackUser relation
|
|
201
|
+
current_slack_id = None
|
|
202
|
+
if hasattr(user, "slack_user") and user.slack_user:
|
|
203
|
+
current_slack_id = user.slack_user.slack_id
|
|
204
|
+
|
|
205
|
+
if current_slack_id == test_slack_id:
|
|
206
|
+
continue # Already has the test ID
|
|
207
|
+
|
|
208
|
+
if dry_run:
|
|
209
|
+
self.stdout.write(
|
|
210
|
+
f"🔄 Would update {email}: {current_slack_id} -> {test_slack_id}"
|
|
211
|
+
)
|
|
212
|
+
elif hasattr(user, "slack_user") and user.slack_user:
|
|
213
|
+
user.slack_user.slack_id = test_slack_id
|
|
214
|
+
user.slack_user.save(update_fields=["slack_id"])
|
|
215
|
+
self.stdout.write(f"✅ Updated {email}: {current_slack_id} -> {test_slack_id}")
|
|
216
|
+
else:
|
|
217
|
+
# Create new SlackUser if it doesn't exist
|
|
218
|
+
SlackUser.objects.create(user=user, slack_id=test_slack_id)
|
|
219
|
+
self.stdout.write(f"✅ Created SlackUser for {email}: -> {test_slack_id}")
|
|
220
|
+
|
|
221
|
+
updated_count += 1
|
|
222
|
+
|
|
223
|
+
except User.DoesNotExist:
|
|
224
|
+
self.stdout.write(f"❌ User not found in database: {email}")
|
|
225
|
+
except (SlackUser.DoesNotExist, ValueError) as e:
|
|
226
|
+
self.stdout.write(f"❌ Error updating {email}: {e}")
|
|
227
|
+
|
|
228
|
+
return updated_count
|
|
229
|
+
|
|
230
|
+
def _restore_mapping(self, mapping: dict[str, Any], *, dry_run: bool = False) -> int:
|
|
231
|
+
"""Restore original Slack IDs from mapping."""
|
|
232
|
+
updated_count = 0
|
|
233
|
+
|
|
234
|
+
for email, user_data in mapping.items():
|
|
235
|
+
original_slack_id = user_data["original_slack_id"]
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
user = User.objects.get(email=email)
|
|
239
|
+
|
|
240
|
+
# Get current slack_id from SlackUser relation
|
|
241
|
+
current_slack_id = None
|
|
242
|
+
if hasattr(user, "slack_user") and user.slack_user:
|
|
243
|
+
current_slack_id = user.slack_user.slack_id
|
|
244
|
+
|
|
245
|
+
if current_slack_id == original_slack_id:
|
|
246
|
+
continue # Already has the original ID
|
|
247
|
+
|
|
248
|
+
if dry_run:
|
|
249
|
+
self.stdout.write(
|
|
250
|
+
f"🔄 Would restore {email}: {current_slack_id} -> {original_slack_id}"
|
|
251
|
+
)
|
|
252
|
+
elif hasattr(user, "slack_user") and user.slack_user:
|
|
253
|
+
if original_slack_id:
|
|
254
|
+
user.slack_user.slack_id = original_slack_id
|
|
255
|
+
user.slack_user.save(update_fields=["slack_id"])
|
|
256
|
+
self.stdout.write(f"✅ Restored {email}: {current_slack_id} -> {original_slack_id}")
|
|
257
|
+
else:
|
|
258
|
+
# If original was None, delete the SlackUser
|
|
259
|
+
user.slack_user.delete()
|
|
260
|
+
self.stdout.write(f"✅ Removed SlackUser for {email}: {current_slack_id} -> None")
|
|
261
|
+
else:
|
|
262
|
+
self.stdout.write(f"⚠️ User {email} has no SlackUser to restore")
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
updated_count += 1
|
|
266
|
+
|
|
267
|
+
except User.DoesNotExist:
|
|
268
|
+
self.stdout.write(f"❌ User not found in database: {email}")
|
|
269
|
+
except (SlackUser.DoesNotExist, ValueError) as e:
|
|
270
|
+
self.stdout.write(f"❌ Error restoring {email}: {e}")
|
|
271
|
+
|
|
272
|
+
return updated_count
|
|
@@ -210,7 +210,7 @@ class SlackMessageIncidentDeclaredAnnouncement(SlackMessageSurface):
|
|
|
210
210
|
def get_blocks(self) -> list[Block]:
|
|
211
211
|
fields = [
|
|
212
212
|
f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
|
|
213
|
-
f":package: *
|
|
213
|
+
f":package: *Incident category:* {self.incident.incident_category.name}",
|
|
214
214
|
f":speaking_head_in_silhouette: *Opened by:* {user_slack_handle_or_name(self.incident.created_by)}",
|
|
215
215
|
f":calendar: *Created at:* {date_time(self.incident.created_at)}",
|
|
216
216
|
f"{SLACK_APP_EMOJI} <{self.incident.status_page_url + '?utm_medium=FireFighter+Slack&utm_source=Slack+Message&utm_campaign=Announcement+Message+In+Channel'}|*{APP_DISPLAY_NAME} Status Page*>",
|
|
@@ -270,7 +270,7 @@ class SlackMessageIncidentDeclaredAnnouncementGeneral(SlackMessageSurface):
|
|
|
270
270
|
def get_blocks(self) -> list[Block]:
|
|
271
271
|
fields = [
|
|
272
272
|
f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
|
|
273
|
-
f":package: *
|
|
273
|
+
f":package: *Incident category:* {self.incident.incident_category.name}",
|
|
274
274
|
f"{SLACK_APP_EMOJI} <{self.incident.status_page_url + '?utm_medium=FireFighter+Slack&utm_source=Slack+Message&utm_campaign=Announcement+Message+General'}|*{APP_DISPLAY_NAME} Status Page*>",
|
|
275
275
|
f":speaking_head_in_silhouette: *Opened by:* {user_slack_handle_or_name(self.incident.created_by)}",
|
|
276
276
|
f":calendar: *Created at:* {date_time(self.incident.created_at)}",
|
|
@@ -483,10 +483,10 @@ class SlackMessageIncidentStatusUpdated(SlackMessageSurface):
|
|
|
483
483
|
text=f":rotating_light: *Priority:* {self.incident.priority.emoji} {self.incident.priority.name}"
|
|
484
484
|
)
|
|
485
485
|
)
|
|
486
|
-
if self.incident_update.
|
|
486
|
+
if self.incident_update.incident_category:
|
|
487
487
|
fields.append(
|
|
488
488
|
MarkdownTextObject(
|
|
489
|
-
text=f":package: *
|
|
489
|
+
text=f":package: *Incident category:* {self.incident.incident_category.group.name} - {self.incident.incident_category.name}"
|
|
490
490
|
)
|
|
491
491
|
)
|
|
492
492
|
if self.incident_update.environment:
|
|
@@ -735,7 +735,7 @@ class SlackMessagesSOS(SlackMessageSurface):
|
|
|
735
735
|
),
|
|
736
736
|
SectionBlock(
|
|
737
737
|
text=MarkdownTextObject(
|
|
738
|
-
text=f"Hello {self.usergroup_target}\nIncident responders have asked for help on a critical incident. This incident is a *{self.incident.priority.name}* and concerns the *{self.incident.
|
|
738
|
+
text=f"Hello {self.usergroup_target}\nIncident responders have asked for help on a critical incident. This incident is a *{self.incident.priority.name}* and concerns the *{self.incident.incident_category.group.name}/{self.incident.incident_category}* domain.\n\nPlease help the team working to mitigate it :lovecommunity:"
|
|
739
739
|
)
|
|
740
740
|
),
|
|
741
741
|
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Generated manually on 2025-08-19 - Add incident_categories fields to Slack models
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("incidents", "0022_add_incident_category_fields"),
|
|
10
|
+
("slack", "0004_alter_usergroup_components"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AddField(
|
|
15
|
+
model_name="conversation",
|
|
16
|
+
name="incident_categories",
|
|
17
|
+
field=models.ManyToManyField(
|
|
18
|
+
blank=True,
|
|
19
|
+
help_text="Incident categories that are related to this conversation. When creating a new incident with one of these incident categories, members will be invited to the conversation.",
|
|
20
|
+
to="incidents.incidentcategory",
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
migrations.AddField(
|
|
24
|
+
model_name="usergroup",
|
|
25
|
+
name="incident_categories",
|
|
26
|
+
field=models.ManyToManyField(
|
|
27
|
+
blank=True,
|
|
28
|
+
help_text="Incident created with this usergroup automatically add the group members to these incident categories.",
|
|
29
|
+
related_name="usergroups",
|
|
30
|
+
to="incidents.incidentcategory",
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
]
|