Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
('seismic', True),
('trails', True),
('marketplace', True),
('traffic', True),
]

import os
Expand Down Expand Up @@ -85,6 +86,7 @@
'DEFAULT_THROTTLE_RATES': {
'directions': '30/min',
'marketplace_write': '20/min',
'traffic_write': '30/min',
},
}

Expand Down
1 change: 1 addition & 0 deletions src/src/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
path('api/v3/seismic/', include('seismic.urls_v3')),
path('api/v3/trails/', include('trails.urls_v3')),
path('api/v3/marketplace/', include('marketplace.urls_v3')),
path('api/v3/traffic/', include('traffic.urls_v3')),
]

if 'legal' in settings.INSTALLED_APPS:
Expand Down
23 changes: 23 additions & 0 deletions src/tenancy/migrations/0009_enable_traffic_feature_flag.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Enable traffic module on default island."""

from django.db import migrations


def enable_traffic(apps, schema_editor):
Island = apps.get_model('tenancy', 'Island')
for island in Island.objects.filter(key='sao-miguel'):
flags = dict(island.feature_flags or {})
if not flags.get('traffic'):
flags['traffic'] = True
island.feature_flags = flags
island.save(update_fields=['feature_flags'])


class Migration(migrations.Migration):
dependencies = [
('tenancy', '0008_enable_marketplace_feature_flag'),
]

operations = [
migrations.RunPython(enable_traffic, migrations.RunPython.noop),
]
16 changes: 16 additions & 0 deletions src/tenancy/tests/test_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,19 @@ def test_enabled_modules_omits_marketplace_when_flag_false(self):
island.save(update_fields=['feature_flags'])
modules = enabled_modules(island)
self.assertNotIn('marketplace', modules)

def test_enabled_modules_includes_traffic_when_flag_set(self):
island = get_or_create_default_island()
island.feature_flags = {**island.feature_flags, 'traffic': True, 'transit': True}
island.save(update_fields=['feature_flags'])
modules = enabled_modules(island)
self.assertIn('traffic', modules)

def test_enabled_modules_omits_traffic_when_flag_false(self):
island = get_or_create_default_island()
flags = dict(island.feature_flags or {})
flags['traffic'] = False
island.feature_flags = flags
island.save(update_fields=['feature_flags'])
modules = enabled_modules(island)
self.assertNotIn('traffic', modules)
56 changes: 56 additions & 0 deletions src/traffic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Traffic module

Waze-style, community-sourced traffic + hazard alerts, scoped per island
(`TenantScopedModel`). Reports are **public on create** — no moderation queue.
Abuse is mitigated by per-session write throttling, confirm/deny voting,
auto-expiry (category TTL), and admin takedown.

## Models (`models.py`)

- **`TrafficCategory`** — admin-managed pick list. `default_ttl_minutes` drives
auto-expiry; `is_schedulable` gates the radar scheduling UI. Seeded for
`sao-miguel` by migration `0002`.
- **`TrafficReport`** — `status ∈ {active, scheduled, expired, removed}`.
Ownership is pseudonymous via `created_by_session_hash`. Carries
`latitude/longitude`, optional `description/road`, `active_from/active_until`
(scheduling), `expires_at`, and `confirm_count/deny_count`.
- **`TrafficConfirmation`** — one `still_there` / `gone` vote per
report+session (unique). `gone` votes ≥ `DENY_THRESHOLD` (3) expire a report.

## Lifecycle

```
scheduled --(active_from ≤ now)--> active --(expires_at ≤ now | 3× deny)--> expired
└--(owner/admin delete)--> removed
```

Transitions are driven by `services.run_lifecycle()`, invoked every minute by
the Celery beat task `traffic.run_lifecycle` (registered in migration `0003`).
It runs **unscoped** across islands (time-driven transitions are tenant-agnostic).

## API (`/api/v3/traffic/`)

| Method | Path | Notes |
|---|---|---|
| GET | `/categories` | Pick list for the quick-report sheet. |
| GET | `/reports` | Filters: `lat,lng,radius_km` (near-me, Haversine) or `bbox`, `category`, `include_scheduled`, `limit`. |
| POST | `/reports` | Create. Throttled (`traffic_write`, 30/min per session). Location plausibility checked against island radius. |
| GET | `/reports/{id}` | Single report. |
| PATCH/DELETE | `/reports/{id}` | Owner (via `X-Session-Id`) or staff only. DELETE is a soft `removed`. |
| POST | `/reports/{id}/confirm` | Body `{vote: still_there\|gone}`. Upserts the caller's vote. |

Writes identify the caller via the `X-Session-Id` header (hashed server-side).

## Seeding demo data

```bash
cd src
python manage.py seed_traffic_demo # ~10 active + 2 scheduled radars
python manage.py seed_traffic_demo --clear # remove seeded rows first
```

## Tests

```bash
cd src && python manage.py test traffic
```
Empty file added src/traffic/__init__.py
Empty file.
42 changes: 42 additions & 0 deletions src/traffic/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from django.contrib import admin

from traffic.models import TrafficCategory, TrafficConfirmation, TrafficReport


def _expire(modeladmin, request, queryset):
queryset.update(status=TrafficReport.EXPIRED)


_expire.short_description = 'Mark selected expired'


def _remove(modeladmin, request, queryset):
queryset.update(status=TrafficReport.REMOVED)


_remove.short_description = 'Remove selected (takedown)'


@admin.register(TrafficCategory)
class TrafficCategoryAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'icon', 'default_ttl_minutes', 'is_schedulable', 'order', 'island')
list_filter = ('island', 'is_schedulable')
search_fields = ('name', 'slug')
prepopulated_fields = {'slug': ('name',)}


@admin.register(TrafficReport)
class TrafficReportAdmin(admin.ModelAdmin):
list_display = (
'category', 'status', 'road', 'expires_at',
'confirm_count', 'deny_count', 'created_at', 'island',
)
list_filter = ('island', 'status', 'category')
search_fields = ('description', 'road')
actions = [_expire, _remove]


@admin.register(TrafficConfirmation)
class TrafficConfirmationAdmin(admin.ModelAdmin):
list_display = ('report', 'vote', 'island', 'created_at')
list_filter = ('island', 'vote')
223 changes: 223 additions & 0 deletions src/traffic/api_v3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
"""Traffic v3 API (function views, service-backed, instant-publish UGC)."""

from __future__ import annotations

from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, throttle_classes
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response

from consent.services import hash_session_id
from tenancy.services import for_island
from traffic import services
from traffic.serializers import ConfirmSerializer, ReportWriteSerializer
from traffic.throttling import TrafficWriteThrottle


def _require_island(request: Request) -> Response | None:
if request.island is None:
return Response(
{'error': {'code': 'island_required', 'message': 'Island context required'}},
status=status.HTTP_400_BAD_REQUEST,
)
return None


def _error(code: str, message: str, http_status: int) -> Response:
return Response({'error': {'code': code, 'message': message}}, status=http_status)


def _session_id(request: Request) -> str:
return (
request.headers.get('X-Session-Id', '').strip()
or str(request.GET.get('session_id', '')).strip()
)


def _is_staff(request: Request) -> bool:
user = getattr(request, 'user', None)
return bool(user and user.is_authenticated and user.is_staff)


def _hash_or_empty(session_id: str, request: Request) -> str:
if not session_id:
return ''
return hash_session_id(session_id, request.island.key)


def _float_or_none(raw: str | None) -> float | None:
if raw is None or str(raw).strip() == '':
return None
try:
return float(raw)
except ValueError:
return None


def _parse_bbox(raw: str | None) -> tuple[float, float, float, float] | None:
if not raw:
return None
parts = raw.split(',')
if len(parts) != 4:
return None
try:
min_lng, min_lat, max_lng, max_lat = (float(p) for p in parts)
except ValueError:
return None
return (min_lng, min_lat, max_lng, max_lat)


@api_view(['GET'])
@permission_classes([AllowAny])
def categories_view(request: Request) -> Response:
err = _require_island(request)
if err:
return err
with for_island(request.island):
return Response({'categories': services.list_categories()})


@api_view(['GET', 'POST'])
@permission_classes([AllowAny])
@throttle_classes([TrafficWriteThrottle])
def reports_view(request: Request) -> Response:
err = _require_island(request)
if err:
return err

if request.method == 'GET':
category = request.GET.get('category', '').strip() or None
lat = _float_or_none(request.GET.get('lat'))
lng = _float_or_none(request.GET.get('lng'))
radius_km = _float_or_none(request.GET.get('radius_km'))
bbox = _parse_bbox(request.GET.get('bbox'))
include_scheduled = request.GET.get('include_scheduled', '').lower() in ('1', 'true', 'yes')
try:
limit = int(request.GET.get('limit', '100'))
except ValueError:
limit = 100
with for_island(request.island):
reports = services.list_reports(
lat=lat, lng=lng, radius_km=radius_km, bbox=bbox,
category=category, include_scheduled=include_scheduled, limit=limit,
)
return Response({'reports': reports})

serializer = ReportWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
session_id = data['session_id'].strip()
if not session_id:
return _error('session_required', 'session_id is required', status.HTTP_400_BAD_REQUEST)
category_slug = (data.get('category_slug') or '').strip()
if not category_slug:
return _error('validation_error', 'category_slug is required', status.HTTP_400_BAD_REQUEST)
latitude = data.get('latitude')
longitude = data.get('longitude')
if latitude is None or longitude is None:
return _error('validation_error', 'latitude and longitude are required', status.HTTP_400_BAD_REQUEST)

session_hash = hash_session_id(session_id, request.island.key)
with for_island(request.island):
try:
payload = services.create_report(
island=request.island,
session_hash=session_hash,
category_slug=category_slug,
latitude=latitude,
longitude=longitude,
description=data.get('description', ''),
road=data.get('road', ''),
active_from=data.get('active_from'),
active_until=data.get('active_until'),
)
except services.CategoryNotFound:
return _error('invalid_category', 'Unknown category', status.HTTP_400_BAD_REQUEST)
except services.SchedulingNotAllowed:
return _error(
'scheduling_not_allowed',
'This category does not support scheduled reports',
status.HTTP_400_BAD_REQUEST,
)
except services.LocationImplausible:
return _error(
'location_implausible',
'Coordinates fall outside the island',
status.HTTP_422_UNPROCESSABLE_ENTITY,
)
return Response(payload, status=status.HTTP_201_CREATED)


@api_view(['GET', 'PATCH', 'DELETE'])
@permission_classes([AllowAny])
@throttle_classes([TrafficWriteThrottle])
def report_detail_view(request: Request, report_id: int) -> Response:
err = _require_island(request)
if err:
return err

is_staff = _is_staff(request)

if request.method == 'GET':
with for_island(request.island):
payload = services.get_report(report_id, is_staff=is_staff)
if payload is None:
return _error('not_found', 'Report not found', status.HTTP_404_NOT_FOUND)
return Response(payload)

if request.method == 'DELETE':
session_hash = _hash_or_empty(_session_id(request), request)
with for_island(request.island):
try:
result = services.soft_delete_report(
report_id, session_hash=session_hash, is_staff=is_staff
)
except services.OwnershipError:
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
if result is None:
return _error('not_found', 'Report not found', status.HTTP_404_NOT_FOUND)
return Response(status=status.HTTP_204_NO_CONTENT)

# PATCH
serializer = ReportWriteSerializer(data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
write_session = data.get('session_id', '').strip() or _session_id(request)
write_hash = _hash_or_empty(write_session, request)
write_data = {k: v for k, v in data.items() if k != 'session_id'}
with for_island(request.island):
try:
payload = services.update_report(
report_id, session_hash=write_hash, is_staff=is_staff, data=write_data
)
except services.OwnershipError:
return _error('not_owner', 'Not allowed', status.HTTP_403_FORBIDDEN)
if payload is None:
return _error('not_found', 'Report not found', status.HTTP_404_NOT_FOUND)
return Response(payload)


@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([TrafficWriteThrottle])
def report_confirm_view(request: Request, report_id: int) -> Response:
err = _require_island(request)
if err:
return err

serializer = ConfirmSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
session_id = data['session_id'].strip()
if not session_id:
return _error('session_required', 'session_id is required', status.HTTP_400_BAD_REQUEST)
session_hash = hash_session_id(session_id, request.island.key)
with for_island(request.island):
result = services.upsert_confirmation(
report_id=report_id, session_hash=session_hash, vote=data['vote']
)
if result is None:
return _error('not_found', 'Report not found or inactive', status.HTTP_404_NOT_FOUND)
payload, created = result
return Response(payload, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
7 changes: 7 additions & 0 deletions src/traffic/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from django.apps import AppConfig


class TrafficConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'traffic'
verbose_name = 'Traffic'
Empty file.
Empty file.
Loading
Loading