Skip to content

Commit c52377a

Browse files
authored
Merge pull request #83 from sousa-dev/feat/traffic-module
feat(traffic): community traffic alerts API
2 parents c10fbb3 + fb859f4 commit c52377a

27 files changed

Lines changed: 1781 additions & 0 deletions

src/src/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
('seismic', True),
4242
('trails', True),
4343
('marketplace', True),
44+
('traffic', True),
4445
]
4546

4647
import os
@@ -85,6 +86,7 @@
8586
'DEFAULT_THROTTLE_RATES': {
8687
'directions': '30/min',
8788
'marketplace_write': '20/min',
89+
'traffic_write': '30/min',
8890
},
8991
}
9092

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:
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Enable traffic module on default island."""
2+
3+
from django.db import migrations
4+
5+
6+
def enable_traffic(apps, schema_editor):
7+
Island = apps.get_model('tenancy', 'Island')
8+
for island in Island.objects.filter(key='sao-miguel'):
9+
flags = dict(island.feature_flags or {})
10+
if not flags.get('traffic'):
11+
flags['traffic'] = True
12+
island.feature_flags = flags
13+
island.save(update_fields=['feature_flags'])
14+
15+
16+
class Migration(migrations.Migration):
17+
dependencies = [
18+
('tenancy', '0008_enable_marketplace_feature_flag'),
19+
]
20+
21+
operations = [
22+
migrations.RunPython(enable_traffic, migrations.RunPython.noop),
23+
]

src/tenancy/tests/test_bootstrap.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,19 @@ def test_enabled_modules_omits_marketplace_when_flag_false(self):
5454
island.save(update_fields=['feature_flags'])
5555
modules = enabled_modules(island)
5656
self.assertNotIn('marketplace', modules)
57+
58+
def test_enabled_modules_includes_traffic_when_flag_set(self):
59+
island = get_or_create_default_island()
60+
island.feature_flags = {**island.feature_flags, 'traffic': True, 'transit': True}
61+
island.save(update_fields=['feature_flags'])
62+
modules = enabled_modules(island)
63+
self.assertIn('traffic', modules)
64+
65+
def test_enabled_modules_omits_traffic_when_flag_false(self):
66+
island = get_or_create_default_island()
67+
flags = dict(island.feature_flags or {})
68+
flags['traffic'] = False
69+
island.feature_flags = flags
70+
island.save(update_fields=['feature_flags'])
71+
modules = enabled_modules(island)
72+
self.assertNotIn('traffic', modules)

src/traffic/README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Traffic module
2+
3+
Waze-style, community-sourced traffic + hazard alerts, scoped per island
4+
(`TenantScopedModel`). Reports are **public on create** — no moderation queue.
5+
Abuse is mitigated by per-session write throttling, confirm/deny voting,
6+
auto-expiry (category TTL), and admin takedown.
7+
8+
## Models (`models.py`)
9+
10+
- **`TrafficCategory`** — admin-managed pick list. `default_ttl_minutes` drives
11+
auto-expiry; `is_schedulable` gates the radar scheduling UI. Seeded for
12+
`sao-miguel` by migration `0002`.
13+
- **`TrafficReport`**`status ∈ {active, scheduled, expired, removed}`.
14+
Ownership is pseudonymous via `created_by_session_hash`. Carries
15+
`latitude/longitude`, optional `description/road`, `active_from/active_until`
16+
(scheduling), `expires_at`, and `confirm_count/deny_count`.
17+
- **`TrafficConfirmation`** — one `still_there` / `gone` vote per
18+
report+session (unique). `gone` votes ≥ `DENY_THRESHOLD` (3) expire a report.
19+
20+
## Lifecycle
21+
22+
```
23+
scheduled --(active_from ≤ now)--> active --(expires_at ≤ now | 3× deny)--> expired
24+
└--(owner/admin delete)--> removed
25+
```
26+
27+
Transitions are driven by `services.run_lifecycle()`, invoked every minute by
28+
the Celery beat task `traffic.run_lifecycle` (registered in migration `0003`).
29+
It runs **unscoped** across islands (time-driven transitions are tenant-agnostic).
30+
31+
## API (`/api/v3/traffic/`)
32+
33+
| Method | Path | Notes |
34+
|---|---|---|
35+
| GET | `/categories` | Pick list for the quick-report sheet. |
36+
| GET | `/reports` | Filters: `lat,lng,radius_km` (near-me, Haversine) or `bbox`, `category`, `include_scheduled`, `limit`. |
37+
| POST | `/reports` | Create. Throttled (`traffic_write`, 30/min per session). Location plausibility checked against island radius. |
38+
| GET | `/reports/{id}` | Single report. |
39+
| PATCH/DELETE | `/reports/{id}` | Owner (via `X-Session-Id`) or staff only. DELETE is a soft `removed`. |
40+
| POST | `/reports/{id}/confirm` | Body `{vote: still_there\|gone}`. Upserts the caller's vote. |
41+
42+
Writes identify the caller via the `X-Session-Id` header (hashed server-side).
43+
44+
## Seeding demo data
45+
46+
```bash
47+
cd src
48+
python manage.py seed_traffic_demo # ~10 active + 2 scheduled radars
49+
python manage.py seed_traffic_demo --clear # remove seeded rows first
50+
```
51+
52+
## Tests
53+
54+
```bash
55+
cd src && python manage.py test traffic
56+
```

src/traffic/__init__.py

Whitespace-only changes.

src/traffic/admin.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
from django.contrib import admin
2+
3+
from traffic.models import TrafficCategory, TrafficConfirmation, TrafficReport
4+
5+
6+
def _expire(modeladmin, request, queryset):
7+
queryset.update(status=TrafficReport.EXPIRED)
8+
9+
10+
_expire.short_description = 'Mark selected expired'
11+
12+
13+
def _remove(modeladmin, request, queryset):
14+
queryset.update(status=TrafficReport.REMOVED)
15+
16+
17+
_remove.short_description = 'Remove selected (takedown)'
18+
19+
20+
@admin.register(TrafficCategory)
21+
class TrafficCategoryAdmin(admin.ModelAdmin):
22+
list_display = ('name', 'slug', 'icon', 'default_ttl_minutes', 'is_schedulable', 'order', 'island')
23+
list_filter = ('island', 'is_schedulable')
24+
search_fields = ('name', 'slug')
25+
prepopulated_fields = {'slug': ('name',)}
26+
27+
28+
@admin.register(TrafficReport)
29+
class TrafficReportAdmin(admin.ModelAdmin):
30+
list_display = (
31+
'category', 'status', 'road', 'expires_at',
32+
'confirm_count', 'deny_count', 'created_at', 'island',
33+
)
34+
list_filter = ('island', 'status', 'category')
35+
search_fields = ('description', 'road')
36+
actions = [_expire, _remove]
37+
38+
39+
@admin.register(TrafficConfirmation)
40+
class TrafficConfirmationAdmin(admin.ModelAdmin):
41+
list_display = ('report', 'vote', 'island', 'created_at')
42+
list_filter = ('island', 'vote')

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/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.apps import AppConfig
2+
3+
4+
class TrafficConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'traffic'
7+
verbose_name = 'Traffic'

src/traffic/management/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)