diff --git a/src/src/settings.py b/src/src/settings.py index 5de2bad..368e7c1 100644 --- a/src/src/settings.py +++ b/src/src/settings.py @@ -41,6 +41,7 @@ ('seismic', True), ('trails', True), ('marketplace', True), + ('traffic', True), ] import os @@ -85,6 +86,7 @@ 'DEFAULT_THROTTLE_RATES': { 'directions': '30/min', 'marketplace_write': '20/min', + 'traffic_write': '30/min', }, } diff --git a/src/src/urls.py b/src/src/urls.py index 879cd36..7bf19c7 100644 --- a/src/src/urls.py +++ b/src/src/urls.py @@ -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: diff --git a/src/tenancy/migrations/0009_enable_traffic_feature_flag.py b/src/tenancy/migrations/0009_enable_traffic_feature_flag.py new file mode 100644 index 0000000..a58b632 --- /dev/null +++ b/src/tenancy/migrations/0009_enable_traffic_feature_flag.py @@ -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), + ] diff --git a/src/tenancy/tests/test_bootstrap.py b/src/tenancy/tests/test_bootstrap.py index 511bea3..c68d923 100644 --- a/src/tenancy/tests/test_bootstrap.py +++ b/src/tenancy/tests/test_bootstrap.py @@ -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) diff --git a/src/traffic/README.md b/src/traffic/README.md new file mode 100644 index 0000000..011af0b --- /dev/null +++ b/src/traffic/README.md @@ -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 +``` diff --git a/src/traffic/__init__.py b/src/traffic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/traffic/admin.py b/src/traffic/admin.py new file mode 100644 index 0000000..9e5a489 --- /dev/null +++ b/src/traffic/admin.py @@ -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') diff --git a/src/traffic/api_v3.py b/src/traffic/api_v3.py new file mode 100644 index 0000000..d2a6ec2 --- /dev/null +++ b/src/traffic/api_v3.py @@ -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) diff --git a/src/traffic/apps.py b/src/traffic/apps.py new file mode 100644 index 0000000..4586c60 --- /dev/null +++ b/src/traffic/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class TrafficConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'traffic' + verbose_name = 'Traffic' diff --git a/src/traffic/management/__init__.py b/src/traffic/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/traffic/management/commands/__init__.py b/src/traffic/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/traffic/management/commands/seed_traffic_demo.py b/src/traffic/management/commands/seed_traffic_demo.py new file mode 100644 index 0000000..d1bea52 --- /dev/null +++ b/src/traffic/management/commands/seed_traffic_demo.py @@ -0,0 +1,124 @@ +"""Seed active + scheduled traffic reports for local dev / demo.""" + +from __future__ import annotations + +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from consent.services import hash_session_id +from tenancy.models import Island +from tenancy.services import for_island +from traffic import services +from traffic.models import TrafficReport + +DEMO_SESSION_PREFIX = 'demo-traffic-' + +# (category_slug, road, description, lat, lng) +DEMO_REPORTS: list[tuple] = [ + ('acidente', 'EN1-1A', 'Despiste junto à rotunda, fila a formar-se.', 37.7411, -25.6756), + ('transito', 'Av. Infante D. Henrique', 'Trânsito lento na marginal.', 37.7395, -25.6680), + ('obras', 'EN1-1A — Lagoa', 'Trabalhos na via, uma faixa cortada.', 37.7450, -25.5700), + ('desvio', 'Ribeira Grande', 'Desvio no centro por evento.', 37.8210, -25.5150), + ('inundacao', 'EN1-1A — Água Retorta', 'Água na faixa após chuva forte.', 37.8000, -25.2300), + ('perigo', 'Sete Cidades', 'Pedras na via na descida.', 37.8600, -25.7900), + ('policia', 'Ponta Delgada', 'Fiscalização à saída da cidade.', 37.7360, -25.6600), + ('tempo', 'Lagoa do Fogo', 'Nevoeiro denso, visibilidade reduzida.', 37.8550, -25.4750), + ('acidente', 'Furnas', 'Colisão ligeira, trânsito condicionado.', 37.7710, -25.3100), + ('transito', 'Nordeste', 'Fila à entrada da vila.', 37.8200, -25.1450), +] + +# Schedulable radars announced in advance — (road, description, hours_from_now, duration_h, lat, lng) +DEMO_SCHEDULED: list[tuple] = [ + ('EN1-1A — Vila Franca', 'Radar anunciado pela PSP.', 2, 3, 37.7180, -25.4330), + ('EN2-2A — Rabo de Peixe', 'Operação de velocidade.', 6, 2, 37.8050, -25.5800), +] + + +class Command(BaseCommand): + help = 'Seed ~10 active + 2 scheduled traffic reports for demo/dev.' + + def add_arguments(self, parser): + parser.add_argument('--island', default='sao-miguel', help='Island key (default: sao-miguel)') + parser.add_argument( + '--clear', + action='store_true', + help='Remove reports seeded by this command first', + ) + + def handle(self, *args, **options): + island_key: str = options['island'] + try: + island = Island.objects.get(key=island_key) + except Island.DoesNotExist: + self.stderr.write(self.style.ERROR(f'Unknown island: {island_key}')) + return + + if options['clear']: + removed = self._clear_demo(island) + self.stdout.write(f'Removed {removed} demo report(s).') + + created = 0 + now = timezone.now() + with for_island(island): + for idx, (slug, road, desc, lat, lng) in enumerate(DEMO_REPORTS, start=1): + session_hash = hash_session_id(f'{DEMO_SESSION_PREFIX}{idx}', island.key) + if TrafficReport.objects.filter( + island=island, + created_by_session_hash=session_hash, + ).exists(): + continue + try: + services.create_report( + island=island, + session_hash=session_hash, + category_slug=slug, + latitude=lat, + longitude=lng, + description=desc, + road=road, + ) + created += 1 + except services.TrafficError as exc: + self.stderr.write(self.style.WARNING(f'Skip {slug} ({road}): {exc}')) + + for idx, (road, desc, start_h, dur_h, lat, lng) in enumerate(DEMO_SCHEDULED, start=1): + session_hash = hash_session_id(f'{DEMO_SESSION_PREFIX}sched-{idx}', island.key) + if TrafficReport.objects.filter( + island=island, + created_by_session_hash=session_hash, + ).exists(): + continue + active_from = now + timedelta(hours=start_h) + try: + services.create_report( + island=island, + session_hash=session_hash, + category_slug='radar', + latitude=lat, + longitude=lng, + description=desc, + road=road, + active_from=active_from, + active_until=active_from + timedelta(hours=dur_h), + ) + created += 1 + except services.TrafficError as exc: + self.stderr.write(self.style.WARNING(f'Skip scheduled radar ({road}): {exc}')) + + self.stdout.write( + self.style.SUCCESS( + f'Seeded {created} traffic report(s) on {island_key} ' + f'({len(DEMO_REPORTS)} active + {len(DEMO_SCHEDULED)} scheduled available).' + ) + ) + + def _clear_demo(self, island: Island) -> int: + with for_island(island): + reports = TrafficReport.objects.filter( + created_by_session_hash__startswith=DEMO_SESSION_PREFIX + ) + count = reports.count() + reports.delete() + return count diff --git a/src/traffic/migrations/0001_initial.py b/src/traffic/migrations/0001_initial.py new file mode 100644 index 0000000..eb50a5f --- /dev/null +++ b/src/traffic/migrations/0001_initial.py @@ -0,0 +1,173 @@ +# Generated by Django 5.0.6 on 2026-06-02 15:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("tenancy", "0008_enable_marketplace_feature_flag"), + ] + + operations = [ + migrations.CreateModel( + name="TrafficCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("legacy_ref", models.JSONField(blank=True, default=dict)), + ("name", models.CharField(max_length=80)), + ("slug", models.SlugField(max_length=80)), + ("icon", models.CharField(blank=True, default="", max_length=64)), + ( + "default_ttl_minutes", + models.PositiveIntegerField( + default=120, + help_text="How long a fresh report of this category stays active.", + ), + ), + ( + "is_schedulable", + models.BooleanField( + default=False, + help_text="Allow pre-announced reports with a future active_from (e.g. radar).", + ), + ), + ("order", models.PositiveIntegerField(default=0)), + ( + "island", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="tenancy.island" + ), + ), + ], + options={ + "verbose_name_plural": "Traffic categories", + "ordering": ["order", "name"], + "unique_together": {("island", "slug")}, + }, + ), + migrations.CreateModel( + name="TrafficReport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("legacy_ref", models.JSONField(blank=True, default=dict)), + ( + "status", + models.CharField( + choices=[ + ("active", "Active"), + ("scheduled", "Scheduled"), + ("expired", "Expired"), + ("removed", "Removed"), + ], + db_index=True, + default="active", + max_length=16, + ), + ), + ( + "created_by_session_hash", + models.CharField(blank=True, db_index=True, max_length=64), + ), + ("latitude", models.FloatField()), + ("longitude", models.FloatField()), + ("description", models.TextField(blank=True, default="")), + ("road", models.CharField(blank=True, default="", max_length=160)), + ("active_from", models.DateTimeField(blank=True, null=True)), + ("active_until", models.DateTimeField(blank=True, null=True)), + ("expires_at", models.DateTimeField(db_index=True)), + ("confirm_count", models.PositiveIntegerField(default=0)), + ("deny_count", models.PositiveIntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="reports", + to="traffic.trafficcategory", + ), + ), + ( + "island", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="tenancy.island" + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.CreateModel( + name="TrafficConfirmation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("legacy_ref", models.JSONField(blank=True, default=dict)), + ("session_hash", models.CharField(db_index=True, max_length=64)), + ( + "vote", + models.CharField( + choices=[("still_there", "Still there"), ("gone", "Gone")], + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "island", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, to="tenancy.island" + ), + ), + ( + "report", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="confirmations", + to="traffic.trafficreport", + ), + ), + ], + options={ + "ordering": ["-created_at"], + }, + ), + migrations.AddIndex( + model_name="trafficreport", + index=models.Index( + fields=["island", "status", "expires_at"], + name="traffic_tra_island__d9c8a2_idx", + ), + ), + migrations.AlterUniqueTogether( + name="trafficconfirmation", + unique_together={("report", "session_hash")}, + ), + ] diff --git a/src/traffic/migrations/0002_seed_default_categories.py b/src/traffic/migrations/0002_seed_default_categories.py new file mode 100644 index 0000000..282c0f7 --- /dev/null +++ b/src/traffic/migrations/0002_seed_default_categories.py @@ -0,0 +1,57 @@ +"""Seed default traffic report categories for the São Miguel island. + +The quick-pick reporter needs a small, fixed, icon'd set on first launch. +TTL drives auto-expiry; is_schedulable gates the radar scheduling UI. +""" + +from django.db import migrations + +# (slug, name, icon, default_ttl_minutes, is_schedulable, order) +DEFAULT_CATEGORIES = [ + ('acidente', 'Acidente', '💥', 120, False, 1), + ('transito', 'Trânsito', '🚗', 60, False, 2), + ('radar', 'Radar', '📷', 90, True, 3), + ('policia', 'Polícia', '🚓', 90, False, 4), + ('obras', 'Obras', '🚧', 1440, False, 5), + ('desvio', 'Desvio', '↪️', 480, False, 6), + ('inundacao', 'Inundação', '🌊', 240, False, 7), + ('perigo', 'Perigo na via', '⚠️', 90, False, 8), + ('tempo', 'Tempo / Nevoeiro', '🌫️', 180, False, 9), +] + + +def seed_categories(apps, schema_editor): + Island = apps.get_model('tenancy', 'Island') + TrafficCategory = apps.get_model('traffic', 'TrafficCategory') + for island in Island.objects.filter(key='sao-miguel'): + for slug, name, icon, ttl, schedulable, order in DEFAULT_CATEGORIES: + TrafficCategory.objects.get_or_create( + island=island, + slug=slug, + defaults={ + 'name': name, + 'icon': icon, + 'default_ttl_minutes': ttl, + 'is_schedulable': schedulable, + 'order': order, + }, + ) + + +def remove_categories(apps, schema_editor): + Island = apps.get_model('tenancy', 'Island') + TrafficCategory = apps.get_model('traffic', 'TrafficCategory') + slugs = [slug for slug, *_ in DEFAULT_CATEGORIES] + for island in Island.objects.filter(key='sao-miguel'): + TrafficCategory.objects.filter(island=island, slug__in=slugs).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('traffic', '0001_initial'), + ('tenancy', '0009_enable_traffic_feature_flag'), + ] + + operations = [ + migrations.RunPython(seed_categories, remove_categories), + ] diff --git a/src/traffic/migrations/0003_periodic_task_run_lifecycle.py b/src/traffic/migrations/0003_periodic_task_run_lifecycle.py new file mode 100644 index 0000000..b1ef7a6 --- /dev/null +++ b/src/traffic/migrations/0003_periodic_task_run_lifecycle.py @@ -0,0 +1,48 @@ +"""Register the per-minute traffic lifecycle task (activation + expiry).""" + +from django.db import migrations + + +def _every_minute_schedule(apps): + CrontabSchedule = apps.get_model('django_celery_beat', 'CrontabSchedule') + schedule, _ = CrontabSchedule.objects.get_or_create( + minute='*', + hour='*', + day_of_week='*', + day_of_month='*', + month_of_year='*', + defaults={'timezone': 'Atlantic/Azores'}, + ) + if schedule.timezone != 'Atlantic/Azores': + schedule.timezone = 'Atlantic/Azores' + schedule.save(update_fields=['timezone']) + return schedule + + +def register_lifecycle_task(apps, schema_editor): + PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask') + every_minute = _every_minute_schedule(apps) + PeriodicTask.objects.update_or_create( + name='traffic.run_lifecycle', + defaults={ + 'task': 'traffic.run_lifecycle', + 'crontab': every_minute, + 'enabled': True, + }, + ) + + +def unregister_lifecycle_task(apps, schema_editor): + PeriodicTask = apps.get_model('django_celery_beat', 'PeriodicTask') + PeriodicTask.objects.filter(name='traffic.run_lifecycle').delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('traffic', '0002_seed_default_categories'), + ('django_celery_beat', '__latest__'), + ] + + operations = [ + migrations.RunPython(register_lifecycle_task, unregister_lifecycle_task), + ] diff --git a/src/traffic/migrations/__init__.py b/src/traffic/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/traffic/models.py b/src/traffic/models.py new file mode 100644 index 0000000..a00042c --- /dev/null +++ b/src/traffic/models.py @@ -0,0 +1,108 @@ +"""Traffic: crowdsourced live road alerts (instant-publish UGC). + +Unlike ``marketplace``, traffic reports are **public on create** — there is no +pending-moderation queue. ``status`` is a lifecycle field +(``active``/``scheduled``/``expired``/``removed``) driven by a Celery task and +by confirm/deny voting. Trust comes from per-session throttling, voting, and +auto-expiry rather than pre-publication review. +""" + +from __future__ import annotations + +from django.db import models + +from tenancy.models import TenantScopedModel + + +class TrafficCategory(TenantScopedModel): + """Admin-managed, seeded report category for the quick-pick reporter.""" + + name = models.CharField(max_length=80) + slug = models.SlugField(max_length=80) + icon = models.CharField(max_length=64, blank=True, default='') + default_ttl_minutes = models.PositiveIntegerField( + default=120, + help_text='How long a fresh report of this category stays active.', + ) + is_schedulable = models.BooleanField( + default=False, + help_text='Allow pre-announced reports with a future active_from (e.g. radar).', + ) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order', 'name'] + unique_together = [('island', 'slug')] + verbose_name_plural = 'Traffic categories' + + def __str__(self) -> str: + return self.name + + +class TrafficReport(TenantScopedModel): + """A single crowdsourced road alert. Public immediately on create.""" + + ACTIVE = 'active' + SCHEDULED = 'scheduled' + EXPIRED = 'expired' + REMOVED = 'removed' + STATUS_CHOICES = [ + (ACTIVE, 'Active'), + (SCHEDULED, 'Scheduled'), + (EXPIRED, 'Expired'), + (REMOVED, 'Removed'), + ] + + status = models.CharField( + max_length=16, choices=STATUS_CHOICES, default=ACTIVE, db_index=True + ) + category = models.ForeignKey( + TrafficCategory, on_delete=models.PROTECT, related_name='reports' + ) + created_by_session_hash = models.CharField(max_length=64, db_index=True, blank=True) + latitude = models.FloatField() + longitude = models.FloatField() + description = models.TextField(blank=True, default='') + road = models.CharField(max_length=160, blank=True, default='') + active_from = models.DateTimeField(null=True, blank=True) + active_until = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(db_index=True) + confirm_count = models.PositiveIntegerField(default=0) + deny_count = models.PositiveIntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + indexes = [models.Index(fields=['island', 'status', 'expires_at'])] + + def __str__(self) -> str: + return f'{self.category_id} @ {self.latitude},{self.longitude} ({self.status})' + + def is_owned_by(self, session_hash: str) -> bool: + return bool(session_hash) and self.created_by_session_hash == session_hash + + +class TrafficConfirmation(TenantScopedModel): + """A 'still there' / 'gone' vote, one per session per report.""" + + STILL_THERE = 'still_there' + GONE = 'gone' + VOTE_CHOICES = [ + (STILL_THERE, 'Still there'), + (GONE, 'Gone'), + ] + + report = models.ForeignKey( + TrafficReport, on_delete=models.CASCADE, related_name='confirmations' + ) + session_hash = models.CharField(max_length=64, db_index=True) + vote = models.CharField(max_length=16, choices=VOTE_CHOICES) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + unique_together = [('report', 'session_hash')] + + def __str__(self) -> str: + return f'{self.vote} on {self.report_id}' diff --git a/src/traffic/serializers.py b/src/traffic/serializers.py new file mode 100644 index 0000000..7821e6a --- /dev/null +++ b/src/traffic/serializers.py @@ -0,0 +1,21 @@ +"""Request validation for traffic v3 writes (service layer builds responses).""" + +from __future__ import annotations + +from rest_framework import serializers + + +class ReportWriteSerializer(serializers.Serializer): + session_id = serializers.CharField(max_length=128) + category_slug = serializers.SlugField(required=False, allow_blank=True) + latitude = serializers.FloatField(required=False, allow_null=True) + longitude = serializers.FloatField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True, default='') + road = serializers.CharField(max_length=160, required=False, allow_blank=True, default='') + active_from = serializers.DateTimeField(required=False, allow_null=True) + active_until = serializers.DateTimeField(required=False, allow_null=True) + + +class ConfirmSerializer(serializers.Serializer): + session_id = serializers.CharField(max_length=128) + vote = serializers.ChoiceField(choices=['still_there', 'gone']) diff --git a/src/traffic/services.py b/src/traffic/services.py new file mode 100644 index 0000000..1811a86 --- /dev/null +++ b/src/traffic/services.py @@ -0,0 +1,324 @@ +"""Traffic business logic: CRUD, geo search, voting, lifecycle, serialization. + +Reports are **public on create** (no moderation queue). Reads/writes assume the +active island is bound (v3 views wrap calls in ``with for_island(request.island):``). +Creates pass ``island`` explicitly. Ownership is pseudonymous via +``created_by_session_hash``. ``run_lifecycle`` runs unscoped across islands from +the Celery beat task (time-driven transitions are tenant-agnostic). +""" + +from __future__ import annotations + +import math +from datetime import timedelta +from typing import Any + +from django.utils import timezone + +from traffic.models import TrafficCategory, TrafficConfirmation, TrafficReport + +MAX_LIMIT = 200 +DEFAULT_LIMIT = 100 +DENY_THRESHOLD = 3 +EARTH_RADIUS_KM = 6371.0 + + +class TrafficError(Exception): + """Base for traffic service errors.""" + + +class OwnershipError(TrafficError): + """Raised when a non-owner / non-staff attempts a restricted write.""" + + +class CategoryNotFound(TrafficError): + """Raised when a write references an unknown category slug.""" + + +class LocationImplausible(TrafficError): + """Raised when report coordinates fall outside the island radius.""" + + +class SchedulingNotAllowed(TrafficError): + """Raised when active_from is set on a non-schedulable category.""" + + +# --------------------------------------------------------------------------- # +# Geo helpers +# --------------------------------------------------------------------------- # + +def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float: + d_lat = math.radians(lat2 - lat1) + d_lng = math.radians(lng2 - lng1) + a = ( + math.sin(d_lat / 2) ** 2 + + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(d_lng / 2) ** 2 + ) + return EARTH_RADIUS_KM * 2 * math.asin(math.sqrt(a)) + + +# --------------------------------------------------------------------------- # +# Serialization +# --------------------------------------------------------------------------- # + +def serialize_category(category: TrafficCategory) -> dict[str, Any]: + return { + 'id': category.id, + 'name': category.name, + 'slug': category.slug, + 'icon': category.icon, + 'defaultTtlMinutes': category.default_ttl_minutes, + 'isSchedulable': category.is_schedulable, + 'order': category.order, + } + + +def serialize_report(report: TrafficReport) -> dict[str, Any]: + return { + 'id': report.id, + 'status': report.status, + 'category': { + 'id': report.category_id, + 'name': report.category.name, + 'slug': report.category.slug, + 'icon': report.category.icon, + }, + 'latitude': report.latitude, + 'longitude': report.longitude, + 'description': report.description, + 'road': report.road, + 'confidence': {'confirm': report.confirm_count, 'deny': report.deny_count}, + 'activeFrom': report.active_from.isoformat() if report.active_from else None, + 'activeUntil': report.active_until.isoformat() if report.active_until else None, + 'expiresAt': report.expires_at.isoformat() if report.expires_at else None, + 'createdAt': report.created_at.isoformat(), + } + + +# --------------------------------------------------------------------------- # +# Categories +# --------------------------------------------------------------------------- # + +def list_categories() -> list[dict[str, Any]]: + return [serialize_category(c) for c in TrafficCategory.objects.all()] + + +def _resolve_category(slug: str) -> TrafficCategory: + try: + return TrafficCategory.objects.get(slug=slug) + except TrafficCategory.DoesNotExist as exc: + raise CategoryNotFound(slug) from exc + + +# --------------------------------------------------------------------------- # +# Reports — reads +# --------------------------------------------------------------------------- # + +def list_reports( + *, + lat: float | None = None, + lng: float | None = None, + radius_km: float | None = None, + bbox: tuple[float, float, float, float] | None = None, + category: str | None = None, + include_scheduled: bool = False, + limit: int = DEFAULT_LIMIT, +) -> list[dict[str, Any]]: + statuses = [TrafficReport.ACTIVE] + if include_scheduled: + statuses.append(TrafficReport.SCHEDULED) + + qs = TrafficReport.objects.select_related('category').filter(status__in=statuses) + if category: + qs = qs.filter(category__slug=category) + + if bbox is not None: + min_lng, min_lat, max_lng, max_lat = bbox + qs = qs.filter( + latitude__gte=min_lat, latitude__lte=max_lat, + longitude__gte=min_lng, longitude__lte=max_lng, + ) + + limit = max(1, min(limit, MAX_LIMIT)) + + if lat is not None and lng is not None and radius_km is not None: + within = [ + r for r in qs + if haversine_km(lat, lng, r.latitude, r.longitude) <= radius_km + ] + within.sort(key=lambda r: haversine_km(lat, lng, r.latitude, r.longitude)) + return [serialize_report(r) for r in within[:limit]] + + return [serialize_report(r) for r in qs[:limit]] + + +def _get_report_or_none(report_id: int) -> TrafficReport | None: + try: + return TrafficReport.objects.select_related('category').get(id=report_id) + except TrafficReport.DoesNotExist: + return None + + +def get_report(report_id: int, *, is_staff: bool = False) -> dict[str, Any] | None: + report = _get_report_or_none(report_id) + if report is None: + return None + if report.status == TrafficReport.REMOVED and not is_staff: + return None + return serialize_report(report) + + +# --------------------------------------------------------------------------- # +# Reports — writes +# --------------------------------------------------------------------------- # + +def _check_plausible(island, latitude: float, longitude: float) -> None: + distance = haversine_km(island.center_lat, island.center_lng, latitude, longitude) + if distance > island.radius_km: + raise LocationImplausible(f'{distance:.1f}km from island center') + + +def create_report( + *, + island, + session_hash: str, + category_slug: str, + latitude: float, + longitude: float, + description: str = '', + road: str = '', + active_from=None, + active_until=None, +) -> dict[str, Any]: + category = _resolve_category(category_slug) + _check_plausible(island, latitude, longitude) + + now = timezone.now() + ttl = timedelta(minutes=category.default_ttl_minutes) + + if active_from and active_from > now: + if not category.is_schedulable: + raise SchedulingNotAllowed(category_slug) + status = TrafficReport.SCHEDULED + expires_at = active_until or (active_from + ttl) + else: + active_from = None + status = TrafficReport.ACTIVE + expires_at = active_until or (now + ttl) + + report = TrafficReport.objects.create( + island=island, + category=category, + created_by_session_hash=session_hash, + latitude=latitude, + longitude=longitude, + description=description, + road=road, + active_from=active_from, + active_until=active_until, + expires_at=expires_at, + status=status, + ) + return serialize_report(report) + + +_REPORT_WRITE_FIELDS = ('latitude', 'longitude', 'description', 'road') + + +def update_report( + report_id: int, + *, + session_hash: str, + is_staff: bool, + data: dict[str, Any], +) -> dict[str, Any] | None: + report = _get_report_or_none(report_id) + if report is None or report.status == TrafficReport.REMOVED: + return None + if not (is_staff or report.is_owned_by(session_hash)): + raise OwnershipError(report_id) + for field in _REPORT_WRITE_FIELDS: + if field in data: + setattr(report, field, data[field]) + report.save() + return serialize_report(report) + + +def soft_delete_report(report_id: int, *, session_hash: str, is_staff: bool) -> bool | None: + report = _get_report_or_none(report_id) + if report is None or report.status == TrafficReport.REMOVED: + return None + if not (is_staff or report.is_owned_by(session_hash)): + raise OwnershipError(report_id) + report.status = TrafficReport.REMOVED + report.save(update_fields=['status', 'updated_at']) + return True + + +# --------------------------------------------------------------------------- # +# Confirmations (voting) +# --------------------------------------------------------------------------- # + +def upsert_confirmation( + *, + report_id: int, + session_hash: str, + vote: str, +) -> tuple[dict[str, Any], bool] | None: + report = _get_report_or_none(report_id) + if report is None or report.status in (TrafficReport.REMOVED, TrafficReport.EXPIRED): + return None + + _, created = TrafficConfirmation.objects.update_or_create( + report=report, + session_hash=session_hash, + defaults={'island': report.island, 'vote': vote}, + ) + _recompute_confidence(report) + return serialize_report(report), created + + +def _recompute_confidence(report: TrafficReport) -> None: + votes = TrafficConfirmation.objects.filter(report=report) + confirm = votes.filter(vote=TrafficConfirmation.STILL_THERE).count() + deny = votes.filter(vote=TrafficConfirmation.GONE).count() + report.confirm_count = confirm + report.deny_count = deny + + if deny >= DENY_THRESHOLD: + report.status = TrafficReport.EXPIRED + elif report.status == TrafficReport.ACTIVE: + # A fresh "still there" extends life by half the category TTL, capped + # at one full TTL ahead of now. + ttl = timedelta(minutes=report.category.default_ttl_minutes) + now = timezone.now() + extended = report.expires_at + (ttl / 2) + report.expires_at = min(extended, now + ttl) + + report.save(update_fields=['confirm_count', 'deny_count', 'status', 'expires_at', 'updated_at']) + + +# --------------------------------------------------------------------------- # +# Lifecycle (Celery) +# --------------------------------------------------------------------------- # + +def run_lifecycle(*, now=None) -> dict[str, int]: + """Activate due scheduled reports and expire stale active ones. + + Runs unscoped across all islands — these transitions are time-driven and + tenant-agnostic (explicit cross-tenant Celery operation per SDD/11 §3). + Idempotent and re-runnable. + """ + now = now or timezone.now() + + activated = ( + TrafficReport.objects.unscoped() + .filter(status=TrafficReport.SCHEDULED, active_from__lte=now) + .update(status=TrafficReport.ACTIVE) + ) + expired = ( + TrafficReport.objects.unscoped() + .filter(status=TrafficReport.ACTIVE, expires_at__lte=now) + .update(status=TrafficReport.EXPIRED) + ) + return {'activated': activated, 'expired': expired} diff --git a/src/traffic/tasks.py b/src/traffic/tasks.py new file mode 100644 index 0000000..0591976 --- /dev/null +++ b/src/traffic/tasks.py @@ -0,0 +1,18 @@ +"""Traffic Celery tasks.""" + +from __future__ import annotations + +import logging + +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task(name='traffic.run_lifecycle') +def run_lifecycle_task() -> dict: + from traffic.services import run_lifecycle + + counts = run_lifecycle() + logger.info('traffic.run_lifecycle counts=%s', counts) + return {'status': 'ok', **counts} diff --git a/src/traffic/tests/__init__.py b/src/traffic/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/traffic/tests/test_api_v3.py b/src/traffic/tests/test_api_v3.py new file mode 100644 index 0000000..9d1d01a --- /dev/null +++ b/src/traffic/tests/test_api_v3.py @@ -0,0 +1,158 @@ +"""U3 API tests: verbs, ownership 403, tenant isolation, schedule, confirm, throttle.""" + +from __future__ import annotations + +from datetime import timedelta + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIClient + +from tenancy.models import Island +from tenancy.services import get_or_create_default_island +from traffic.models import TrafficCategory, TrafficReport + +SM = {'HTTP_X_ISLAND': 'sao-miguel'} + + +class TrafficAPITests(TestCase): + def setUp(self): + cache.clear() + self.client = APIClient() + self.island = get_or_create_default_island() + TrafficCategory.objects.create( + island=self.island, name='Acidente', slug='acidente', default_ttl_minutes=120, + ) + TrafficCategory.objects.create( + island=self.island, name='Radar', slug='radar', default_ttl_minutes=90, + is_schedulable=True, + ) + + def _create(self, session='owner', slug='acidente', lat=37.78, lng=-25.50, **extra): + body = {'session_id': session, 'category_slug': slug, 'latitude': lat, 'longitude': lng, **extra} + return self.client.post('/api/v3/traffic/reports', body, format='json', **SM) + + # --- categories / create ------------------------------------------------ + + def test_categories_seeded(self): + resp = self.client.get('/api/v3/traffic/categories', **SM) + self.assertEqual(resp.status_code, 200) + slugs = [c['slug'] for c in resp.json()['categories']] + self.assertIn('acidente', slugs) + self.assertIn('radar', slugs) + + def test_create_report_active_and_public_immediately(self): + resp = self._create() + self.assertEqual(resp.status_code, 201) + self.assertEqual(resp.json()['status'], 'active') + listed = self.client.get('/api/v3/traffic/reports', **SM).json()['reports'] + self.assertEqual(len(listed), 1) + + def test_create_requires_session_and_coords(self): + no_session = self.client.post( + '/api/v3/traffic/reports', + {'session_id': '', 'category_slug': 'acidente', 'latitude': 37.78, 'longitude': -25.50}, + format='json', **SM, + ) + self.assertEqual(no_session.status_code, 400) + no_coords = self.client.post( + '/api/v3/traffic/reports', + {'session_id': 'x', 'category_slug': 'acidente'}, + format='json', **SM, + ) + self.assertEqual(no_coords.status_code, 400) + + def test_create_location_implausible(self): + resp = self._create(lat=40.0, lng=-8.0) # mainland + self.assertEqual(resp.status_code, 422) + self.assertEqual(resp.json()['error']['code'], 'location_implausible') + + # --- scheduling --------------------------------------------------------- + + def test_scheduled_radar_hidden_then_shown(self): + future = (timezone.now() + timedelta(hours=2)).isoformat() + resp = self._create(slug='radar', active_from=future) + self.assertEqual(resp.status_code, 201) + self.assertEqual(resp.json()['status'], 'scheduled') + default = self.client.get('/api/v3/traffic/reports', **SM).json()['reports'] + self.assertEqual(default, []) + with_sched = self.client.get( + '/api/v3/traffic/reports?include_scheduled=true', **SM + ).json()['reports'] + self.assertEqual(len(with_sched), 1) + + def test_scheduling_rejected_on_non_schedulable(self): + future = (timezone.now() + timedelta(hours=2)).isoformat() + resp = self._create(slug='acidente', active_from=future) + self.assertEqual(resp.status_code, 400) + self.assertEqual(resp.json()['error']['code'], 'scheduling_not_allowed') + + # --- geo ---------------------------------------------------------------- + + def test_list_radius_filters(self): + self._create(session='a', lat=37.783, lng=-25.50) + self._create(session='b', lat=37.95, lng=-25.30) + resp = self.client.get('/api/v3/traffic/reports?lat=37.782&lng=-25.499&radius_km=2', **SM) + self.assertEqual(len(resp.json()['reports']), 1) + + # --- confirm ------------------------------------------------------------ + + def test_confirm_vote_upsert(self): + rid = self._create().json()['id'] + first = self.client.post( + f'/api/v3/traffic/reports/{rid}/confirm', + {'session_id': 'v', 'vote': 'still_there'}, format='json', **SM, + ) + self.assertEqual(first.status_code, 201) + self.assertEqual(first.json()['confidence']['confirm'], 1) + second = self.client.post( + f'/api/v3/traffic/reports/{rid}/confirm', + {'session_id': 'v', 'vote': 'gone'}, format='json', **SM, + ) + self.assertEqual(second.status_code, 200) + self.assertEqual(second.json()['confidence']['confirm'], 0) + self.assertEqual(second.json()['confidence']['deny'], 1) + + # --- ownership ---------------------------------------------------------- + + def test_patch_delete_ownership(self): + rid = self._create(session='owner').json()['id'] + forbidden = self.client.patch( + f'/api/v3/traffic/reports/{rid}', + {'session_id': 'intruder', 'description': 'x'}, format='json', **SM, + ) + self.assertEqual(forbidden.status_code, 403) + ok = self.client.patch( + f'/api/v3/traffic/reports/{rid}', + {'session_id': 'owner', 'description': 'updated'}, format='json', **SM, + ) + self.assertEqual(ok.status_code, 200) + self.assertEqual(ok.json()['description'], 'updated') + deleted = self.client.delete( + f'/api/v3/traffic/reports/{rid}', **{**SM, 'HTTP_X_SESSION_ID': 'owner'} + ) + self.assertEqual(deleted.status_code, 204) + self.assertEqual(TrafficReport.objects.get(id=rid).status, TrafficReport.REMOVED) + + # --- tenant isolation --------------------------------------------------- + + def test_tenant_isolation(self): + rid = self._create().json()['id'] + Island.objects.create( + key='terceira', name='Terceira', center_lat=38.7, center_lng=-27.2, + radius_km=40, feature_flags={'traffic': True}, + ) + resp = self.client.get( + f'/api/v3/traffic/reports/{rid}', **{'HTTP_X_ISLAND': 'terceira'} + ) + self.assertEqual(resp.status_code, 404) + + # --- throttle ----------------------------------------------------------- + + def test_write_throttled(self): + cache.clear() # traffic_write rate is 30/min + last = None + for _ in range(32): + last = self._create(session='spammer') + self.assertEqual(last.status_code, 429) diff --git a/src/traffic/tests/test_models.py b/src/traffic/tests/test_models.py new file mode 100644 index 0000000..c9183b4 --- /dev/null +++ b/src/traffic/tests/test_models.py @@ -0,0 +1,72 @@ +"""U1 model-level tests: defaults, ownership, uniqueness, lifecycle status.""" + +from __future__ import annotations + +from datetime import timedelta + +from django.db import IntegrityError, transaction +from django.test import TestCase +from django.utils import timezone + +from tenancy.services import get_or_create_default_island +from traffic.models import TrafficCategory, TrafficConfirmation, TrafficReport + + +class TrafficModelTests(TestCase): + def setUp(self): + self.island = get_or_create_default_island() + self.category = TrafficCategory.objects.create( + island=self.island, name='Acidente', slug='acidente', default_ttl_minutes=120 + ) + + def _report(self, **kwargs): + defaults = dict( + island=self.island, + category=self.category, + created_by_session_hash='sess-a', + latitude=37.74, + longitude=-25.66, + expires_at=timezone.now() + timedelta(minutes=120), + ) + defaults.update(kwargs) + return TrafficReport.objects.create(**defaults) + + def test_report_defaults_active(self): + report = self._report() + self.assertEqual(report.status, TrafficReport.ACTIVE) + self.assertEqual(report.confirm_count, 0) + self.assertEqual(report.deny_count, 0) + + def test_is_owned_by(self): + report = self._report(created_by_session_hash='owner-hash') + self.assertTrue(report.is_owned_by('owner-hash')) + self.assertFalse(report.is_owned_by('other-hash')) + self.assertFalse(report.is_owned_by('')) + + def test_category_unique_per_island(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + TrafficCategory.objects.create( + island=self.island, name='Acidente 2', slug='acidente' + ) + + def test_confirmation_unique_per_session_per_report(self): + report = self._report() + TrafficConfirmation.objects.create( + island=self.island, report=report, session_hash='sess-x', + vote=TrafficConfirmation.STILL_THERE, + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + TrafficConfirmation.objects.create( + island=self.island, report=report, session_hash='sess-x', + vote=TrafficConfirmation.GONE, + ) + + def test_active_filter_excludes_non_active(self): + self._report(status=TrafficReport.EXPIRED) + self._report(status=TrafficReport.ACTIVE, created_by_session_hash='sess-b', road='EN1-1A') + active = TrafficReport.objects.for_island(self.island).filter( + status=TrafficReport.ACTIVE + ) + self.assertEqual([r.road for r in active], ['EN1-1A']) diff --git a/src/traffic/tests/test_services.py b/src/traffic/tests/test_services.py new file mode 100644 index 0000000..c07affe --- /dev/null +++ b/src/traffic/tests/test_services.py @@ -0,0 +1,224 @@ +"""U2 service tests: create/lifecycle/voting/geo/ownership.""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from tenancy.services import for_island, get_or_create_default_island +from traffic import services +from traffic.models import TrafficCategory, TrafficReport + +pytestmark = pytest.mark.django_db + + +def _island(): + island = get_or_create_default_island() + # center 37.782213, -25.499806, radius 50km + return island + + +def _category(island, slug='acidente', ttl=120, schedulable=False): + return TrafficCategory.objects.create( + island=island, name=slug.title(), slug=slug, + default_ttl_minutes=ttl, is_schedulable=schedulable, + ) + + +def test_create_report_is_active_immediately(): + island = _island() + with for_island(island): + _category(island) + payload = services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=37.78, longitude=-25.50, + ) + assert payload['status'] == TrafficReport.ACTIVE + listed = services.list_reports() + assert any(r['id'] == payload['id'] for r in listed) + + +def test_create_scheduled_radar_hidden_by_default(): + island = _island() + with for_island(island): + _category(island, slug='radar', schedulable=True) + future = timezone.now() + timedelta(hours=3) + payload = services.create_report( + island=island, session_hash='a', category_slug='radar', + latitude=37.78, longitude=-25.50, active_from=future, + ) + assert payload['status'] == TrafficReport.SCHEDULED + assert all(r['id'] != payload['id'] for r in services.list_reports()) + assert any( + r['id'] == payload['id'] + for r in services.list_reports(include_scheduled=True) + ) + + +def test_scheduling_rejected_on_non_schedulable_category(): + island = _island() + with for_island(island): + _category(island, slug='acidente') + with pytest.raises(services.SchedulingNotAllowed): + services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=37.78, longitude=-25.50, + active_from=timezone.now() + timedelta(hours=1), + ) + + +def test_location_implausible_outside_radius(): + island = _island() + with for_island(island): + _category(island) + with pytest.raises(services.LocationImplausible): + services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=40.0, longitude=-8.0, # mainland Portugal + ) + + +def test_unknown_category_raises(): + island = _island() + with for_island(island): + with pytest.raises(services.CategoryNotFound): + services.create_report( + island=island, session_hash='a', category_slug='nope', + latitude=37.78, longitude=-25.50, + ) + + +def test_confirm_still_there_extends_and_counts(): + island = _island() + with for_island(island): + _category(island, ttl=120) + created = services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=37.78, longitude=-25.50, + ) + before = TrafficReport.objects.get(id=created['id']).expires_at + result = services.upsert_confirmation( + report_id=created['id'], session_hash='voter1', vote='still_there' + ) + payload, was_created = result + assert was_created is True + assert payload['confidence']['confirm'] == 1 + after = TrafficReport.objects.get(id=created['id']).expires_at + assert after > before + + +def test_confirm_upsert_one_per_session(): + island = _island() + with for_island(island): + _category(island) + created = services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=37.78, longitude=-25.50, + ) + services.upsert_confirmation(report_id=created['id'], session_hash='v', vote='still_there') + _, was_created = services.upsert_confirmation( + report_id=created['id'], session_hash='v', vote='gone' + ) + assert was_created is False + report = TrafficReport.objects.get(id=created['id']) + assert report.confirm_count == 0 + assert report.deny_count == 1 + + +def test_deny_threshold_expires_report(): + island = _island() + with for_island(island): + _category(island) + created = services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=37.78, longitude=-25.50, + ) + for i in range(services.DENY_THRESHOLD): + services.upsert_confirmation( + report_id=created['id'], session_hash=f'v{i}', vote='gone' + ) + assert TrafficReport.objects.get(id=created['id']).status == TrafficReport.EXPIRED + + +def test_list_reports_radius_filters_and_sorts(): + island = _island() + with for_island(island): + _category(island) + near = services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=37.783, longitude=-25.50, + ) + far = services.create_report( + island=island, session_hash='b', category_slug='acidente', + latitude=37.85, longitude=-25.40, + ) + results = services.list_reports(lat=37.782, lng=-25.499, radius_km=2) + ids = [r['id'] for r in results] + assert near['id'] in ids + assert far['id'] not in ids + + +def test_list_reports_bbox_filters(): + island = _island() + with for_island(island): + _category(island) + inside = services.create_report( + island=island, session_hash='a', category_slug='acidente', + latitude=37.78, longitude=-25.50, + ) + outside = services.create_report( + island=island, session_hash='b', category_slug='acidente', + latitude=37.88, longitude=-25.30, + ) + results = services.list_reports(bbox=(-25.55, 37.75, -25.45, 37.80)) + ids = [r['id'] for r in results] + assert inside['id'] in ids + assert outside['id'] not in ids + + +def test_update_and_delete_ownership(): + island = _island() + with for_island(island): + _category(island) + created = services.create_report( + island=island, session_hash='owner', category_slug='acidente', + latitude=37.78, longitude=-25.50, + ) + with pytest.raises(services.OwnershipError): + services.update_report(created['id'], session_hash='other', is_staff=False, + data={'description': 'x'}) + updated = services.update_report(created['id'], session_hash='owner', is_staff=False, + data={'description': 'updated'}) + assert updated['description'] == 'updated' + with pytest.raises(services.OwnershipError): + services.soft_delete_report(created['id'], session_hash='other', is_staff=False) + assert services.soft_delete_report(created['id'], session_hash='owner', is_staff=False) + assert TrafficReport.objects.get(id=created['id']).status == TrafficReport.REMOVED + + +def test_run_lifecycle_activates_and_expires(): + island = _island() + with for_island(island): + cat = _category(island, slug='radar', schedulable=True) + now = timezone.now() + # scheduled, due now + scheduled = TrafficReport.objects.create( + island=island, category=cat, latitude=37.78, longitude=-25.50, + status=TrafficReport.SCHEDULED, active_from=now - timedelta(minutes=1), + expires_at=now + timedelta(hours=1), + ) + # active, past expiry + stale = TrafficReport.objects.create( + island=island, category=cat, latitude=37.78, longitude=-25.50, + status=TrafficReport.ACTIVE, expires_at=now - timedelta(minutes=1), + ) + counts = services.run_lifecycle() + assert counts['activated'] >= 1 + assert counts['expired'] >= 1 + assert TrafficReport.objects.unscoped().get(id=scheduled.id).status == TrafficReport.ACTIVE + assert TrafficReport.objects.unscoped().get(id=stale.id).status == TrafficReport.EXPIRED + # idempotent + second = services.run_lifecycle() + assert second['activated'] == 0 diff --git a/src/traffic/tests/test_tasks.py b/src/traffic/tests/test_tasks.py new file mode 100644 index 0000000..3380198 --- /dev/null +++ b/src/traffic/tests/test_tasks.py @@ -0,0 +1,38 @@ +"""U4 task test: the Celery task drives the lifecycle transitions.""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest +from django.utils import timezone + +from tenancy.services import for_island, get_or_create_default_island +from traffic.models import TrafficCategory, TrafficReport +from traffic.tasks import run_lifecycle_task + +pytestmark = pytest.mark.django_db + + +def test_run_lifecycle_task_transitions(): + island = get_or_create_default_island() + now = timezone.now() + with for_island(island): + cat = TrafficCategory.objects.create( + island=island, name='Radar', slug='radar', is_schedulable=True, + ) + due = TrafficReport.objects.create( + island=island, category=cat, latitude=37.78, longitude=-25.50, + status=TrafficReport.SCHEDULED, active_from=now - timedelta(minutes=1), + expires_at=now + timedelta(hours=1), + ) + stale = TrafficReport.objects.create( + island=island, category=cat, latitude=37.78, longitude=-25.50, + status=TrafficReport.ACTIVE, expires_at=now - timedelta(minutes=1), + ) + + result = run_lifecycle_task() + + assert result['status'] == 'ok' + assert TrafficReport.objects.unscoped().get(id=due.id).status == TrafficReport.ACTIVE + assert TrafficReport.objects.unscoped().get(id=stale.id).status == TrafficReport.EXPIRED diff --git a/src/traffic/throttling.py b/src/traffic/throttling.py new file mode 100644 index 0000000..916ab24 --- /dev/null +++ b/src/traffic/throttling.py @@ -0,0 +1,27 @@ +"""DRF throttle for traffic write endpoints.""" + +from __future__ import annotations + +from rest_framework.throttling import SimpleRateThrottle + +SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS') + + +class TrafficWriteThrottle(SimpleRateThrottle): + """Throttle writes per (island, session); reads are never throttled.""" + + scope = 'traffic_write' + + def get_cache_key(self, request, view): + if request.method in SAFE_METHODS: + return None + session_id = request.headers.get('X-Session-Id', '').strip() + if not session_id: + try: + session_id = str(request.data.get('session_id', '')).strip() + except Exception: + session_id = '' + island = getattr(request, 'island', None) + island_key = island.key if island else 'unknown' + ident = session_id or self.get_ident(request) + return self.cache_format % {'scope': self.scope, 'ident': f'{island_key}:{ident}'} diff --git a/src/traffic/urls_v3.py b/src/traffic/urls_v3.py new file mode 100644 index 0000000..70ef76e --- /dev/null +++ b/src/traffic/urls_v3.py @@ -0,0 +1,19 @@ +"""Traffic v3 URL routes.""" + +from __future__ import annotations + +from django.urls import path + +from traffic.api_v3 import ( + categories_view, + report_confirm_view, + report_detail_view, + reports_view, +) + +urlpatterns = [ + path('categories', categories_view, name='v3-traffic-categories'), + path('reports', reports_view, name='v3-traffic-reports'), + path('reports/', report_detail_view, name='v3-traffic-report-detail'), + path('reports//confirm', report_confirm_view, name='v3-traffic-report-confirm'), +]