Skip to content

Commit edd9d23

Browse files
hc-sousacursoragent
andcommitted
feat(traffic): v3 API — reports/categories/confirm, throttle, URL wiring
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 51be32d commit edd9d23

6 files changed

Lines changed: 449 additions & 0 deletions

File tree

src/src/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
path('api/v3/seismic/', include('seismic.urls_v3')),
2121
path('api/v3/trails/', include('trails.urls_v3')),
2222
path('api/v3/marketplace/', include('marketplace.urls_v3')),
23+
path('api/v3/traffic/', include('traffic.urls_v3')),
2324
]
2425

2526
if 'legal' in settings.INSTALLED_APPS:

src/traffic/api_v3.py

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
"""Traffic v3 API (function views, service-backed, instant-publish UGC)."""
2+
3+
from __future__ import annotations
4+
5+
from rest_framework import status
6+
from rest_framework.decorators import api_view, permission_classes, throttle_classes
7+
from rest_framework.permissions import AllowAny
8+
from rest_framework.request import Request
9+
from rest_framework.response import Response
10+
11+
from consent.services import hash_session_id
12+
from tenancy.services import for_island
13+
from traffic import services
14+
from traffic.serializers import ConfirmSerializer, ReportWriteSerializer
15+
from traffic.throttling import TrafficWriteThrottle
16+
17+
18+
def _require_island(request: Request) -> Response | None:
19+
if request.island is None:
20+
return Response(
21+
{'error': {'code': 'island_required', 'message': 'Island context required'}},
22+
status=status.HTTP_400_BAD_REQUEST,
23+
)
24+
return None
25+
26+
27+
def _error(code: str, message: str, http_status: int) -> Response:
28+
return Response({'error': {'code': code, 'message': message}}, status=http_status)
29+
30+
31+
def _session_id(request: Request) -> str:
32+
return (
33+
request.headers.get('X-Session-Id', '').strip()
34+
or str(request.GET.get('session_id', '')).strip()
35+
)
36+
37+
38+
def _is_staff(request: Request) -> bool:
39+
user = getattr(request, 'user', None)
40+
return bool(user and user.is_authenticated and user.is_staff)
41+
42+
43+
def _hash_or_empty(session_id: str, request: Request) -> str:
44+
if not session_id:
45+
return ''
46+
return hash_session_id(session_id, request.island.key)
47+
48+
49+
def _float_or_none(raw: str | None) -> float | None:
50+
if raw is None or str(raw).strip() == '':
51+
return None
52+
try:
53+
return float(raw)
54+
except ValueError:
55+
return None
56+
57+
58+
def _parse_bbox(raw: str | None) -> tuple[float, float, float, float] | None:
59+
if not raw:
60+
return None
61+
parts = raw.split(',')
62+
if len(parts) != 4:
63+
return None
64+
try:
65+
min_lng, min_lat, max_lng, max_lat = (float(p) for p in parts)
66+
except ValueError:
67+
return None
68+
return (min_lng, min_lat, max_lng, max_lat)
69+
70+
71+
@api_view(['GET'])
72+
@permission_classes([AllowAny])
73+
def categories_view(request: Request) -> Response:
74+
err = _require_island(request)
75+
if err:
76+
return err
77+
with for_island(request.island):
78+
return Response({'categories': services.list_categories()})
79+
80+
81+
@api_view(['GET', 'POST'])
82+
@permission_classes([AllowAny])
83+
@throttle_classes([TrafficWriteThrottle])
84+
def reports_view(request: Request) -> Response:
85+
err = _require_island(request)
86+
if err:
87+
return err
88+
89+
if request.method == 'GET':
90+
category = request.GET.get('category', '').strip() or None
91+
lat = _float_or_none(request.GET.get('lat'))
92+
lng = _float_or_none(request.GET.get('lng'))
93+
radius_km = _float_or_none(request.GET.get('radius_km'))
94+
bbox = _parse_bbox(request.GET.get('bbox'))
95+
include_scheduled = request.GET.get('include_scheduled', '').lower() in ('1', 'true', 'yes')
96+
try:
97+
limit = int(request.GET.get('limit', '100'))
98+
except ValueError:
99+
limit = 100
100+
with for_island(request.island):
101+
reports = services.list_reports(
102+
lat=lat, lng=lng, radius_km=radius_km, bbox=bbox,
103+
category=category, include_scheduled=include_scheduled, limit=limit,
104+
)
105+
return Response({'reports': reports})
106+
107+
serializer = ReportWriteSerializer(data=request.data)
108+
serializer.is_valid(raise_exception=True)
109+
data = serializer.validated_data
110+
session_id = data['session_id'].strip()
111+
if not session_id:
112+
return _error('session_required', 'session_id is required', status.HTTP_400_BAD_REQUEST)
113+
category_slug = (data.get('category_slug') or '').strip()
114+
if not category_slug:
115+
return _error('validation_error', 'category_slug is required', status.HTTP_400_BAD_REQUEST)
116+
latitude = data.get('latitude')
117+
longitude = data.get('longitude')
118+
if latitude is None or longitude is None:
119+
return _error('validation_error', 'latitude and longitude are required', status.HTTP_400_BAD_REQUEST)
120+
121+
session_hash = hash_session_id(session_id, request.island.key)
122+
with for_island(request.island):
123+
try:
124+
payload = services.create_report(
125+
island=request.island,
126+
session_hash=session_hash,
127+
category_slug=category_slug,
128+
latitude=latitude,
129+
longitude=longitude,
130+
description=data.get('description', ''),
131+
road=data.get('road', ''),
132+
active_from=data.get('active_from'),
133+
active_until=data.get('active_until'),
134+
)
135+
except services.CategoryNotFound:
136+
return _error('invalid_category', 'Unknown category', status.HTTP_400_BAD_REQUEST)
137+
except services.SchedulingNotAllowed:
138+
return _error(
139+
'scheduling_not_allowed',
140+
'This category does not support scheduled reports',
141+
status.HTTP_400_BAD_REQUEST,
142+
)
143+
except services.LocationImplausible:
144+
return _error(
145+
'location_implausible',
146+
'Coordinates fall outside the island',
147+
status.HTTP_422_UNPROCESSABLE_ENTITY,
148+
)
149+
return Response(payload, status=status.HTTP_201_CREATED)
150+
151+
152+
@api_view(['GET', 'PATCH', 'DELETE'])
153+
@permission_classes([AllowAny])
154+
@throttle_classes([TrafficWriteThrottle])
155+
def report_detail_view(request: Request, report_id: int) -> Response:
156+
err = _require_island(request)
157+
if err:
158+
return err
159+
160+
is_staff = _is_staff(request)
161+
162+
if request.method == 'GET':
163+
with for_island(request.island):
164+
payload = services.get_report(report_id, is_staff=is_staff)
165+
if payload is None:
166+
return _error('not_found', 'Report not found', status.HTTP_404_NOT_FOUND)
167+
return Response(payload)
168+
169+
if request.method == 'DELETE':
170+
session_hash = _hash_or_empty(_session_id(request), request)
171+
with for_island(request.island):
172+
try:
173+
result = services.soft_delete_report(
174+
report_id, session_hash=session_hash, is_staff=is_staff
175+
)
176+
except services.OwnershipError:
177+
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
178+
if result is None:
179+
return _error('not_found', 'Report not found', status.HTTP_404_NOT_FOUND)
180+
return Response(status=status.HTTP_204_NO_CONTENT)
181+
182+
# PATCH
183+
serializer = ReportWriteSerializer(data=request.data, partial=True)
184+
serializer.is_valid(raise_exception=True)
185+
data = serializer.validated_data
186+
write_session = data.get('session_id', '').strip() or _session_id(request)
187+
write_hash = _hash_or_empty(write_session, request)
188+
write_data = {k: v for k, v in data.items() if k != 'session_id'}
189+
with for_island(request.island):
190+
try:
191+
payload = services.update_report(
192+
report_id, session_hash=write_hash, is_staff=is_staff, data=write_data
193+
)
194+
except services.OwnershipError:
195+
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
196+
if payload is None:
197+
return _error('not_found', 'Report not found', status.HTTP_404_NOT_FOUND)
198+
return Response(payload)
199+
200+
201+
@api_view(['POST'])
202+
@permission_classes([AllowAny])
203+
@throttle_classes([TrafficWriteThrottle])
204+
def report_confirm_view(request: Request, report_id: int) -> Response:
205+
err = _require_island(request)
206+
if err:
207+
return err
208+
209+
serializer = ConfirmSerializer(data=request.data)
210+
serializer.is_valid(raise_exception=True)
211+
data = serializer.validated_data
212+
session_id = data['session_id'].strip()
213+
if not session_id:
214+
return _error('session_required', 'session_id is required', status.HTTP_400_BAD_REQUEST)
215+
session_hash = hash_session_id(session_id, request.island.key)
216+
with for_island(request.island):
217+
result = services.upsert_confirmation(
218+
report_id=report_id, session_hash=session_hash, vote=data['vote']
219+
)
220+
if result is None:
221+
return _error('not_found', 'Report not found or inactive', status.HTTP_404_NOT_FOUND)
222+
payload, created = result
223+
return Response(payload, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)

src/traffic/serializers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Request validation for traffic v3 writes (service layer builds responses)."""
2+
3+
from __future__ import annotations
4+
5+
from rest_framework import serializers
6+
7+
8+
class ReportWriteSerializer(serializers.Serializer):
9+
session_id = serializers.CharField(max_length=128)
10+
category_slug = serializers.SlugField(required=False, allow_blank=True)
11+
latitude = serializers.FloatField(required=False, allow_null=True)
12+
longitude = serializers.FloatField(required=False, allow_null=True)
13+
description = serializers.CharField(required=False, allow_blank=True, default='')
14+
road = serializers.CharField(max_length=160, required=False, allow_blank=True, default='')
15+
active_from = serializers.DateTimeField(required=False, allow_null=True)
16+
active_until = serializers.DateTimeField(required=False, allow_null=True)
17+
18+
19+
class ConfirmSerializer(serializers.Serializer):
20+
session_id = serializers.CharField(max_length=128)
21+
vote = serializers.ChoiceField(choices=['still_there', 'gone'])

0 commit comments

Comments
 (0)