Skip to content

Commit f4a6f5b

Browse files
committed
Add endpoint for forcing participants sync
1 parent 4df38ab commit f4a6f5b

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
@@ -311,3 +311,83 @@ def test_retrieve_incident_does_not_fail_on_sync_error(self):
311311

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

1011
from .models import Incident, filter_visible_to_user
1112
from .serializers import IncidentDetailUISerializer, IncidentListUISerializer
@@ -104,6 +105,67 @@ def get_object(self) -> Incident:
104105
return incident
105106

106107

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

0 commit comments

Comments
 (0)