fleet-platform/app/routers/views.py
kianiadee 34afe60927
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions
Guard day_track call with function-existence check (order-independent deploy)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 10:32:59 +03:00

153 lines
5.5 KiB
Python

import csv
import io
import json
from datetime import UTC, date, datetime, timedelta
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from fastapi.responses import Response
from psycopg.types.json import Jsonb
from app.auth import AuthAccount, require_scope
from app.db import get_pool
from app.models.views import LiveViewResponse
from app.rate_limit import limiter
router = APIRouter(prefix="/api/views", tags=["views"])
_FILTERS_DESC = "JSON object: cost_centre, assigned_city, vehicle_numbers[]"
def _parse_filters(filters_q: str | None) -> dict[str, Any]:
if not filters_q:
return {}
try:
parsed = json.loads(filters_q)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"invalid filters json: {exc}") from exc
if not isinstance(parsed, dict):
raise HTTPException(status_code=400, detail="filters must be a JSON object")
return parsed
@router.get("/live", response_model=LiveViewResponse)
@limiter.limit("60/minute")
async def live_view(
request: Request,
_account: Annotated[AuthAccount, Depends(require_scope("read:fleet"))],
filters: Annotated[str | None, Query(description=_FILTERS_DESC)] = None,
) -> LiveViewResponse:
_ = request
filters_dict = _parse_filters(filters)
pool = await get_pool()
async with pool.connection() as conn, conn.cursor() as cur:
await cur.execute("SELECT serve.fn_live_view(%s)", (Jsonb(filters_dict),))
row = await cur.fetchone()
if row is None or row[0] is None:
raise HTTPException(status_code=500, detail="serve.fn_live_view returned NULL")
return LiveViewResponse.model_validate(row[0])
def _resolve_date(date_q: str | None) -> date:
"""Default to today in EAT (UTC+3) when no ?date= is provided."""
if date_q:
try:
return date.fromisoformat(date_q)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid date: {exc}") from exc
return (datetime.now(UTC) + timedelta(hours=3)).date()
async def _fetch_trips(vehicle_id: int, day: date) -> dict[str, Any]:
pool = await get_pool()
async with pool.connection() as conn, conn.cursor() as cur:
await cur.execute(
"SELECT serve.fn_vehicle_trips(%s, %s)", (vehicle_id, day)
)
row = await cur.fetchone()
if row is None or row[0] is None:
raise HTTPException(status_code=500, detail="serve.fn_vehicle_trips returned NULL")
payload: dict[str, Any] = row[0]
if "error" in payload:
raise HTTPException(status_code=404, detail=payload["error"])
# Continuous day track (GeoJSON LineString of every fix, in order) so the
# frontend can draw one unbroken route under the per-trip coloured
# segments — trip splits on reporting gaps no longer look like the
# vehicle teleported. Guarded by an existence check so the endpoint keeps
# working if the code is deployed before migration 24 lands.
track: Any = None
await cur.execute(
"SELECT to_regprocedure('serve.fn_vehicle_day_track(bigint, date)') IS NOT NULL"
)
exists_row = await cur.fetchone()
if exists_row and exists_row[0]:
await cur.execute(
"SELECT serve.fn_vehicle_day_track(%s, %s)", (vehicle_id, day)
)
track_row = await cur.fetchone()
track = track_row[0] if track_row else None
payload["day_track"] = track
return payload
@router.get("/vehicle/{vehicle_id}/trips")
@limiter.limit("60/minute")
async def vehicle_trips(
request: Request,
_account: Annotated[AuthAccount, Depends(require_scope("read:fleet"))],
vehicle_id: Annotated[int, Path(ge=1)],
date_q: Annotated[
str | None, Query(alias="date", description="YYYY-MM-DD in EAT; defaults to today")
] = None,
) -> dict[str, Any]:
_ = request
day = _resolve_date(date_q)
return await _fetch_trips(vehicle_id, day)
@router.get("/vehicle/{vehicle_id}/trips.csv")
@limiter.limit("30/minute")
async def vehicle_trips_csv(
request: Request,
_account: Annotated[AuthAccount, Depends(require_scope("read:fleet"))],
vehicle_id: Annotated[int, Path(ge=1)],
date_q: Annotated[
str | None, Query(alias="date", description="YYYY-MM-DD in EAT; defaults to today")
] = None,
) -> Response:
_ = request
day = _resolve_date(date_q)
payload = await _fetch_trips(vehicle_id, day)
plate = payload.get("plate") or ""
reporting_time = payload.get("reporting_time") or ""
date_str = payload.get("date", day.isoformat())
buf = io.StringIO()
w = csv.writer(buf)
w.writerow([
"date", "plate", "reporting_time",
"trip_id", "started_at", "ended_at",
"duration_min", "distance_km", "idling_min", "end_reason",
])
for trip in payload.get("trips", []):
w.writerow([
date_str,
plate,
reporting_time,
trip.get("trip_id"),
trip.get("started_at"),
trip.get("ended_at"),
trip.get("duration_min"),
trip.get("distance_km"),
trip.get("idling_min"),
trip.get("end_reason"),
])
safe_plate = (plate or f"v{vehicle_id}").replace(" ", "_").replace("/", "-")
filename = f"trips_{safe_plate}_{date_str}.csv"
return Response(
content=buf.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)