Skip to content

Commit 51be32d

Browse files
hc-sousacursoragent
andcommitted
feat(traffic): services — CRUD, geo search, voting, lifecycle
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8a5acbe commit 51be32d

2 files changed

Lines changed: 548 additions & 0 deletions

File tree

src/traffic/services.py

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
"""Traffic business logic: CRUD, geo search, voting, lifecycle, serialization.
2+
3+
Reports are **public on create** (no moderation queue). Reads/writes assume the
4+
active island is bound (v3 views wrap calls in ``with for_island(request.island):``).
5+
Creates pass ``island`` explicitly. Ownership is pseudonymous via
6+
``created_by_session_hash``. ``run_lifecycle`` runs unscoped across islands from
7+
the Celery beat task (time-driven transitions are tenant-agnostic).
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import math
13+
from datetime import timedelta
14+
from typing import Any
15+
16+
from django.utils import timezone
17+
18+
from traffic.models import TrafficCategory, TrafficConfirmation, TrafficReport
19+
20+
MAX_LIMIT = 200
21+
DEFAULT_LIMIT = 100
22+
DENY_THRESHOLD = 3
23+
EARTH_RADIUS_KM = 6371.0
24+
25+
26+
class TrafficError(Exception):
27+
"""Base for traffic service errors."""
28+
29+
30+
class OwnershipError(TrafficError):
31+
"""Raised when a non-owner / non-staff attempts a restricted write."""
32+
33+
34+
class CategoryNotFound(TrafficError):
35+
"""Raised when a write references an unknown category slug."""
36+
37+
38+
class LocationImplausible(TrafficError):
39+
"""Raised when report coordinates fall outside the island radius."""
40+
41+
42+
class SchedulingNotAllowed(TrafficError):
43+
"""Raised when active_from is set on a non-schedulable category."""
44+
45+
46+
# --------------------------------------------------------------------------- #
47+
# Geo helpers
48+
# --------------------------------------------------------------------------- #
49+
50+
def haversine_km(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
51+
d_lat = math.radians(lat2 - lat1)
52+
d_lng = math.radians(lng2 - lng1)
53+
a = (
54+
math.sin(d_lat / 2) ** 2
55+
+ math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(d_lng / 2) ** 2
56+
)
57+
return EARTH_RADIUS_KM * 2 * math.asin(math.sqrt(a))
58+
59+
60+
# --------------------------------------------------------------------------- #
61+
# Serialization
62+
# --------------------------------------------------------------------------- #
63+
64+
def serialize_category(category: TrafficCategory) -> dict[str, Any]:
65+
return {
66+
'id': category.id,
67+
'name': category.name,
68+
'slug': category.slug,
69+
'icon': category.icon,
70+
'defaultTtlMinutes': category.default_ttl_minutes,
71+
'isSchedulable': category.is_schedulable,
72+
'order': category.order,
73+
}
74+
75+
76+
def serialize_report(report: TrafficReport) -> dict[str, Any]:
77+
return {
78+
'id': report.id,
79+
'status': report.status,
80+
'category': {
81+
'id': report.category_id,
82+
'name': report.category.name,
83+
'slug': report.category.slug,
84+
'icon': report.category.icon,
85+
},
86+
'latitude': report.latitude,
87+
'longitude': report.longitude,
88+
'description': report.description,
89+
'road': report.road,
90+
'confidence': {'confirm': report.confirm_count, 'deny': report.deny_count},
91+
'activeFrom': report.active_from.isoformat() if report.active_from else None,
92+
'activeUntil': report.active_until.isoformat() if report.active_until else None,
93+
'expiresAt': report.expires_at.isoformat() if report.expires_at else None,
94+
'createdAt': report.created_at.isoformat(),
95+
}
96+
97+
98+
# --------------------------------------------------------------------------- #
99+
# Categories
100+
# --------------------------------------------------------------------------- #
101+
102+
def list_categories() -> list[dict[str, Any]]:
103+
return [serialize_category(c) for c in TrafficCategory.objects.all()]
104+
105+
106+
def _resolve_category(slug: str) -> TrafficCategory:
107+
try:
108+
return TrafficCategory.objects.get(slug=slug)
109+
except TrafficCategory.DoesNotExist as exc:
110+
raise CategoryNotFound(slug) from exc
111+
112+
113+
# --------------------------------------------------------------------------- #
114+
# Reports — reads
115+
# --------------------------------------------------------------------------- #
116+
117+
def list_reports(
118+
*,
119+
lat: float | None = None,
120+
lng: float | None = None,
121+
radius_km: float | None = None,
122+
bbox: tuple[float, float, float, float] | None = None,
123+
category: str | None = None,
124+
include_scheduled: bool = False,
125+
limit: int = DEFAULT_LIMIT,
126+
) -> list[dict[str, Any]]:
127+
statuses = [TrafficReport.ACTIVE]
128+
if include_scheduled:
129+
statuses.append(TrafficReport.SCHEDULED)
130+
131+
qs = TrafficReport.objects.select_related('category').filter(status__in=statuses)
132+
if category:
133+
qs = qs.filter(category__slug=category)
134+
135+
if bbox is not None:
136+
min_lng, min_lat, max_lng, max_lat = bbox
137+
qs = qs.filter(
138+
latitude__gte=min_lat, latitude__lte=max_lat,
139+
longitude__gte=min_lng, longitude__lte=max_lng,
140+
)
141+
142+
limit = max(1, min(limit, MAX_LIMIT))
143+
144+
if lat is not None and lng is not None and radius_km is not None:
145+
within = [
146+
r for r in qs
147+
if haversine_km(lat, lng, r.latitude, r.longitude) <= radius_km
148+
]
149+
within.sort(key=lambda r: haversine_km(lat, lng, r.latitude, r.longitude))
150+
return [serialize_report(r) for r in within[:limit]]
151+
152+
return [serialize_report(r) for r in qs[:limit]]
153+
154+
155+
def _get_report_or_none(report_id: int) -> TrafficReport | None:
156+
try:
157+
return TrafficReport.objects.select_related('category').get(id=report_id)
158+
except TrafficReport.DoesNotExist:
159+
return None
160+
161+
162+
def get_report(report_id: int, *, is_staff: bool = False) -> dict[str, Any] | None:
163+
report = _get_report_or_none(report_id)
164+
if report is None:
165+
return None
166+
if report.status == TrafficReport.REMOVED and not is_staff:
167+
return None
168+
return serialize_report(report)
169+
170+
171+
# --------------------------------------------------------------------------- #
172+
# Reports — writes
173+
# --------------------------------------------------------------------------- #
174+
175+
def _check_plausible(island, latitude: float, longitude: float) -> None:
176+
distance = haversine_km(island.center_lat, island.center_lng, latitude, longitude)
177+
if distance > island.radius_km:
178+
raise LocationImplausible(f'{distance:.1f}km from island center')
179+
180+
181+
def create_report(
182+
*,
183+
island,
184+
session_hash: str,
185+
category_slug: str,
186+
latitude: float,
187+
longitude: float,
188+
description: str = '',
189+
road: str = '',
190+
active_from=None,
191+
active_until=None,
192+
) -> dict[str, Any]:
193+
category = _resolve_category(category_slug)
194+
_check_plausible(island, latitude, longitude)
195+
196+
now = timezone.now()
197+
ttl = timedelta(minutes=category.default_ttl_minutes)
198+
199+
if active_from and active_from > now:
200+
if not category.is_schedulable:
201+
raise SchedulingNotAllowed(category_slug)
202+
status = TrafficReport.SCHEDULED
203+
expires_at = active_until or (active_from + ttl)
204+
else:
205+
active_from = None
206+
status = TrafficReport.ACTIVE
207+
expires_at = active_until or (now + ttl)
208+
209+
report = TrafficReport.objects.create(
210+
island=island,
211+
category=category,
212+
created_by_session_hash=session_hash,
213+
latitude=latitude,
214+
longitude=longitude,
215+
description=description,
216+
road=road,
217+
active_from=active_from,
218+
active_until=active_until,
219+
expires_at=expires_at,
220+
status=status,
221+
)
222+
return serialize_report(report)
223+
224+
225+
_REPORT_WRITE_FIELDS = ('latitude', 'longitude', 'description', 'road')
226+
227+
228+
def update_report(
229+
report_id: int,
230+
*,
231+
session_hash: str,
232+
is_staff: bool,
233+
data: dict[str, Any],
234+
) -> dict[str, Any] | None:
235+
report = _get_report_or_none(report_id)
236+
if report is None or report.status == TrafficReport.REMOVED:
237+
return None
238+
if not (is_staff or report.is_owned_by(session_hash)):
239+
raise OwnershipError(report_id)
240+
for field in _REPORT_WRITE_FIELDS:
241+
if field in data:
242+
setattr(report, field, data[field])
243+
report.save()
244+
return serialize_report(report)
245+
246+
247+
def soft_delete_report(report_id: int, *, session_hash: str, is_staff: bool) -> bool | None:
248+
report = _get_report_or_none(report_id)
249+
if report is None or report.status == TrafficReport.REMOVED:
250+
return None
251+
if not (is_staff or report.is_owned_by(session_hash)):
252+
raise OwnershipError(report_id)
253+
report.status = TrafficReport.REMOVED
254+
report.save(update_fields=['status', 'updated_at'])
255+
return True
256+
257+
258+
# --------------------------------------------------------------------------- #
259+
# Confirmations (voting)
260+
# --------------------------------------------------------------------------- #
261+
262+
def upsert_confirmation(
263+
*,
264+
report_id: int,
265+
session_hash: str,
266+
vote: str,
267+
) -> tuple[dict[str, Any], bool] | None:
268+
report = _get_report_or_none(report_id)
269+
if report is None or report.status in (TrafficReport.REMOVED, TrafficReport.EXPIRED):
270+
return None
271+
272+
_, created = TrafficConfirmation.objects.update_or_create(
273+
report=report,
274+
session_hash=session_hash,
275+
defaults={'island': report.island, 'vote': vote},
276+
)
277+
_recompute_confidence(report)
278+
return serialize_report(report), created
279+
280+
281+
def _recompute_confidence(report: TrafficReport) -> None:
282+
votes = TrafficConfirmation.objects.filter(report=report)
283+
confirm = votes.filter(vote=TrafficConfirmation.STILL_THERE).count()
284+
deny = votes.filter(vote=TrafficConfirmation.GONE).count()
285+
report.confirm_count = confirm
286+
report.deny_count = deny
287+
288+
if deny >= DENY_THRESHOLD:
289+
report.status = TrafficReport.EXPIRED
290+
elif report.status == TrafficReport.ACTIVE:
291+
# A fresh "still there" extends life by half the category TTL, capped
292+
# at one full TTL ahead of now.
293+
ttl = timedelta(minutes=report.category.default_ttl_minutes)
294+
now = timezone.now()
295+
extended = report.expires_at + (ttl / 2)
296+
report.expires_at = min(extended, now + ttl)
297+
298+
report.save(update_fields=['confirm_count', 'deny_count', 'status', 'expires_at', 'updated_at'])
299+
300+
301+
# --------------------------------------------------------------------------- #
302+
# Lifecycle (Celery)
303+
# --------------------------------------------------------------------------- #
304+
305+
def run_lifecycle(*, now=None) -> dict[str, int]:
306+
"""Activate due scheduled reports and expire stale active ones.
307+
308+
Runs unscoped across all islands — these transitions are time-driven and
309+
tenant-agnostic (explicit cross-tenant Celery operation per SDD/11 §3).
310+
Idempotent and re-runnable.
311+
"""
312+
now = now or timezone.now()
313+
314+
activated = (
315+
TrafficReport.objects.unscoped()
316+
.filter(status=TrafficReport.SCHEDULED, active_from__lte=now)
317+
.update(status=TrafficReport.ACTIVE)
318+
)
319+
expired = (
320+
TrafficReport.objects.unscoped()
321+
.filter(status=TrafficReport.ACTIVE, expires_at__lte=now)
322+
.update(status=TrafficReport.EXPIRED)
323+
)
324+
return {'activated': activated, 'expired': expired}

0 commit comments

Comments
 (0)