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
@@ -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 = ["components", "members"]
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 = ("components",)
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 = ["components", "members"]
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", "components", "created_at", "updated_at")}),
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.components.through # type: ignore[assignment]
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.components.through # type: ignore[assignment]
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.component_inlines.append(UserGroupInline)
360
- firefighter.incidents.admin.component_inlines.append(ConversationInline)
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: *Issue category:* {self.incident.component.name}",
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: *Issue category:* {self.incident.component.name}",
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.FIXED and status_changed:
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.component:
501
+ if self.incident_update.incident_category:
487
502
  fields.append(
488
503
  MarkdownTextObject(
489
- text=f":package: *Issue category:* {self.incident.component.group.name} - {self.incident.component.name}"
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.FIXED else ''}:warning:",
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.FIXED:
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.component.group.name}/{self.incident.component}* domain.\n\nPlease help the team working to mitigate it :lovecommunity:"
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 Component, User
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
- components = models.ManyToManyField["Component", "Component"](
192
- Component, related_name="conversations", blank=True
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.component.group.name} - {incident.component.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>"
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(
@@ -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 Component, User
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
- components = models.ManyToManyField["Component", "Component"](
171
- Component,
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.",
@@ -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.FIXED
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.component.deploy_warning
53
+ and incident.incident_category.deploy_warning
54
54
  )