from __future__ import annotations from datetime import date, datetime from typing import Literal from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db from app.core.config import get_settings from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.outbox import OutboxEvent from app.models.vehicle import Vehicle from app.schemas import ( AttentionItem, AutomationRunOut, CurrentUser, DashboardOut, EvidenceSignalOut, TodayItem, ) from app.services.operations import compute_metrics router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"]) settings = get_settings() _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} def _local_tz() -> ZoneInfo: return ZoneInfo(settings.demo_timezone) def _today() -> date: # Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor # shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting. # Timestamps are stored in UTC but the operational day is the local (Europe/Brussels) # calendar day, so a 23:30Z departure belongs to tomorrow's schedule in summer. return datetime.now(_local_tz()).date() def _local_date(value: datetime) -> date: return value.astimezone(_local_tz()).date() @router.get("", response_model=DashboardOut) def get_dashboard( db: Session = Depends(get_db), _user: CurrentUser = Depends(get_current_user), ) -> DashboardOut: metrics = compute_metrics(db) vehicles_by_id = {v.id: v for v in db.scalars(select(Vehicle)).all()} customers_by_id = {c.id: c for c in db.scalars(select(Customer)).all()} issues = db.scalars( select(DataQualityIssue) .where(DataQualityIssue.status == "open") .order_by(DataQualityIssue.detected_at.asc()) ).all() attention_items = [] for issue in issues: entity: Vehicle | Customer | None link_type: Literal["vehicle", "customer"] if issue.entity_type == "vehicle": entity = vehicles_by_id.get(issue.entity_id) link_type = "vehicle" else: entity = customers_by_id.get(issue.entity_id) link_type = "customer" link_ref = entity.public_ref if entity else "" # The backend never emits prose for the attention queue -- only stable signal # codes + raw data params, exactly like the issue detail page's evidence list # (see app/services/data_quality.py::_open_issue). The frontend is the one place # that turns these into the operator's selected language; `evidence_json["summary"]` # is a technical fallback only, never rendered here. signals = [ EvidenceSignalOut(code=s["code"], params=s.get("params", {})) for s in issue.evidence_json.get("signals", []) ] attention_items.append( AttentionItem( kind="quality_issue", severity=issue.severity, rule_type=issue.rule_type, evidence_signals=signals, link_type=link_type, link_ref=link_ref, issue_ref=issue.public_ref, ) ) # Curate a credible severity mix instead of letting `high` dominate every slot: # each item's real severity is unchanged, only the display selection is capped per # tier (a handful of "now", then "today", then "later") so a heavy day of high-severity # issues doesn't crowd out medium/low ones the operator should still see. high_items = [i for i in attention_items if i.severity == "high"] medium_items = [i for i in attention_items if i.severity == "medium"] low_items = [i for i in attention_items if i.severity == "low"] attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8] today = _today() bookings = db.scalars(select(Booking)).all() today_items: list[TodayItem] = [] for b in bookings: vehicle = vehicles_by_id.get(b.vehicle_id) vehicle_ref = vehicle.public_ref if vehicle else "" if _local_date(b.starts_at) == today and b.status in ("reserved", "active"): today_items.append( TodayItem( kind="departure", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, scheduled_at=b.starts_at, ) ) if _local_date(b.ends_at) == today and b.status in ("active", "returned"): today_items.append( TodayItem( kind="return", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, scheduled_at=b.ends_at, ) ) today_items.sort(key=lambda item: item.scheduled_at) recent = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all() recent_automation = [ AutomationRunOut( event_id=str(r.event_id), event_type=r.event_type, aggregate_ref=r.payload_json.get("aggregate_ref", ""), status=r.delivery_status, attempts=r.attempts, last_error=r.last_error, last_error_code=r.last_error_code, occurred_at=r.occurred_at, ) for r in recent ] return DashboardOut( metrics=metrics, attention_items=attention_items, today=today_items, recent_automation=recent_automation, )