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"]) 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}"'}, )