Skip to content

Commit f1b7ae8

Browse files
committed
Add endpoint for forcing participants sync
1 parent a2c1c20 commit f1b7ae8

File tree

3 files changed

+149
-2
lines changed

3 files changed

+149
-2
lines changed

src/firetower/incidents/tests/test_views.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,83 @@ def test_retrieve_incident_does_not_fail_on_sync_error(self):
310310

311311
assert response.status_code == 200
312312
assert response.data["id"] == incident.incident_number
313+
314+
def test_sync_participants_endpoint(self):
315+
"""Test POST /api/ui/incidents/{id}/sync-participants/ forces sync"""
316+
incident = Incident.objects.create(
317+
title="Test Incident",
318+
status=IncidentStatus.ACTIVE,
319+
severity=IncidentSeverity.P1,
320+
)
321+
322+
with patch(
323+
"firetower.incidents.views.sync_incident_participants_from_slack"
324+
) as mock_sync:
325+
mock_sync.return_value = {
326+
"added": 3,
327+
"already_existed": 5,
328+
"errors": [],
329+
"skipped": False,
330+
}
331+
332+
self.client.force_authenticate(user=self.user)
333+
response = self.client.post(
334+
f"/api/ui/incidents/{incident.incident_number}/sync-participants/"
335+
)
336+
337+
assert response.status_code == 200
338+
assert response.data["success"] is True
339+
assert response.data["stats"]["added"] == 3
340+
assert response.data["stats"]["already_existed"] == 5
341+
mock_sync.assert_called_once_with(incident, force=True)
342+
343+
def test_sync_participants_endpoint_handles_errors(self):
344+
"""Test sync endpoint returns 500 on error"""
345+
incident = Incident.objects.create(
346+
title="Test Incident",
347+
status=IncidentStatus.ACTIVE,
348+
severity=IncidentSeverity.P1,
349+
)
350+
351+
with patch(
352+
"firetower.incidents.views.sync_incident_participants_from_slack"
353+
) as mock_sync:
354+
mock_sync.side_effect = Exception("Slack API error")
355+
356+
self.client.force_authenticate(user=self.user)
357+
response = self.client.post(
358+
f"/api/ui/incidents/{incident.incident_number}/sync-participants/"
359+
)
360+
361+
assert response.status_code == 500
362+
assert response.data["success"] is False
363+
assert "Slack API error" in response.data["error"]
364+
assert len(response.data["stats"]["errors"]) > 0
365+
366+
def test_sync_participants_endpoint_respects_privacy(self):
367+
"""Test sync endpoint returns 404 for private incidents user can't access"""
368+
other_user = User.objects.create_user(
369+
username="[email protected]",
370+
password="testpass123",
371+
)
372+
incident = Incident.objects.create(
373+
title="Private Incident",
374+
status=IncidentStatus.ACTIVE,
375+
severity=IncidentSeverity.P1,
376+
is_private=True,
377+
captain=other_user,
378+
)
379+
380+
self.client.force_authenticate(user=self.user)
381+
response = self.client.post(
382+
f"/api/ui/incidents/{incident.incident_number}/sync-participants/"
383+
)
384+
385+
assert response.status_code == 404
386+
387+
def test_sync_participants_endpoint_invalid_format(self):
388+
"""Test sync endpoint returns 400 for invalid incident ID"""
389+
self.client.force_authenticate(user=self.user)
390+
response = self.client.post("/api/ui/incidents/INVALID-123/sync-participants/")
391+
392+
assert response.status_code == 400

src/firetower/incidents/urls.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from django.urls import path
22

3-
from .views import incident_detail_ui, incident_list_ui
3+
from .views import incident_detail_ui, incident_list_ui, sync_incident_participants
44

55
urlpatterns = [
66
path("ui/incidents/", incident_list_ui, name="incident-list-ui"),
@@ -9,4 +9,9 @@
99
incident_detail_ui,
1010
name="incident-detail-ui",
1111
),
12+
path(
13+
"ui/incidents/<str:incident_id>/sync-participants/",
14+
sync_incident_participants,
15+
name="sync-incident-participants",
16+
),
1217
]

src/firetower/incidents/views.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.shortcuts import get_object_or_404
66
from rest_framework import generics
77
from rest_framework.exceptions import ValidationError
8+
from rest_framework.response import Response
89

910
from .models import Incident, filter_visible_to_user
1011
from .serializers import IncidentDetailUISerializer, IncidentListUISerializer
@@ -103,6 +104,67 @@ def get_object(self):
103104
return incident
104105

105106

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

0 commit comments

Comments
 (0)