2026-05-27 09:24:00 +00:00
|
|
|
import csv
|
|
|
|
|
import io
|
2026-05-22 21:53:42 +00:00
|
|
|
import json
|
2026-05-27 09:24:00 +00:00
|
|
|
from datetime import UTC, date, datetime, timedelta
|
2026-05-22 21:53:42 +00:00
|
|
|
from typing import Annotated, Any
|
|
|
|
|
|
2026-05-27 09:24:00 +00:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
|
|
|
|
|
from fastapi.responses import Response
|
2026-05-22 21:53:42 +00:00
|
|
|
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])
|
2026-05-27 09:24:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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}"'},
|
|
|
|
|
)
|