Skip to content

Commit fb859f4

Browse files
hc-sousacursoragent
andcommitted
feat(traffic): seed_traffic_demo command + module docs
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 10827f1 commit fb859f4

4 files changed

Lines changed: 180 additions & 0 deletions

File tree

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/management/__init__.py

Whitespace-only changes.

src/traffic/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Seed active + scheduled traffic reports for local dev / demo."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
7+
from django.core.management.base import BaseCommand
8+
from django.utils import timezone
9+
10+
from consent.services import hash_session_id
11+
from tenancy.models import Island
12+
from tenancy.services import for_island
13+
from traffic import services
14+
from traffic.models import TrafficReport
15+
16+
DEMO_SESSION_PREFIX = 'demo-traffic-'
17+
18+
# (category_slug, road, description, lat, lng)
19+
DEMO_REPORTS: list[tuple] = [
20+
('acidente', 'EN1-1A', 'Despiste junto à rotunda, fila a formar-se.', 37.7411, -25.6756),
21+
('transito', 'Av. Infante D. Henrique', 'Trânsito lento na marginal.', 37.7395, -25.6680),
22+
('obras', 'EN1-1A — Lagoa', 'Trabalhos na via, uma faixa cortada.', 37.7450, -25.5700),
23+
('desvio', 'Ribeira Grande', 'Desvio no centro por evento.', 37.8210, -25.5150),
24+
('inundacao', 'EN1-1A — Água Retorta', 'Água na faixa após chuva forte.', 37.8000, -25.2300),
25+
('perigo', 'Sete Cidades', 'Pedras na via na descida.', 37.8600, -25.7900),
26+
('policia', 'Ponta Delgada', 'Fiscalização à saída da cidade.', 37.7360, -25.6600),
27+
('tempo', 'Lagoa do Fogo', 'Nevoeiro denso, visibilidade reduzida.', 37.8550, -25.4750),
28+
('acidente', 'Furnas', 'Colisão ligeira, trânsito condicionado.', 37.7710, -25.3100),
29+
('transito', 'Nordeste', 'Fila à entrada da vila.', 37.8200, -25.1450),
30+
]
31+
32+
# Schedulable radars announced in advance — (road, description, hours_from_now, duration_h, lat, lng)
33+
DEMO_SCHEDULED: list[tuple] = [
34+
('EN1-1A — Vila Franca', 'Radar anunciado pela PSP.', 2, 3, 37.7180, -25.4330),
35+
('EN2-2A — Rabo de Peixe', 'Operação de velocidade.', 6, 2, 37.8050, -25.5800),
36+
]
37+
38+
39+
class Command(BaseCommand):
40+
help = 'Seed ~10 active + 2 scheduled traffic reports for demo/dev.'
41+
42+
def add_arguments(self, parser):
43+
parser.add_argument('--island', default='sao-miguel', help='Island key (default: sao-miguel)')
44+
parser.add_argument(
45+
'--clear',
46+
action='store_true',
47+
help='Remove reports seeded by this command first',
48+
)
49+
50+
def handle(self, *args, **options):
51+
island_key: str = options['island']
52+
try:
53+
island = Island.objects.get(key=island_key)
54+
except Island.DoesNotExist:
55+
self.stderr.write(self.style.ERROR(f'Unknown island: {island_key}'))
56+
return
57+
58+
if options['clear']:
59+
removed = self._clear_demo(island)
60+
self.stdout.write(f'Removed {removed} demo report(s).')
61+
62+
created = 0
63+
now = timezone.now()
64+
with for_island(island):
65+
for idx, (slug, road, desc, lat, lng) in enumerate(DEMO_REPORTS, start=1):
66+
session_hash = hash_session_id(f'{DEMO_SESSION_PREFIX}{idx}', island.key)
67+
if TrafficReport.objects.filter(
68+
island=island,
69+
created_by_session_hash=session_hash,
70+
).exists():
71+
continue
72+
try:
73+
services.create_report(
74+
island=island,
75+
session_hash=session_hash,
76+
category_slug=slug,
77+
latitude=lat,
78+
longitude=lng,
79+
description=desc,
80+
road=road,
81+
)
82+
created += 1
83+
except services.TrafficError as exc:
84+
self.stderr.write(self.style.WARNING(f'Skip {slug} ({road}): {exc}'))
85+
86+
for idx, (road, desc, start_h, dur_h, lat, lng) in enumerate(DEMO_SCHEDULED, start=1):
87+
session_hash = hash_session_id(f'{DEMO_SESSION_PREFIX}sched-{idx}', island.key)
88+
if TrafficReport.objects.filter(
89+
island=island,
90+
created_by_session_hash=session_hash,
91+
).exists():
92+
continue
93+
active_from = now + timedelta(hours=start_h)
94+
try:
95+
services.create_report(
96+
island=island,
97+
session_hash=session_hash,
98+
category_slug='radar',
99+
latitude=lat,
100+
longitude=lng,
101+
description=desc,
102+
road=road,
103+
active_from=active_from,
104+
active_until=active_from + timedelta(hours=dur_h),
105+
)
106+
created += 1
107+
except services.TrafficError as exc:
108+
self.stderr.write(self.style.WARNING(f'Skip scheduled radar ({road}): {exc}'))
109+
110+
self.stdout.write(
111+
self.style.SUCCESS(
112+
f'Seeded {created} traffic report(s) on {island_key} '
113+
f'({len(DEMO_REPORTS)} active + {len(DEMO_SCHEDULED)} scheduled available).'
114+
)
115+
)
116+
117+
def _clear_demo(self, island: Island) -> int:
118+
with for_island(island):
119+
reports = TrafficReport.objects.filter(
120+
created_by_session_hash__startswith=DEMO_SESSION_PREFIX
121+
)
122+
count = reports.count()
123+
reports.delete()
124+
return count

0 commit comments

Comments
 (0)