Skip to content

Commit f6d9e69

Browse files
committed
Move sync endpoint to Service API and add IncidentPermission
1 parent 740cf42 commit f6d9e69

File tree

4 files changed

+87
-72
lines changed

4 files changed

+87
-72
lines changed

src/firetower/incidents/permissions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class IncidentPermission(permissions.BasePermission):
2828
2929
- READ: User must have visibility to the incident (respects is_visible_to_user)
3030
- CREATE: Any authenticated user can create
31-
- UPDATE: Same as read permissions (anyone who can see can update)
31+
- UPDATE/POST: Same as read permissions (anyone who can see can update/interact)
3232
"""
3333

3434
def has_permission(self, request: Request, view: "APIView") -> bool:
@@ -46,8 +46,8 @@ def has_object_permission(
4646
if request.method in permissions.SAFE_METHODS:
4747
return obj.is_visible_to_user(user)
4848

49-
# UPDATE: Same as read permissions
50-
if request.method == "PATCH":
49+
# UPDATE/POST: Same as read permissions (for PATCH updates and POST actions)
50+
if request.method in ["PATCH", "POST"]:
5151
return obj.is_visible_to_user(user)
5252

5353
return False

src/firetower/incidents/tests/test_views.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ def test_retrieve_incident_does_not_fail_on_sync_error(self):
316316
assert response.data["id"] == incident.incident_number
317317

318318
def test_sync_participants_endpoint(self):
319-
"""Test POST /api/ui/incidents/{id}/sync-participants/ forces sync"""
319+
"""Test POST /api/incidents/{id}/sync-participants/ forces sync"""
320320
incident = Incident.objects.create(
321321
title="Test Incident",
322322
status=IncidentStatus.ACTIVE,
@@ -333,7 +333,7 @@ def test_sync_participants_endpoint(self):
333333

334334
self.client.force_authenticate(user=self.user)
335335
response = self.client.post(
336-
f"/api/ui/incidents/{incident.incident_number}/sync-participants/"
336+
f"/api/incidents/{incident.incident_number}/sync-participants/"
337337
)
338338

339339
assert response.status_code == 200
@@ -357,7 +357,7 @@ def test_sync_participants_endpoint_handles_errors(self):
357357

358358
self.client.force_authenticate(user=self.user)
359359
response = self.client.post(
360-
f"/api/ui/incidents/{incident.incident_number}/sync-participants/"
360+
f"/api/incidents/{incident.incident_number}/sync-participants/"
361361
)
362362

363363
assert response.status_code == 500
@@ -385,15 +385,15 @@ def test_sync_participants_endpoint_respects_privacy(self):
385385

386386
self.client.force_authenticate(user=self.user)
387387
response = self.client.post(
388-
f"/api/ui/incidents/{incident.incident_number}/sync-participants/"
388+
f"/api/incidents/{incident.incident_number}/sync-participants/"
389389
)
390390

391391
assert response.status_code == 404
392392

393393
def test_sync_participants_endpoint_invalid_format(self):
394394
"""Test sync endpoint returns 400 for invalid incident ID"""
395395
self.client.force_authenticate(user=self.user)
396-
response = self.client.post("/api/ui/incidents/INVALID-123/sync-participants/")
396+
response = self.client.post("/api/incidents/INVALID-123/sync-participants/")
397397

398398
assert response.status_code == 400
399399

src/firetower/incidents/urls.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@
1616
incident_detail_ui,
1717
name="incident-detail-ui",
1818
),
19-
path(
20-
"ui/incidents/<str:incident_id>/sync-participants/",
21-
sync_incident_participants,
22-
name="sync-incident-participants",
23-
),
2419
# Service API endpoints
2520
path(
2621
"incidents/",
@@ -32,4 +27,9 @@
3227
IncidentRetrieveUpdateAPIView.as_view(),
3328
name="incident-retrieve-update",
3429
),
30+
path(
31+
"incidents/<str:incident_id>/sync-participants/",
32+
sync_incident_participants,
33+
name="sync-incident-participants",
34+
),
3535
]

src/firetower/incidents/views.py

Lines changed: 74 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -113,68 +113,9 @@ def get_object(self) -> Incident:
113113
return incident
114114

115115

116-
class SyncIncidentParticipantsView(generics.GenericAPIView):
117-
"""
118-
Force sync incident participants from Slack channel.
119-
120-
POST /api/ui/incidents/{incident_id}/sync-participants/
121-
122-
Accepts incident_id in format: INC-2000
123-
Returns sync statistics.
124-
Bypasses throttle (force=True).
125-
126-
Authentication enforced via DEFAULT_PERMISSION_CLASSES in settings.
127-
"""
128-
129-
def get_queryset(self) -> QuerySet[Incident]:
130-
return Incident.objects.all()
131-
132-
def get_incident(self) -> Incident:
133-
incident_id = self.kwargs["incident_id"]
134-
project_key = settings.PROJECT_KEY
135-
136-
incident_pattern = rf"^{re.escape(project_key)}-(\d+)$"
137-
match = re.match(incident_pattern, incident_id)
138-
139-
if not match:
140-
raise ValidationError(
141-
f"Invalid incident ID format. Expected format: {project_key}-<number> (e.g., {project_key}-123)"
142-
)
143-
144-
numeric_id = int(match.group(1))
145-
queryset = self.get_queryset()
146-
queryset = filter_visible_to_user(queryset, self.request.user)
147-
148-
return get_object_or_404(queryset, id=numeric_id)
149-
150-
def post(self, request: Request, incident_id: str) -> Response:
151-
incident = self.get_incident()
152-
153-
try:
154-
stats = sync_incident_participants_from_slack(incident, force=True)
155-
return Response({"success": True, "stats": asdict(stats)})
156-
except Exception as e:
157-
logger.error(
158-
f"Failed to force sync participants for incident {incident.id}: {e}",
159-
exc_info=True,
160-
)
161-
error_stats = ParticipantsSyncStats(
162-
errors=["Failed to sync participants from Slack"]
163-
)
164-
return Response(
165-
{
166-
"success": False,
167-
"error": "Failed to sync participants from Slack",
168-
"stats": asdict(error_stats),
169-
},
170-
status=500,
171-
)
172-
173-
174116
# View aliases for cleaner URL imports
175117
incident_list_ui = IncidentListUIView.as_view()
176118
incident_detail_ui = IncidentDetailUIView.as_view()
177-
sync_incident_participants = SyncIncidentParticipantsView.as_view()
178119

179120

180121
class IncidentListCreateAPIView(generics.ListCreateAPIView):
@@ -275,3 +216,77 @@ def get_object(self) -> Incident:
275216
)
276217

277218
return obj
219+
220+
221+
class SyncIncidentParticipantsView(generics.GenericAPIView):
222+
"""
223+
Force sync incident participants from Slack channel.
224+
225+
POST /api/incidents/{incident_id}/sync-participants/
226+
227+
Accepts incident_id in format: INC-2000
228+
Returns sync statistics.
229+
Bypasses throttle (force=True).
230+
231+
Uses IncidentPermission for access control.
232+
"""
233+
234+
permission_classes = [IncidentPermission]
235+
236+
def get_queryset(self) -> QuerySet[Incident]:
237+
return Incident.objects.all()
238+
239+
def get_object(self) -> Incident:
240+
"""
241+
Parse INC-2000 format and check permissions.
242+
243+
Returns incident if found and user has access, otherwise 404.
244+
"""
245+
incident_id = self.kwargs["incident_id"]
246+
project_key = settings.PROJECT_KEY
247+
248+
incident_pattern = rf"^{re.escape(project_key)}-(\d+)$"
249+
match = re.match(incident_pattern, incident_id)
250+
251+
if not match:
252+
raise ValidationError(
253+
f"Invalid incident ID format. Expected format: {project_key}-<number> (e.g., {project_key}-123)"
254+
)
255+
256+
numeric_id = int(match.group(1))
257+
queryset = self.get_queryset()
258+
queryset = filter_visible_to_user(queryset, self.request.user)
259+
260+
obj = get_object_or_404(queryset, id=numeric_id)
261+
262+
# Check object permissions
263+
self.check_object_permissions(self.request, obj)
264+
265+
return obj
266+
267+
def post(self, request: Request, incident_id: str) -> Response:
268+
incident = self.get_object()
269+
270+
try:
271+
stats = sync_incident_participants_from_slack(incident, force=True)
272+
return Response({"success": True, "stats": asdict(stats)})
273+
except Exception as e:
274+
logger.error(
275+
f"Failed to force sync participants for incident {incident.id}: {e}",
276+
exc_info=True,
277+
)
278+
error_stats = ParticipantsSyncStats(
279+
errors=["Failed to sync participants from Slack"]
280+
)
281+
return Response(
282+
{
283+
"success": False,
284+
"error": "Failed to sync participants from Slack",
285+
"stats": asdict(error_stats),
286+
},
287+
status=500,
288+
)
289+
290+
291+
# View alias for sync endpoint
292+
sync_incident_participants = SyncIncidentParticipantsView.as_view()

0 commit comments

Comments
 (0)