fleet-platform/app/routers/views.py

137 lines
4.6 KiB
Python
Raw Normal View History

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