fleet-platform/app/routers/views.py
kianiadee 419c030761
Some checks are pending
build / lint-test (push) Waiting to run
build / build-push (push) Blocked by required conditions
Trip detection backend: serve.fn_vehicle_trips + JSON/CSV endpoints
Migration 19: serve.fn_vehicle_trips(vehicle_id, date) — PL/pgSQL state
machine that walks state.position_history for one vehicle on one EAT day
and emits the trip breakdown. Rules:

  - reporting_time = first ACC_ON of the day
  - trip starts at ACC_ON (or first fix if already ACC_ON / moving)
  - trip ends:
      * ACC_OFF + stationary (<5 km/h) for >=5 min  → end_reason 'work_stop'
      * fix gap >30 min                              → end_reason 'long_gap'
      * end of day's data                            → end_reason 'day_end'
  - within a trip, ACC_ON + stationary >=5 min is logged as an idling
    segment (no trip split — engine still on)
  - distance only accumulates when speed >= 5 km/h (excludes GPS jitter)
  - falls back to movement-only segmentation when acc_state is null
    across the day (has_acc_data=false in the response)

Returns one jsonb document: vehicle, date, reporting_time, day totals
(distance, driving/idling/stopped/unknown minutes), data_quality flags,
trips[] with start/end/duration/distance/idling/end_reason/stops/path
where path is a GeoJSON LineString ready for the map.

New endpoints (read:fleet, rate-limited):
  GET /api/views/vehicle/{id}/trips?date=YYYY-MM-DD       JSON
  GET /api/views/vehicle/{id}/trips.csv?date=YYYY-MM-DD   one row per trip

Defaults date to today in EAT (UTC+3) regardless of host TZ.
2026-05-27 12:24:00 +03:00

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