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
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,13 +210,28 @@ 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*>",
|
|
217
217
|
]
|
|
218
218
|
if hasattr(self.incident, "jira_ticket") and self.incident.jira_ticket:
|
|
219
219
|
fields.append(f":jira_new: <{self.incident.jira_ticket.url}|*Jira ticket*>")
|
|
220
|
+
|
|
221
|
+
# Add custom fields if present
|
|
222
|
+
if hasattr(self.incident, "custom_fields") and self.incident.custom_fields:
|
|
223
|
+
custom_fields = self.incident.custom_fields
|
|
224
|
+
if custom_fields.get("zendesk_ticket_id"):
|
|
225
|
+
fields.append(f":ticket: *Zendesk Ticket:* {custom_fields['zendesk_ticket_id']}")
|
|
226
|
+
if custom_fields.get("seller_contract_id"):
|
|
227
|
+
fields.append(f":memo: *Seller Contract:* {custom_fields['seller_contract_id']}")
|
|
228
|
+
if custom_fields.get("zoho_desk_ticket_id"):
|
|
229
|
+
fields.append(f":ticket: *Zoho Desk Ticket:* {custom_fields['zoho_desk_ticket_id']}")
|
|
230
|
+
if custom_fields.get("is_key_account") is True:
|
|
231
|
+
fields.append(":star: *Key Account*")
|
|
232
|
+
if custom_fields.get("is_seller_in_golden_list") is True:
|
|
233
|
+
fields.append(":medal: *Golden List Seller*")
|
|
234
|
+
|
|
220
235
|
blocks: list[Block] = [
|
|
221
236
|
SectionBlock(
|
|
222
237
|
text=f"{self.incident.priority.emoji} {self.incident.priority.name} - A new incident has been declared:"
|
|
@@ -270,7 +285,7 @@ class SlackMessageIncidentDeclaredAnnouncementGeneral(SlackMessageSurface):
|
|
|
270
285
|
def get_blocks(self) -> list[Block]:
|
|
271
286
|
fields = [
|
|
272
287
|
f"{self.incident.priority.emoji} *Priority:* {self.incident.priority.name}",
|
|
273
|
-
f":package: *
|
|
288
|
+
f":package: *Incident category:* {self.incident.incident_category.name}",
|
|
274
289
|
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
290
|
f":speaking_head_in_silhouette: *Opened by:* {user_slack_handle_or_name(self.incident.created_by)}",
|
|
276
291
|
f":calendar: *Created at:* {date_time(self.incident.created_at)}",
|
|
@@ -442,7 +457,7 @@ class SlackMessageIncidentStatusUpdated(SlackMessageSurface):
|
|
|
442
457
|
|
|
443
458
|
if self.in_channel:
|
|
444
459
|
self.title_text = "A new incident update has been posted"
|
|
445
|
-
elif incident.status == IncidentStatus.
|
|
460
|
+
elif incident.status == IncidentStatus.MITIGATED and status_changed:
|
|
446
461
|
self.title_text = f":large_green_circle: Incident #{incident.slack_channel_name} has been {incident.status.label}. :large_green_circle:"
|
|
447
462
|
elif old_priority is not None and old_priority.value > 3:
|
|
448
463
|
self.title_text = f"Incident #{incident.slack_channel_name} has escalated from {old_priority.name} to {incident.priority.name}."
|
|
@@ -483,10 +498,10 @@ class SlackMessageIncidentStatusUpdated(SlackMessageSurface):
|
|
|
483
498
|
text=f":rotating_light: *Priority:* {self.incident.priority.emoji} {self.incident.priority.name}"
|
|
484
499
|
)
|
|
485
500
|
)
|
|
486
|
-
if self.incident_update.
|
|
501
|
+
if self.incident_update.incident_category:
|
|
487
502
|
fields.append(
|
|
488
503
|
MarkdownTextObject(
|
|
489
|
-
text=f":package: *
|
|
504
|
+
text=f":package: *Incident category:* {self.incident.incident_category.group.name} - {self.incident.incident_category.name}"
|
|
490
505
|
)
|
|
491
506
|
)
|
|
492
507
|
if self.incident_update.environment:
|
|
@@ -509,7 +524,7 @@ class SlackMessageIncidentStatusUpdated(SlackMessageSurface):
|
|
|
509
524
|
value=str(self.incident.id),
|
|
510
525
|
action_id=UpdateStatusModal.open_action,
|
|
511
526
|
)
|
|
512
|
-
if self.in_channel
|
|
527
|
+
if self.in_channel and self.incident.status != IncidentStatus.CLOSED
|
|
513
528
|
else None
|
|
514
529
|
),
|
|
515
530
|
),
|
|
@@ -691,7 +706,7 @@ class SlackMessageDeployWarning(SlackMessageSurface):
|
|
|
691
706
|
blocks = [
|
|
692
707
|
HeaderBlock(
|
|
693
708
|
text=PlainTextObject(
|
|
694
|
-
text=f":warning: Deploy warning {'(Mitigated) ' if self.incident.status == IncidentStatus.
|
|
709
|
+
text=f":warning: Deploy warning {'(Mitigated) ' if self.incident.status == IncidentStatus.MITIGATED else ''}:warning:",
|
|
695
710
|
emoji=True,
|
|
696
711
|
)
|
|
697
712
|
),
|
|
@@ -702,7 +717,7 @@ class SlackMessageDeployWarning(SlackMessageSurface):
|
|
|
702
717
|
),
|
|
703
718
|
]
|
|
704
719
|
|
|
705
|
-
if self.incident.status >= IncidentStatus.
|
|
720
|
+
if self.incident.status >= IncidentStatus.MITIGATED:
|
|
706
721
|
blocks.extend(
|
|
707
722
|
[
|
|
708
723
|
SectionBlock(
|
|
@@ -735,7 +750,7 @@ class SlackMessagesSOS(SlackMessageSurface):
|
|
|
735
750
|
),
|
|
736
751
|
SectionBlock(
|
|
737
752
|
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.
|
|
753
|
+
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
754
|
)
|
|
740
755
|
),
|
|
741
756
|
]
|
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Generated manually on 2025-08-19 - Copy component relationships to incident_categories
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def copy_components_to_incident_categories(apps, schema_editor):
|
|
7
|
+
"""Copy all component M2M relationships to incident_categories M2M relationships"""
|
|
8
|
+
Conversation = apps.get_model("slack", "Conversation")
|
|
9
|
+
UserGroup = apps.get_model("slack", "UserGroup")
|
|
10
|
+
|
|
11
|
+
# Copy conversation components to incident_categories
|
|
12
|
+
conversations_updated = 0
|
|
13
|
+
for conversation in Conversation.objects.prefetch_related("components").all():
|
|
14
|
+
# Copy all component relationships to incident_categories (same UUIDs)
|
|
15
|
+
incident_category_ids = list(conversation.components.values_list("id", flat=True))
|
|
16
|
+
conversation.incident_categories.set(incident_category_ids)
|
|
17
|
+
if incident_category_ids:
|
|
18
|
+
conversations_updated += 1
|
|
19
|
+
|
|
20
|
+
# Copy usergroup components to incident_categories
|
|
21
|
+
usergroups_updated = 0
|
|
22
|
+
for usergroup in UserGroup.objects.prefetch_related("components").all():
|
|
23
|
+
incident_category_ids = list(usergroup.components.values_list("id", flat=True))
|
|
24
|
+
usergroup.incident_categories.set(incident_category_ids)
|
|
25
|
+
if incident_category_ids:
|
|
26
|
+
usergroups_updated += 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def reverse_copy_components_to_incident_categories(apps, schema_editor):
|
|
30
|
+
"""Reverse: copy incident_categories relationships back to components"""
|
|
31
|
+
Conversation = apps.get_model("slack", "Conversation")
|
|
32
|
+
UserGroup = apps.get_model("slack", "UserGroup")
|
|
33
|
+
|
|
34
|
+
# Copy conversation incident_categories back to components
|
|
35
|
+
for conversation in Conversation.objects.prefetch_related("incident_categories").all():
|
|
36
|
+
component_ids = list(conversation.incident_categories.values_list("id", flat=True))
|
|
37
|
+
conversation.components.set(component_ids)
|
|
38
|
+
|
|
39
|
+
# Copy usergroup incident_categories back to components
|
|
40
|
+
for usergroup in UserGroup.objects.prefetch_related("incident_categories").all():
|
|
41
|
+
component_ids = list(usergroup.incident_categories.values_list("id", flat=True))
|
|
42
|
+
usergroup.components.set(component_ids)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Migration(migrations.Migration):
|
|
46
|
+
|
|
47
|
+
dependencies = [
|
|
48
|
+
("slack", "0005_add_incident_categories_fields"),
|
|
49
|
+
("incidents", "0023_populate_incident_category_references"),
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
operations = [
|
|
53
|
+
migrations.RunPython(
|
|
54
|
+
copy_components_to_incident_categories,
|
|
55
|
+
reverse_copy_components_to_incident_categories,
|
|
56
|
+
),
|
|
57
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Generated manually on 2025-08-19 - Remove components fields from Slack models
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("slack", "0006_copy_components_to_incident_categories"),
|
|
10
|
+
("incidents", "0024_remove_component_fields_and_model"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.RemoveField(
|
|
15
|
+
model_name="conversation",
|
|
16
|
+
name="components",
|
|
17
|
+
),
|
|
18
|
+
migrations.RemoveField(
|
|
19
|
+
model_name="usergroup",
|
|
20
|
+
name="components",
|
|
21
|
+
),
|
|
22
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Generated by Django 4.2.23 on 2025-09-17 17:49
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
("incidents", "0026_alter_incidentcategory_options_and_more"),
|
|
10
|
+
("slack", "0007_remove_components_fields"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
operations = [
|
|
14
|
+
migrations.AlterField(
|
|
15
|
+
model_name="conversation",
|
|
16
|
+
name="incident_categories",
|
|
17
|
+
field=models.ManyToManyField(
|
|
18
|
+
blank=True,
|
|
19
|
+
related_name="conversations",
|
|
20
|
+
to="incidents.incidentcategory",
|
|
21
|
+
),
|
|
22
|
+
),
|
|
23
|
+
migrations.AlterField(
|
|
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 issue categories.",
|
|
29
|
+
related_name="usergroups",
|
|
30
|
+
to="incidents.incidentcategory",
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
]
|
|
@@ -11,7 +11,7 @@ from django_stubs_ext.db.models import TypedModelMeta
|
|
|
11
11
|
from slack_sdk.errors import SlackApiError
|
|
12
12
|
|
|
13
13
|
from firefighter.firefighter.utils import get_in
|
|
14
|
-
from firefighter.incidents.models import
|
|
14
|
+
from firefighter.incidents.models import IncidentCategory, User
|
|
15
15
|
from firefighter.slack.messages.base import SlackMessageStrategy, SlackMessageSurface
|
|
16
16
|
from firefighter.slack.models.user import SlackUser
|
|
17
17
|
from firefighter.slack.slack_app import DefaultWebClient, SlackApp, slack_client
|
|
@@ -188,8 +188,8 @@ class Conversation(models.Model):
|
|
|
188
188
|
updated_at = models.DateTimeField(auto_now=True)
|
|
189
189
|
|
|
190
190
|
members = models.ManyToManyField(User, blank=True)
|
|
191
|
-
|
|
192
|
-
|
|
191
|
+
incident_categories = models.ManyToManyField["IncidentCategory", "IncidentCategory"](
|
|
192
|
+
IncidentCategory, related_name="conversations", blank=True
|
|
193
193
|
)
|
|
194
194
|
tag = models.CharField(
|
|
195
195
|
max_length=80,
|
|
@@ -100,7 +100,7 @@ class IncidentChannel(Conversation):
|
|
|
100
100
|
self, client: WebClient = DefaultWebClient
|
|
101
101
|
) -> SlackResponse | None:
|
|
102
102
|
incident: Incident = self.incident
|
|
103
|
-
topic = f"Incident - {incident.priority.emoji} {incident.priority.name} - {incident.status.label} - {incident.
|
|
103
|
+
topic = f"Incident - {incident.priority.emoji} {incident.priority.name} - {incident.status.label} - {incident.incident_category.group.name} - {incident.incident_category.name} - {SLACK_APP_EMOJI} <{incident.status_page_url + '?utm_medium=FireFighter+Slack&utm_source=Slack+Topic&utm_campaign=Slack+Topic+Link'}| {APP_DISPLAY_NAME} Status>"
|
|
104
104
|
|
|
105
105
|
if len(topic) > 250:
|
|
106
106
|
logger.warning(
|
firefighter/slack/models/user.py
CHANGED
|
@@ -147,7 +147,7 @@ class SlackUserManager(models.Manager["SlackUser"]):
|
|
|
147
147
|
# If we have no Slack User, let's go ahead and create a User and its associated SlackUser
|
|
148
148
|
user, _created = User.objects.get_or_create(
|
|
149
149
|
email=email,
|
|
150
|
-
username=email.split("@")[0],
|
|
150
|
+
username=email.split("@", maxsplit=1)[0],
|
|
151
151
|
defaults={
|
|
152
152
|
"name": clean_user_info["name"],
|
|
153
153
|
},
|
|
@@ -8,7 +8,7 @@ from django.db import models
|
|
|
8
8
|
from django_stubs_ext.db.models import TypedModelMeta
|
|
9
9
|
|
|
10
10
|
from firefighter.firefighter.utils import get_first_in, get_in
|
|
11
|
-
from firefighter.incidents.models import
|
|
11
|
+
from firefighter.incidents.models import IncidentCategory, User
|
|
12
12
|
from firefighter.slack.slack_app import DefaultWebClient, SlackApp, slack_client
|
|
13
13
|
|
|
14
14
|
if TYPE_CHECKING:
|
|
@@ -167,8 +167,8 @@ class UserGroup(models.Model):
|
|
|
167
167
|
help_text="Is this an external group, from an external Slack Workspace? Corresponds to the `is_external` field in the Slack API.",
|
|
168
168
|
)
|
|
169
169
|
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
incident_categories = models.ManyToManyField["IncidentCategory", "IncidentCategory"](
|
|
171
|
+
IncidentCategory,
|
|
172
172
|
related_name="usergroups",
|
|
173
173
|
blank=True,
|
|
174
174
|
help_text="Incident created with this usergroup automatically add the group members to these issue categories.",
|
firefighter/slack/rules.py
CHANGED
|
@@ -31,7 +31,7 @@ def should_publish_in_general_channel(
|
|
|
31
31
|
):
|
|
32
32
|
# If it has just been Mitigated
|
|
33
33
|
if (
|
|
34
|
-
incident.status == IncidentStatus.
|
|
34
|
+
incident.status == IncidentStatus.MITIGATED
|
|
35
35
|
and incident_update is not None
|
|
36
36
|
and incident_update.status is not None
|
|
37
37
|
):
|
|
@@ -50,5 +50,5 @@ def should_publish_in_it_deploy_channel(incident: Incident) -> bool:
|
|
|
50
50
|
incident.environment.value == "PRD"
|
|
51
51
|
and incident.priority.value <= 1
|
|
52
52
|
and not incident.private
|
|
53
|
-
and incident.
|
|
53
|
+
and incident.incident_category.deploy_warning
|
|
54
54
|
)
|