Publish curated MobilityOps source snapshot
This commit is contained in:
commit
7adc072f5e
317 files changed
+48851
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||
settings = get_settings()
|
||||
|
||||
# Only entity types with a stable public reference and (optionally) a real frontend route
|
||||
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
|
||||
# entity_id and are left as plain labels.
|
||||
_ENTITY_MODELS: dict[str, Any] = {
|
||||
"vehicle": Vehicle,
|
||||
"booking": Booking,
|
||||
"customer": Customer,
|
||||
"data_quality_issue": DataQualityIssue,
|
||||
}
|
||||
_ROUTE_TEMPLATES: dict[str, str] = {
|
||||
"vehicle": "/vehicles/{ref}",
|
||||
"booking": "/bookings/{ref}",
|
||||
"data_quality_issue": "/data-quality/{ref}",
|
||||
# No customer detail route exists in this proof of concept; still resolve the
|
||||
# reference for display, just without a link.
|
||||
}
|
||||
|
||||
|
||||
def _as_utc(value: datetime | None) -> datetime | None:
|
||||
"""Treat naive query datetimes as UTC so they compare safely with aware values."""
|
||||
if value is None:
|
||||
return None
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
@router.get("/export.csv")
|
||||
def export_audit_csv(
|
||||
occurred_from: datetime | None = Query(default=None),
|
||||
occurred_to: datetime | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> Response:
|
||||
end = _as_utc(occurred_to) or datetime.now(UTC)
|
||||
start = _as_utc(occurred_from) or end - timedelta(days=30)
|
||||
if end <= start or end - start > timedelta(days=90):
|
||||
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
|
||||
events = db.scalars(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.occurred_at >= start, AuditEvent.occurred_at <= end)
|
||||
.order_by(AuditEvent.occurred_at)
|
||||
.limit(settings.privacy_audit_export_max_rows + 1)
|
||||
).all()
|
||||
if len(events) > settings.privacy_audit_export_max_rows:
|
||||
raise HTTPException(status_code=413, detail="Audit export exceeds configured row limit")
|
||||
output = io.StringIO(newline="")
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
(
|
||||
"id",
|
||||
"occurred_at",
|
||||
"actor_type",
|
||||
"actor_label",
|
||||
"action",
|
||||
"entity_type",
|
||||
"entity_id",
|
||||
"correlation_id",
|
||||
"before",
|
||||
"after",
|
||||
"metadata",
|
||||
)
|
||||
)
|
||||
for event in events:
|
||||
writer.writerow(
|
||||
(
|
||||
event.id,
|
||||
event.occurred_at.isoformat(),
|
||||
event.actor_type,
|
||||
event.actor_label,
|
||||
event.action,
|
||||
event.entity_type,
|
||||
event.entity_id or "",
|
||||
event.correlation_id,
|
||||
json.dumps(event.before_json, separators=(",", ":"), default=str),
|
||||
json.dumps(event.after_json, separators=(",", ":"), default=str),
|
||||
json.dumps(event.metadata_json, separators=(",", ":"), default=str),
|
||||
)
|
||||
)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="audit_exported",
|
||||
entity_type="audit",
|
||||
metadata={"from": start.isoformat(), "to": end.isoformat(), "rows": len(events)},
|
||||
)
|
||||
db.commit()
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="mobilityops-audit.csv"'},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
|
||||
ids_by_type: dict[str, set[uuid.UUID]] = {}
|
||||
for event in events:
|
||||
if event.entity_id is not None and event.entity_type in _ENTITY_MODELS:
|
||||
ids_by_type.setdefault(event.entity_type, set()).add(event.entity_id)
|
||||
|
||||
refs: dict[uuid.UUID, str] = {}
|
||||
for entity_type, ids in ids_by_type.items():
|
||||
model = _ENTITY_MODELS[entity_type]
|
||||
rows: Sequence[Any] = db.scalars(select(model).where(model.id.in_(ids))).all()
|
||||
for row in rows:
|
||||
refs[row.id] = row.public_ref
|
||||
return refs
|
||||
|
||||
|
||||
@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
|
||||
def list_audit_events(
|
||||
actor_label: str | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
entity_type: str | None = Query(default=None),
|
||||
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
correlation_id: uuid.UUID | None = Query(default=None),
|
||||
occurred_from: datetime | None = Query(default=None),
|
||||
occurred_to: datetime | None = Query(default=None),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[AuditEventOut] | AuditEventPageOut:
|
||||
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
|
||||
if actor_label:
|
||||
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
||||
if action:
|
||||
stmt = stmt.where(AuditEvent.action == action)
|
||||
if entity_type:
|
||||
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
||||
if entity_ref:
|
||||
matched_ids: set[uuid.UUID] = set()
|
||||
for model in _ENTITY_MODELS.values():
|
||||
matched_ids.update(
|
||||
db.scalars(
|
||||
select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))
|
||||
).all()
|
||||
)
|
||||
if not matched_ids:
|
||||
if page is None:
|
||||
return []
|
||||
return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
|
||||
if correlation_id:
|
||||
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||
if occurred_from:
|
||||
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
|
||||
if occurred_to:
|
||||
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
events = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
entity_refs = _resolve_entity_refs(db, events)
|
||||
|
||||
out = []
|
||||
for e in events:
|
||||
ref = entity_refs.get(e.entity_id) if e.entity_id else None
|
||||
route = _ROUTE_TEMPLATES.get(e.entity_type)
|
||||
out.append(
|
||||
AuditEventOut(
|
||||
id=str(e.id),
|
||||
actor_type=e.actor_type,
|
||||
actor_label=e.actor_label,
|
||||
action=e.action,
|
||||
entity_type=e.entity_type,
|
||||
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||
entity_ref=ref,
|
||||
entity_link=route.format(ref=ref) if route and ref else None,
|
||||
correlation_id=str(e.correlation_id),
|
||||
occurred_at=e.occurred_at,
|
||||
before=e.before_json,
|
||||
after=e.after_json,
|
||||
metadata=e.metadata_json,
|
||||
)
|
||||
)
|
||||
if page is None:
|
||||
return out
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
return AuditEventPageOut(
|
||||
items=out,
|
||||
page=min(page_number, total_pages),
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
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.core.ratelimit import FailedAttemptLimiter
|
||||
from app.core.security import (
|
||||
SessionPayload,
|
||||
create_session_token,
|
||||
hash_password,
|
||||
read_session_token,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
_login_limiter = (
|
||||
FailedAttemptLimiter(
|
||||
max_failures=settings.login_max_failures,
|
||||
window_seconds=settings.login_failure_window_seconds,
|
||||
)
|
||||
if settings.login_max_failures > 0
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _client_key(request: Request) -> str:
|
||||
# The API sits behind the web container's reverse proxy in every documented
|
||||
# deployment. The proxy appends/overwrites the socket peer as the final hop, so an
|
||||
# attacker-controlled leading value must never select a fresh limiter bucket.
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[-1].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
oauth = OAuth()
|
||||
if settings.oidc_enabled and settings.oidc_issuer_url:
|
||||
oauth.register(
|
||||
name="oidc",
|
||||
client_id=settings.oidc_client_id,
|
||||
client_secret=settings.oidc_client_secret,
|
||||
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid email profile"},
|
||||
)
|
||||
|
||||
|
||||
def _current_user_out(user: User) -> CurrentUser:
|
||||
return CurrentUser(
|
||||
public_ref=user.public_ref,
|
||||
display_name=user.display_name,
|
||||
role=user.role, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _set_session(response: Response, user: User) -> None:
|
||||
token = create_session_token(
|
||||
SessionPayload(
|
||||
user_id=str(user.id),
|
||||
public_ref=user.public_ref,
|
||||
role=user.role,
|
||||
display_name=user.display_name,
|
||||
issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.session_cookie_secure,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_initial_admin(db: Session) -> None:
|
||||
"""Create or rotate the explicitly configured first manager in operational mode."""
|
||||
if (
|
||||
settings.mobilityops_demo_mode
|
||||
or not settings.initial_admin_email
|
||||
or not settings.initial_admin_password
|
||||
):
|
||||
return
|
||||
email = settings.initial_admin_email.strip().lower()
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is None:
|
||||
user = User(
|
||||
public_ref="USR-ADMIN",
|
||||
email=email,
|
||||
password_hash=hash_password(settings.initial_admin_password),
|
||||
display_name=settings.initial_admin_display_name,
|
||||
role="operations_manager",
|
||||
active=True,
|
||||
)
|
||||
db.add(user)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="system",
|
||||
actor_label="bootstrap",
|
||||
action="operational_admin_created",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _oidc_configured() -> bool:
|
||||
return bool(
|
||||
settings.oidc_enabled
|
||||
and settings.oidc_issuer_url
|
||||
and settings.oidc_client_id
|
||||
and settings.oidc_client_secret
|
||||
)
|
||||
|
||||
|
||||
def _oidc_redirect_uri() -> str:
|
||||
return settings.oidc_redirect_uri or (
|
||||
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
|
||||
)
|
||||
|
||||
|
||||
def _allowed_oidc_email(email: str) -> bool:
|
||||
domains = {
|
||||
value.strip().casefold()
|
||||
for value in settings.oidc_allowed_email_domains.split(",")
|
||||
if value.strip()
|
||||
}
|
||||
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
|
||||
|
||||
|
||||
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
|
||||
subject = str(claims.get("sub") or "").strip()
|
||||
email = str(claims.get("email") or "").strip().lower()
|
||||
if not subject or not email or claims.get("email_verified") is not True:
|
||||
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
|
||||
if not _allowed_oidc_email(email):
|
||||
raise HTTPException(status_code=403, detail="Email domain is not allowed")
|
||||
|
||||
provider = settings.oidc_issuer_url.rstrip("/")
|
||||
user = db.scalar(
|
||||
select(User).where(
|
||||
User.identity_provider == provider,
|
||||
User.external_subject == subject,
|
||||
)
|
||||
)
|
||||
if user is None:
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
if user is not None and user.external_subject not in (None, subject):
|
||||
raise HTTPException(status_code=409, detail="Email is linked to another identity")
|
||||
if user is not None and claims.get("email_verified") is not True:
|
||||
# Linking an existing local account (possibly the bootstrap admin) purely on an
|
||||
# email match requires the IdP to explicitly assert the address is verified;
|
||||
# an absent claim is treated as unverified.
|
||||
raise HTTPException(status_code=401, detail="Verified OIDC email is required")
|
||||
created = user is None
|
||||
if created:
|
||||
if not settings.oidc_auto_provision:
|
||||
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
|
||||
role = settings.oidc_default_role
|
||||
if role not in {"operations_manager", "rental_employee"}:
|
||||
role = "rental_employee"
|
||||
user = User(
|
||||
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
|
||||
email=email,
|
||||
password_hash=None,
|
||||
display_name=str(claims.get("name") or email),
|
||||
role=role,
|
||||
active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
assert user is not None
|
||||
if not user.active:
|
||||
raise HTTPException(status_code=403, detail="User is inactive")
|
||||
user.identity_provider = provider
|
||||
user.external_subject = subject
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="system" if created else "user",
|
||||
actor_id=None if created else user.id,
|
||||
actor_label=settings.oidc_provider_name,
|
||||
action="oidc_user_provisioned" if created else "oidc_identity_linked",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
metadata={"provider": provider},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/oidc/status", response_model=OidcStatusOut)
|
||||
def oidc_status() -> OidcStatusOut:
|
||||
return OidcStatusOut(
|
||||
enabled=_oidc_configured(),
|
||||
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request) -> Response:
|
||||
if not _oidc_configured():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
||||
client = oauth.create_client("oidc")
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
||||
return await client.authorize_redirect(request, _oidc_redirect_uri())
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
|
||||
if not _oidc_configured():
|
||||
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
||||
client = oauth.create_client("oidc")
|
||||
if client is None:
|
||||
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except OAuthError as exc:
|
||||
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
|
||||
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
|
||||
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
|
||||
_set_session(response, user)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=user.id,
|
||||
actor_label=user.display_name,
|
||||
action="oidc_login",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
|
||||
)
|
||||
db.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def password_login(
|
||||
body: PasswordLoginRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
) -> CurrentUser:
|
||||
if settings.mobilityops_demo_mode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Password login is unavailable in demo mode",
|
||||
)
|
||||
limiter_key = _client_key(request)
|
||||
retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
|
||||
if user is None or not user.active or not verify_password(body.password, user.password_hash):
|
||||
if _login_limiter:
|
||||
_login_limiter.record_failure(limiter_key)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if _login_limiter:
|
||||
_login_limiter.reset(limiter_key)
|
||||
_set_session(response, user)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=user.id,
|
||||
actor_label=user.display_name,
|
||||
action="password_login",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return _current_user_out(user)
|
||||
|
||||
|
||||
@router.get("/session", response_model=CurrentUser)
|
||||
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None and token is not None:
|
||||
revoke_session(db, token, payload)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=uuid.UUID(payload.user_id),
|
||||
actor_label=payload.display_name,
|
||||
action="logout",
|
||||
entity_type="user",
|
||||
)
|
||||
db.commit()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {"status": "logged_out"}
|
||||
@@ -0,0 +1,565 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
AvailableVehicleOut,
|
||||
BookingOut,
|
||||
BookingPageOut,
|
||||
CancelBookingRequest,
|
||||
CheckoutBookingRequest,
|
||||
CheckoutBookingResult,
|
||||
CompleteBookingRequirementsRequest,
|
||||
CreateBookingRequest,
|
||||
CurrentUser,
|
||||
NextBookingRisk,
|
||||
RegisterReturnRequest,
|
||||
RegisterReturnResult,
|
||||
RescheduleBookingRequest,
|
||||
ReturnPreviewResult,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import open_odometer_regression_issue
|
||||
from app.services.returns import preview_vehicle_return, register_vehicle_return
|
||||
|
||||
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
||||
|
||||
|
||||
def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOut:
|
||||
return BookingOut(
|
||||
public_ref=booking.public_ref,
|
||||
customer_ref=customer.public_ref,
|
||||
vehicle_ref=vehicle.public_ref,
|
||||
starts_at=booking.starts_at,
|
||||
ends_at=booking.ends_at,
|
||||
status=booking.status,
|
||||
start_odometer_km=booking.start_odometer_km,
|
||||
end_odometer_km=booking.end_odometer_km,
|
||||
requirements_complete=booking.requirements_complete,
|
||||
customer_name=f"{customer.first_name} {customer.last_name}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BookingOut] | BookingPageOut)
|
||||
def list_bookings(
|
||||
status: str | None = Query(default=None),
|
||||
vehicle_ref: str | None = Query(default=None),
|
||||
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
starts_from: datetime | None = Query(default=None),
|
||||
starts_to: datetime | None = Query(default=None),
|
||||
location: str | None = Query(default=None, min_length=1, max_length=120),
|
||||
sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[BookingOut] | BookingPageOut:
|
||||
stmt = select(Booking)
|
||||
if status:
|
||||
stmt = stmt.where(Booking.status == status)
|
||||
if vehicle_ref:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
if vehicle is None:
|
||||
if page is None:
|
||||
return []
|
||||
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
||||
if starts_from:
|
||||
stmt = stmt.where(Booking.ends_at >= starts_from)
|
||||
if starts_to:
|
||||
stmt = stmt.where(Booking.starts_at < starts_to)
|
||||
if query or location:
|
||||
stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join(
|
||||
Vehicle, Booking.vehicle_id == Vehicle.id
|
||||
)
|
||||
if location:
|
||||
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
|
||||
if query:
|
||||
term = f"%{query.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Booking.public_ref.ilike(term),
|
||||
Customer.first_name.ilike(term),
|
||||
Customer.last_name.ilike(term),
|
||||
Vehicle.public_ref.ilike(term),
|
||||
)
|
||||
)
|
||||
if sort == "starts_asc":
|
||||
stmt = stmt.order_by(Booking.starts_at.asc())
|
||||
elif sort == "starts_desc":
|
||||
stmt = stmt.order_by(Booking.starts_at.desc())
|
||||
else:
|
||||
now = datetime.now(UTC)
|
||||
operational_bucket = case(
|
||||
(Booking.status == "active", 0),
|
||||
(Booking.starts_at >= now, 1),
|
||||
else_=2,
|
||||
)
|
||||
stmt = stmt.order_by(
|
||||
operational_bucket,
|
||||
case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(),
|
||||
Booking.starts_at.desc(),
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
bookings = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
customer_ids = {booking.customer_id for booking in bookings}
|
||||
vehicle_ids = {booking.vehicle_id for booking in bookings}
|
||||
customers = {
|
||||
customer.id: customer
|
||||
for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all()
|
||||
}
|
||||
vehicles = {
|
||||
vehicle.id: vehicle
|
||||
for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
|
||||
}
|
||||
items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
|
||||
if page is None:
|
||||
return items
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
return BookingPageOut(
|
||||
items=items,
|
||||
page=min(page_number, total_pages),
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=BookingOut, status_code=201)
|
||||
def create_booking(
|
||||
body: CreateBookingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> BookingOut:
|
||||
if body.ends_at <= body.starts_at:
|
||||
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||
customer = db.scalar(
|
||||
select(Customer).where(Customer.public_ref == body.customer_ref).with_for_update()
|
||||
)
|
||||
if (
|
||||
customer is None
|
||||
or customer.merged_into_customer_id is not None
|
||||
or customer.anonymized_at is not None
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
|
||||
# Serialise booking creation per vehicle. The overlap check must run after
|
||||
# acquiring this lock, otherwise two concurrent requests can both pass it.
|
||||
vehicle = db.scalar(
|
||||
select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update()
|
||||
)
|
||||
if (
|
||||
vehicle is None
|
||||
or not vehicle.active
|
||||
or vehicle.operational_status in {"maintenance", "blocked"}
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking")
|
||||
overlap = db.scalar(
|
||||
select(Booking.id).where(
|
||||
Booking.vehicle_id == vehicle.id,
|
||||
Booking.status.in_(("reserved", "active")),
|
||||
Booking.starts_at < body.ends_at,
|
||||
Booking.ends_at > body.starts_at,
|
||||
)
|
||||
)
|
||||
if overlap is not None:
|
||||
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
|
||||
booking = Booking(
|
||||
public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}",
|
||||
customer_id=customer.id,
|
||||
vehicle_id=vehicle.id,
|
||||
starts_at=body.starts_at,
|
||||
ends_at=body.ends_at,
|
||||
status="reserved",
|
||||
start_odometer_km=None,
|
||||
end_odometer_km=None,
|
||||
# Requirements are intentionally confirmed in a separate, audited action.
|
||||
# Never allow booking creation to bypass that operational checkpoint.
|
||||
requirements_complete=False,
|
||||
)
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="booking_created",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult)
|
||||
def checkout_booking(
|
||||
public_ref: str,
|
||||
body: CheckoutBookingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CheckoutBookingResult:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out")
|
||||
if not booking.requirements_complete:
|
||||
raise HTTPException(status_code=409, detail="Booking requirements are incomplete")
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=500, detail="Booking references a missing vehicle")
|
||||
if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}:
|
||||
raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout")
|
||||
active_conflict = db.scalar(
|
||||
select(Booking.id).where(
|
||||
Booking.vehicle_id == vehicle.id,
|
||||
Booking.status == "active",
|
||||
Booking.id != booking.id,
|
||||
)
|
||||
)
|
||||
if active_conflict is not None:
|
||||
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
|
||||
|
||||
correlation_id = uuid.uuid4()
|
||||
before_booking = {
|
||||
"status": booking.status,
|
||||
"start_odometer_km": booking.start_odometer_km,
|
||||
}
|
||||
before_vehicle = {
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
}
|
||||
attention_reasons: list[str] = []
|
||||
if body.start_odometer_km < vehicle.odometer_km:
|
||||
attention_reasons.append("odometer_regression")
|
||||
if not body.cleanliness_ok:
|
||||
attention_reasons.append("cleanliness")
|
||||
if body.damage_reported:
|
||||
attention_reasons.append("damage")
|
||||
if body.technical_warning:
|
||||
attention_reasons.append("technical_warning")
|
||||
|
||||
inspection = Inspection(
|
||||
public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}",
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="checkout",
|
||||
fuel_level_percent=body.fuel_level_percent,
|
||||
cleanliness_ok=body.cleanliness_ok,
|
||||
damage_reported=body.damage_reported,
|
||||
technical_warning=body.technical_warning,
|
||||
notes=body.notes,
|
||||
odometer_km=body.start_odometer_km,
|
||||
completed_at=datetime.now(UTC),
|
||||
completed_by=user.display_name,
|
||||
)
|
||||
db.add(inspection)
|
||||
if "odometer_regression" in attention_reasons:
|
||||
open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=inspection.public_ref,
|
||||
reading_km=body.start_odometer_km,
|
||||
canonical_km=vehicle.odometer_km,
|
||||
source_type="checkout",
|
||||
related_refs=[booking.public_ref, inspection.public_ref],
|
||||
actor_label=user.display_name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
if attention_reasons:
|
||||
booking.status = "blocked"
|
||||
vehicle.operational_status = (
|
||||
"maintenance" if body.damage_reported or body.technical_warning else "cleaning"
|
||||
)
|
||||
else:
|
||||
booking.status = "active"
|
||||
booking.start_odometer_km = body.start_odometer_km
|
||||
vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km)
|
||||
vehicle.operational_status = "rented"
|
||||
vehicle.version += 1
|
||||
db.flush()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="booking_checkout_recorded",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before_booking,
|
||||
after={
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"booking_status": booking.status,
|
||||
"start_odometer_km": booking.start_odometer_km,
|
||||
"vehicle_status": vehicle.operational_status,
|
||||
"attention_reasons": attention_reasons,
|
||||
},
|
||||
)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="vehicle_status_changed",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before_vehicle,
|
||||
after={
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
},
|
||||
metadata={"booking_ref": booking.public_ref, "inspection_ref": inspection.public_ref},
|
||||
)
|
||||
db.commit()
|
||||
return CheckoutBookingResult(
|
||||
booking_ref=booking.public_ref,
|
||||
vehicle_ref=vehicle.public_ref,
|
||||
inspection_ref=inspection.public_ref,
|
||||
booking_status=booking.status,
|
||||
resulting_vehicle_status=vehicle.operational_status,
|
||||
activated=booking.status == "active",
|
||||
attention_reasons=attention_reasons,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/availability", response_model=list[AvailableVehicleOut])
|
||||
def list_available_vehicles(
|
||||
starts_at: datetime,
|
||||
ends_at: datetime,
|
||||
query: str | None = Query(default=None, max_length=100),
|
||||
limit: int = Query(default=25, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[AvailableVehicleOut]:
|
||||
if ends_at <= starts_at:
|
||||
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||
overlapping_vehicle_ids = select(Booking.vehicle_id).where(
|
||||
Booking.status.in_(("reserved", "active")),
|
||||
Booking.starts_at < ends_at,
|
||||
Booking.ends_at > starts_at,
|
||||
)
|
||||
stmt = (
|
||||
select(Vehicle)
|
||||
.where(
|
||||
Vehicle.active.is_(True),
|
||||
Vehicle.operational_status.not_in(("maintenance", "blocked")),
|
||||
Vehicle.id.not_in(overlapping_vehicle_ids),
|
||||
)
|
||||
.order_by(Vehicle.location, Vehicle.public_ref)
|
||||
.limit(limit)
|
||||
)
|
||||
if query and query.strip():
|
||||
term = f"%{query.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Vehicle.public_ref.ilike(term),
|
||||
Vehicle.make.ilike(term),
|
||||
Vehicle.model.ilike(term),
|
||||
Vehicle.registration_number.ilike(term),
|
||||
Vehicle.location.ilike(term),
|
||||
)
|
||||
)
|
||||
return [
|
||||
AvailableVehicleOut(
|
||||
public_ref=vehicle.public_ref,
|
||||
make=vehicle.make,
|
||||
model=vehicle.model,
|
||||
registration_number=vehicle.registration_number,
|
||||
location=vehicle.location,
|
||||
operational_status=vehicle.operational_status,
|
||||
)
|
||||
for vehicle in db.scalars(stmt).all()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{public_ref}", response_model=BookingOut)
|
||||
def get_booking(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> BookingOut:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref))
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||
if customer is None or vehicle is None:
|
||||
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/complete-requirements", response_model=BookingOut)
|
||||
def complete_booking_requirements(
|
||||
public_ref: str,
|
||||
body: CompleteBookingRequirementsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> BookingOut:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Requirements can only be confirmed for a reserved booking",
|
||||
)
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||
if customer is None or vehicle is None:
|
||||
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||
if not booking.requirements_complete:
|
||||
booking.requirements_complete = True
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="booking_requirements_completed",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
before={"requirements_complete": False},
|
||||
after={"requirements_complete": True},
|
||||
metadata={"confirmation": body.confirmation.strip()},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.patch("/{public_ref}/schedule", response_model=BookingOut)
|
||||
def reschedule_booking(
|
||||
public_ref: str,
|
||||
body: RescheduleBookingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> BookingOut:
|
||||
if body.ends_at <= body.starts_at:
|
||||
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status != "reserved":
|
||||
raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled")
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
if customer is None or vehicle is None:
|
||||
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||
overlap = db.scalar(
|
||||
select(Booking.id).where(
|
||||
Booking.vehicle_id == booking.vehicle_id,
|
||||
Booking.id != booking.id,
|
||||
Booking.status.in_(("reserved", "active")),
|
||||
Booking.starts_at < body.ends_at,
|
||||
Booking.ends_at > body.starts_at,
|
||||
)
|
||||
)
|
||||
if overlap is not None:
|
||||
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
|
||||
before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}
|
||||
booking.starts_at = body.starts_at
|
||||
booking.ends_at = body.ends_at
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="booking_rescheduled",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
before=before,
|
||||
after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()},
|
||||
metadata={"reason": body.reason.strip()},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/cancel", response_model=BookingOut)
|
||||
def cancel_booking(
|
||||
public_ref: str,
|
||||
body: CancelBookingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> BookingOut:
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||
if booking is None:
|
||||
raise HTTPException(status_code=404, detail="Booking not found")
|
||||
if booking.status not in ("reserved", "blocked"):
|
||||
# A booking blocked at checkout (damage, technical warning, ...) has no other exit:
|
||||
# it never became active, so it can neither be returned nor completed. Cancelling
|
||||
# it (audited, with a reason) is the only way to close the file.
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Only a reserved or blocked booking can be cancelled"
|
||||
)
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||
if customer is None or vehicle is None:
|
||||
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||
before = {"status": booking.status}
|
||||
booking.status = "cancelled"
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="booking_cancelled",
|
||||
entity_type="booking",
|
||||
entity_id=booking.id,
|
||||
before=before,
|
||||
after={"status": booking.status, "reason": body.reason.strip()},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(booking, customer, vehicle)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
|
||||
def preview_return(
|
||||
public_ref: str,
|
||||
body: RegisterReturnRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ReturnPreviewResult:
|
||||
booking, vehicle, evaluation = preview_vehicle_return(db, public_ref, body)
|
||||
return ReturnPreviewResult(
|
||||
booking_ref=booking.public_ref,
|
||||
vehicle_ref=vehicle.public_ref,
|
||||
canonical_odometer_km=evaluation.canonical_odometer_km,
|
||||
submitted_odometer_km=evaluation.submitted_odometer_km,
|
||||
odometer_regression=evaluation.odometer_regression,
|
||||
resulting_odometer_km=evaluation.resulting_odometer_km,
|
||||
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
||||
status_reason=evaluation.status_reason,
|
||||
status_reason_code=evaluation.status_reason_code,
|
||||
status_reason_params=evaluation.status_reason_params,
|
||||
would_create_quality_issue=evaluation.would_create_quality_issue,
|
||||
attention_reasons=evaluation.attention_reasons,
|
||||
next_booking_risk=(
|
||||
NextBookingRisk(**evaluation.next_booking_risk)
|
||||
if evaluation.next_booking_risk is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/return", response_model=RegisterReturnResult)
|
||||
def register_return(
|
||||
public_ref: str,
|
||||
body: RegisterReturnRequest,
|
||||
response: Response,
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> RegisterReturnResult:
|
||||
status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user)
|
||||
response.status_code = status_code
|
||||
return RegisterReturnResult(**result)
|
||||
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.customer import Customer
|
||||
from app.schemas import CurrentUser, CustomerOptionOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[CustomerOptionOut])
|
||||
def search_customers(
|
||||
query: str = Query(min_length=2, max_length=100),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[CustomerOptionOut]:
|
||||
term = f"%{query.strip()}%"
|
||||
customers = db.scalars(
|
||||
select(Customer)
|
||||
.where(
|
||||
Customer.merged_into_customer_id.is_(None),
|
||||
Customer.anonymized_at.is_(None),
|
||||
or_(
|
||||
Customer.public_ref.ilike(term),
|
||||
Customer.first_name.ilike(term),
|
||||
Customer.last_name.ilike(term),
|
||||
Customer.email.ilike(term),
|
||||
),
|
||||
)
|
||||
.order_by(Customer.last_name, Customer.first_name)
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [
|
||||
CustomerOptionOut(
|
||||
public_ref=customer.public_ref,
|
||||
display_name=f"{customer.first_name} {customer.last_name}",
|
||||
email=customer.email,
|
||||
)
|
||||
for customer in customers
|
||||
]
|
||||
@@ -0,0 +1,151 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,446 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
ApplyRecommendedStatusRequest,
|
||||
ApplyRecommendedStatusResult,
|
||||
BulkDataQualityWorkRequest,
|
||||
BulkDataQualityWorkResult,
|
||||
CurrentUser,
|
||||
DataQualityIssueDetailOut,
|
||||
DataQualityIssueOut,
|
||||
DataQualityIssuePageOut,
|
||||
MergeCustomersRequest,
|
||||
MergeCustomersResult,
|
||||
ProvideFieldsRequest,
|
||||
ResolveOdometerRegressionRequest,
|
||||
ResolveOverlapRequest,
|
||||
ScanResultOut,
|
||||
StatusRecommendationOut,
|
||||
VehicleStatusFactsOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import (
|
||||
apply_recommended_status,
|
||||
defer_issue,
|
||||
merge_customers,
|
||||
preview_vehicle_status_recommendation,
|
||||
provide_missing_fields,
|
||||
reject_issue,
|
||||
resolve_booking_overlap,
|
||||
resolve_odometer_regression,
|
||||
run_scan,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
|
||||
|
||||
|
||||
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||
assignee = issue.assigned_to_user
|
||||
return DataQualityIssueOut(
|
||||
public_ref=issue.public_ref,
|
||||
rule_type=issue.rule_type,
|
||||
entity_type=issue.entity_type,
|
||||
entity_ref=issue.evidence_json.get("entity_ref", ""),
|
||||
severity=issue.severity,
|
||||
status=issue.status,
|
||||
evidence=issue.evidence_json,
|
||||
detected_at=issue.detected_at,
|
||||
due_at=issue.due_at,
|
||||
assigned_to_ref=assignee.public_ref if assignee else None,
|
||||
assigned_to_name=assignee.display_name if assignee else None,
|
||||
overdue=(
|
||||
issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC)
|
||||
),
|
||||
resolved_at=issue.resolved_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
|
||||
def list_issues(
|
||||
status: str | None = Query(default=None),
|
||||
rule_type: str | None = Query(default=None),
|
||||
severity: str | None = Query(default=None),
|
||||
assigned_to_ref: str | None = Query(default=None),
|
||||
overdue: bool | None = Query(default=None),
|
||||
demo_only: bool | None = Query(default=None),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
|
||||
severity_order = case(
|
||||
(DataQualityIssue.severity == "high", 0),
|
||||
(DataQualityIssue.severity == "medium", 1),
|
||||
else_=2,
|
||||
)
|
||||
stmt = select(DataQualityIssue).order_by(
|
||||
DataQualityIssue.due_at.asc().nulls_last(),
|
||||
severity_order,
|
||||
DataQualityIssue.detected_at.desc(),
|
||||
)
|
||||
if status:
|
||||
stmt = stmt.where(DataQualityIssue.status == status)
|
||||
if rule_type:
|
||||
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
||||
if severity:
|
||||
stmt = stmt.where(DataQualityIssue.severity == severity)
|
||||
if assigned_to_ref == "unassigned":
|
||||
stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
|
||||
elif assigned_to_ref:
|
||||
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
|
||||
User.public_ref == assigned_to_ref
|
||||
)
|
||||
if overdue is True:
|
||||
stmt = stmt.where(
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.due_at < datetime.now(UTC),
|
||||
)
|
||||
if demo_only is True:
|
||||
# Server-side so the guided demo scenarios are found on any page, not only the
|
||||
# 25 rows currently loaded in the browser.
|
||||
stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%"))
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
issues = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
items = [_to_out(i) for i in issues]
|
||||
if page is None:
|
||||
return items
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
return DataQualityIssuePageOut(
|
||||
items=items,
|
||||
page=min(page_number, total_pages),
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
|
||||
def update_issue_work_queue(
|
||||
body: BulkDataQualityWorkRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> BulkDataQualityWorkResult:
|
||||
refs = list(dict.fromkeys(body.issue_refs))
|
||||
if (
|
||||
body.assigned_to_ref is None
|
||||
and not body.clear_assignment
|
||||
and body.due_at is None
|
||||
and not body.clear_due_at
|
||||
):
|
||||
raise HTTPException(status_code=422, detail="No work queue change was requested")
|
||||
if body.assigned_to_ref is not None and body.clear_assignment:
|
||||
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
|
||||
if body.due_at is not None and body.clear_due_at:
|
||||
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
|
||||
if body.due_at is not None and body.due_at.tzinfo is None:
|
||||
raise HTTPException(status_code=422, detail="Due date must include a timezone")
|
||||
|
||||
assignee = None
|
||||
if body.assigned_to_ref is not None:
|
||||
assignee = db.scalar(
|
||||
select(User).where(
|
||||
User.public_ref == body.assigned_to_ref,
|
||||
User.active.is_(True),
|
||||
)
|
||||
)
|
||||
if assignee is None:
|
||||
raise HTTPException(status_code=422, detail="Active assignee not found")
|
||||
|
||||
issues = list(
|
||||
db.scalars(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
|
||||
).all()
|
||||
)
|
||||
if len(issues) != len(refs):
|
||||
found = {issue.public_ref for issue in issues}
|
||||
missing = next(ref for ref in refs if ref not in found)
|
||||
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
|
||||
|
||||
for issue in issues:
|
||||
if issue.status != "open":
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Data quality issue {issue.public_ref} is not open",
|
||||
)
|
||||
before = {
|
||||
"assigned_to_ref": issue.assigned_to_user.public_ref
|
||||
if issue.assigned_to_user
|
||||
else None,
|
||||
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||
}
|
||||
if body.assigned_to_ref is not None:
|
||||
issue.assigned_to_user = assignee
|
||||
elif body.clear_assignment:
|
||||
issue.assigned_to_user = None
|
||||
if body.due_at is not None:
|
||||
issue.due_at = body.due_at
|
||||
elif body.clear_due_at:
|
||||
issue.due_at = None
|
||||
after = {
|
||||
"assigned_to_ref": assignee.public_ref
|
||||
if body.assigned_to_ref is not None and assignee
|
||||
else (None if body.clear_assignment else before["assigned_to_ref"]),
|
||||
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||
}
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="data_quality_work_updated",
|
||||
entity_type="data_quality_issue",
|
||||
entity_id=issue.id,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
db.commit()
|
||||
for issue in issues:
|
||||
db.refresh(issue)
|
||||
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
|
||||
|
||||
|
||||
# Every public reference in this system carries its entity type in its own prefix
|
||||
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
|
||||
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
|
||||
# bookings, not vehicles, and an inline odometer_regression issue's related refs mix a
|
||||
# booking and an inspection ref in the same list.
|
||||
_PREFIX_TO_TYPE = {
|
||||
"CUS-": "customer",
|
||||
"MO-": "vehicle",
|
||||
"BK-": "booking",
|
||||
"INSP-": "inspection",
|
||||
"MAINT-": "maintenance",
|
||||
"MNT-": "maintenance",
|
||||
}
|
||||
|
||||
|
||||
def _entity_type_for_ref(ref: str) -> str | None:
|
||||
for prefix, entity_type in _PREFIX_TO_TYPE.items():
|
||||
if ref.startswith(prefix):
|
||||
return entity_type
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||
if entity_type == "customer":
|
||||
customer = db.scalar(select(Customer).where(Customer.public_ref == ref))
|
||||
if customer is None:
|
||||
return None
|
||||
return {
|
||||
"entity_type": "customer",
|
||||
"public_ref": customer.public_ref,
|
||||
"first_name": customer.first_name,
|
||||
"last_name": customer.last_name,
|
||||
"email": customer.email,
|
||||
"phone": customer.phone,
|
||||
"postal_code": customer.postal_code,
|
||||
"city": customer.city,
|
||||
}
|
||||
if entity_type == "vehicle":
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref))
|
||||
if vehicle is None:
|
||||
return None
|
||||
return {
|
||||
"entity_type": "vehicle",
|
||||
"public_ref": vehicle.public_ref,
|
||||
"registration_number": vehicle.registration_number,
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
"location": vehicle.location,
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
}
|
||||
if entity_type == "booking":
|
||||
booking = db.scalar(select(Booking).where(Booking.public_ref == ref))
|
||||
if booking is None:
|
||||
return None
|
||||
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||
customer = db.get(Customer, booking.customer_id)
|
||||
return {
|
||||
"entity_type": "booking",
|
||||
"public_ref": booking.public_ref,
|
||||
"status": booking.status,
|
||||
"starts_at": booking.starts_at.isoformat(),
|
||||
"ends_at": booking.ends_at.isoformat(),
|
||||
"vehicle_ref": vehicle.public_ref if vehicle else None,
|
||||
"customer_ref": customer.public_ref if customer else None,
|
||||
"end_odometer_km": booking.end_odometer_km,
|
||||
}
|
||||
if entity_type == "inspection":
|
||||
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == ref))
|
||||
if inspection is None:
|
||||
return None
|
||||
booking = db.get(Booking, inspection.booking_id)
|
||||
return {
|
||||
"entity_type": "inspection",
|
||||
"public_ref": inspection.public_ref,
|
||||
"type": inspection.type,
|
||||
"odometer_km": inspection.odometer_km,
|
||||
"completed_at": inspection.completed_at.isoformat(),
|
||||
"booking_ref": booking.public_ref if booking else None,
|
||||
}
|
||||
if entity_type == "maintenance":
|
||||
record = db.scalar(select(MaintenanceRecord).where(MaintenanceRecord.public_ref == ref))
|
||||
if record is None:
|
||||
return None
|
||||
return {
|
||||
"entity_type": "maintenance",
|
||||
"public_ref": record.public_ref,
|
||||
"odometer_km": record.odometer_km,
|
||||
"occurred_at": record.occurred_at.isoformat(),
|
||||
"category": record.category,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut)
|
||||
def get_issue(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueDetailOut:
|
||||
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
||||
if issue is None:
|
||||
raise HTTPException(status_code=404, detail="Data quality issue not found")
|
||||
base = _to_out(issue)
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
related_snapshots = []
|
||||
for ref in related_refs:
|
||||
entity_type = _entity_type_for_ref(ref)
|
||||
if entity_type is None:
|
||||
continue
|
||||
snap = _snapshot(entity_type, ref, db)
|
||||
if snap is not None:
|
||||
related_snapshots.append(snap)
|
||||
return DataQualityIssueDetailOut(
|
||||
**base.model_dump(),
|
||||
entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db),
|
||||
related_snapshots=related_snapshots,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/defer", response_model=DataQualityIssueOut)
|
||||
def defer(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = defer_issue(db, public_ref, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/reject", response_model=DataQualityIssueOut)
|
||||
def reject(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = reject_issue(db, public_ref, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/merge-customers", response_model=MergeCustomersResult)
|
||||
def merge(
|
||||
public_ref: str,
|
||||
body: MergeCustomersRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> MergeCustomersResult:
|
||||
result = merge_customers(db, public_ref, body.survivor_ref, body.field_overrides, user)
|
||||
return MergeCustomersResult(**result)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/provide-fields", response_model=DataQualityIssueOut)
|
||||
def provide_fields(
|
||||
public_ref: str,
|
||||
body: ProvideFieldsRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = provide_missing_fields(db, public_ref, body.fields, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
|
||||
def resolve_odometer(
|
||||
public_ref: str,
|
||||
body: ResolveOdometerRegressionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = resolve_odometer_regression(db, public_ref, body, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/resolve-overlap", response_model=DataQualityIssueOut)
|
||||
def resolve_overlap(
|
||||
public_ref: str,
|
||||
body: ResolveOverlapRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> DataQualityIssueOut:
|
||||
issue = resolve_booking_overlap(db, public_ref, body.booking_ref, body.note, user)
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
|
||||
def status_recommendation(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> StatusRecommendationOut:
|
||||
"""Non-mutating preview: computes the recommendation without changing anything,
|
||||
resolving no issue and writing no audit event. Safe to call repeatedly."""
|
||||
_issue, _vehicle, recommendation, token = preview_vehicle_status_recommendation(db, public_ref)
|
||||
return StatusRecommendationOut(
|
||||
current_status=recommendation.current_status,
|
||||
recommended_status=recommendation.recommended_status,
|
||||
recommendation_code=recommendation.recommendation_code,
|
||||
safe_to_apply=recommendation.safe_to_apply,
|
||||
manual_review_required=recommendation.manual_review_required,
|
||||
facts=VehicleStatusFactsOut(**recommendation.facts.as_dict()),
|
||||
blocking_reasons=recommendation.blocking_reasons,
|
||||
recommendation_token=token,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
||||
)
|
||||
def apply_status(
|
||||
public_ref: str,
|
||||
body: ApplyRecommendedStatusRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> ApplyRecommendedStatusResult:
|
||||
issue, applied_status, reason_code = apply_recommended_status(
|
||||
db, public_ref, user, body.recommendation_token
|
||||
)
|
||||
return ApplyRecommendedStatusResult(
|
||||
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
|
||||
)
|
||||
|
||||
|
||||
@router.post("/scan", response_model=ScanResultOut)
|
||||
def scan(
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> ScanResultOut:
|
||||
result = run_scan(db, actor_label=user.display_name, actor_type="user")
|
||||
return ScanResultOut(created=result.created)
|
||||
@@ -0,0 +1,186 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset, engine
|
||||
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.user import User
|
||||
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||
from app.seed_loader import reset_and_seed
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
||||
from app.services.sessions import revoke_session
|
||||
|
||||
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||
settings = get_settings()
|
||||
_reset_guard = threading.Lock()
|
||||
_RESET_ADVISORY_LOCK_ID = 706_533_149
|
||||
|
||||
|
||||
@router.get("/manifest", response_model=DemoManifestOut)
|
||||
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||
if not settings.mobilityops_demo_mode:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
|
||||
# both need this before any session exists. Nothing here is sensitive — it's the same
|
||||
# honest "what is this demo" summary a logged-in user would see.
|
||||
return build_demo_manifest(db)
|
||||
|
||||
|
||||
@router.post("/login", response_model=CurrentUser)
|
||||
def demo_login(
|
||||
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
||||
) -> CurrentUser:
|
||||
if not settings.mobilityops_demo_mode:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||
public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP"
|
||||
user = db.scalar(select(User).where(User.public_ref == public_ref))
|
||||
if user is None:
|
||||
raise LookupError("Demo users are missing; run the seed loader first.")
|
||||
|
||||
token = create_session_token(
|
||||
SessionPayload(
|
||||
user_id=str(user.id),
|
||||
public_ref=user.public_ref,
|
||||
role=user.role,
|
||||
display_name=user.display_name,
|
||||
issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=settings.session_cookie_secure,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=user.id,
|
||||
actor_label=user.display_name,
|
||||
action="demo_login",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
)
|
||||
db.commit()
|
||||
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
|
||||
|
||||
|
||||
@router.get("/session", response_model=CurrentUser)
|
||||
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||
# Never let the browser (or an intermediary) cache an authentication check — a stale
|
||||
# cached 200 here would keep showing a logged-out browser as authenticated.
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
payload = read_session_token(token) if token else None
|
||||
if payload is not None and token is not None:
|
||||
revoke_session(db, token, payload)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_id=uuid.UUID(payload.user_id),
|
||||
actor_label=payload.display_name,
|
||||
action="demo_logout",
|
||||
entity_type="user",
|
||||
)
|
||||
db.commit()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {"status": "logged_out"}
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
def demo_reset(
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> dict:
|
||||
if not settings.mobilityops_demo_mode:
|
||||
# Outside demo mode the reset endpoint must not exist at all: it wipes
|
||||
# operational data and replaces it with synthetic records.
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||
if not settings.demo_allow_reset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Demo reset is disabled on this deployment.",
|
||||
)
|
||||
if not _reset_guard.acquire(blocking=False):
|
||||
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||
replica_lock_connection = None
|
||||
try:
|
||||
# A session-level lock on its own connection rejects another replica immediately;
|
||||
# the main DB session can then safely end its auth read transaction and wait on
|
||||
# the normal shared/exclusive data barrier without releasing this replica guard.
|
||||
replica_lock_connection = engine.connect()
|
||||
locked = replica_lock_connection.scalar(
|
||||
select(func.pg_try_advisory_lock(_RESET_ADVISORY_LOCK_ID))
|
||||
)
|
||||
if not locked:
|
||||
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||
begin_exclusive_demo_reset(db)
|
||||
# The audit timestamp is shared by every replica. A process-local monotonic
|
||||
# timestamp cannot protect a multi-replica deployment.
|
||||
last_reset_at = db.scalar(
|
||||
select(AuditEvent.occurred_at)
|
||||
.where(AuditEvent.action == "demo_reset")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
elapsed = (datetime.now(UTC) - last_reset_at).total_seconds() if last_reset_at else None
|
||||
if elapsed is not None and elapsed < settings.demo_reset_cooldown_seconds:
|
||||
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
result = reset_and_seed(db, preserve_integration_telemetry=True, commit=False)
|
||||
integrity = scenario_integrity_report(db)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="demo_reset",
|
||||
entity_type="system",
|
||||
metadata={
|
||||
"counts": result.counts,
|
||||
"anchor_date": result.anchor_date.isoformat(),
|
||||
"scenario_integrity": integrity,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
end_exclusive_demo_reset(db)
|
||||
finally:
|
||||
if replica_lock_connection is not None:
|
||||
try:
|
||||
replica_lock_connection.scalar(
|
||||
select(func.pg_advisory_unlock(_RESET_ADVISORY_LOCK_ID))
|
||||
)
|
||||
finally:
|
||||
replica_lock_connection.close()
|
||||
_reset_guard.release()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {
|
||||
"status": "reset",
|
||||
"counts": result.counts,
|
||||
"anchor_date": result.anchor_date.isoformat(),
|
||||
"seeded_at": result.seeded_at.isoformat(),
|
||||
"scenario_integrity": integrity,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.schemas import CurrentUser, IntegrationStatusOut
|
||||
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||
|
||||
|
||||
@router.get("/status", response_model=IntegrationStatusOut)
|
||||
def integration_status(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> IntegrationStatusOut:
|
||||
return IntegrationStatusOut(
|
||||
n8n=derive_n8n_status(db),
|
||||
mcp_hub=derive_mcp_hub_status(db),
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import (
|
||||
N8nHeartbeatIn,
|
||||
N8nHeartbeatResult,
|
||||
ProcedureDocumentOut,
|
||||
ProcedureListOut,
|
||||
ProcedureSyncResultIn,
|
||||
ProcedureSyncResultResult,
|
||||
ReturnCallbackIn,
|
||||
ScanResultOut,
|
||||
WorkflowErrorReportIn,
|
||||
WorkflowErrorReportResult,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import run_scan
|
||||
from app.services.knowledge.procedures import iter_procedure_documents
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
||||
settings = get_settings()
|
||||
|
||||
_CANONICAL_WORKFLOW_NAMES = frozenset(
|
||||
{
|
||||
"Fleet Ops — Vehicle Return Orchestration",
|
||||
"Fleet Ops — Scheduled Data Quality Scan",
|
||||
"Fleet Ops — RAGcore Procedure Sync",
|
||||
"Fleet Ops — Workflow Error Handler",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _require_service_token(service_token: str) -> None:
|
||||
# Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched.
|
||||
if not hmac.compare_digest(
|
||||
service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8")
|
||||
):
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
|
||||
|
||||
def _lock_idempotency_key(db: Session, namespace: str, key: str) -> None:
|
||||
"""Serialize callback check+insert by a stable, transaction-scoped key."""
|
||||
digest = hashlib.sha256(f"{namespace}:{key}".encode()).digest()
|
||||
lock_id = int.from_bytes(digest[:8], byteorder="big", signed=True)
|
||||
db.scalar(select(func.pg_advisory_xact_lock(lock_id)))
|
||||
|
||||
|
||||
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
|
||||
def workflow_heartbeat(
|
||||
body: N8nHeartbeatIn,
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> N8nHeartbeatResult:
|
||||
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
|
||||
_require_service_token(service_token)
|
||||
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
|
||||
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
|
||||
_lock_idempotency_key(db, "n8n_workflow_heartbeat", f"{body.execution_id}:{body.status}")
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
select(AuditEvent.id).where(
|
||||
AuditEvent.action == "n8n_workflow_heartbeat",
|
||||
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||
AuditEvent.after_json["status"].astext == body.status,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
if not already_recorded:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="service",
|
||||
actor_label="n8n workflow heartbeat",
|
||||
action="n8n_workflow_heartbeat",
|
||||
entity_type="automation",
|
||||
after={
|
||||
"workflow_id": body.workflow_id,
|
||||
"workflow_name": body.workflow_name,
|
||||
"status": body.status,
|
||||
},
|
||||
metadata={"execution_id": body.execution_id},
|
||||
)
|
||||
db.commit()
|
||||
return N8nHeartbeatResult(
|
||||
status="already_registered" if already_recorded else "registered",
|
||||
execution_id=body.execution_id,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/return-callback")
|
||||
def return_callback(
|
||||
body: ReturnCallbackIn,
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
_require_service_token(service_token)
|
||||
|
||||
try:
|
||||
event_id = uuid.UUID(idempotency_key)
|
||||
except ValueError as exc:
|
||||
raise AppError(
|
||||
"INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422
|
||||
) from exc
|
||||
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update())
|
||||
if event is None:
|
||||
raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404)
|
||||
if body.event_id != event_id:
|
||||
raise AppError(
|
||||
"CALLBACK_EVENT_MISMATCH",
|
||||
"Callback event_id does not match Idempotency-Key.",
|
||||
status_code=409,
|
||||
)
|
||||
try:
|
||||
expected_correlation_id = uuid.UUID(str(event.payload_json["correlation_id"]))
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise AppError(
|
||||
"INVALID_EVENT_CORRELATION",
|
||||
"The stored outbox event has no valid correlation ID.",
|
||||
status_code=409,
|
||||
) from exc
|
||||
if body.correlation_id != expected_correlation_id:
|
||||
raise AppError(
|
||||
"CALLBACK_CORRELATION_MISMATCH",
|
||||
"Callback correlation_id does not match the outbox event.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# Idempotent by event ID: n8n or our own dispatcher may redeliver the same event
|
||||
# (e.g. a lost response after a timeout), so this callback must not double-record.
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
select(AuditEvent.id).where(
|
||||
AuditEvent.action == "n8n_return_followup_recorded",
|
||||
AuditEvent.metadata_json["event_id"].astext == str(event_id),
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
if not already_recorded:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="service",
|
||||
actor_label="n8n",
|
||||
action="n8n_return_followup_recorded",
|
||||
entity_type="booking",
|
||||
correlation_id=expected_correlation_id,
|
||||
after={"follow_up": body.follow_up, "summary": body.summary},
|
||||
metadata={"event_id": str(event_id)},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "recorded",
|
||||
"event_id": str(event_id),
|
||||
"occurred_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/scheduled-scan", response_model=ScanResultOut)
|
||||
def scheduled_scan(
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ScanResultOut:
|
||||
"""Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and
|
||||
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
|
||||
doesn't already have one open, so a duplicate or overlapping trigger does no
|
||||
duplicate domain work -- it just reports zero new issues for anything already known."""
|
||||
_require_service_token(service_token)
|
||||
|
||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||
return ScanResultOut(created=result.created)
|
||||
|
||||
|
||||
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
|
||||
def workflow_error(
|
||||
body: WorkflowErrorReportIn,
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> WorkflowErrorReportResult:
|
||||
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
|
||||
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
||||
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||
_require_service_token(service_token)
|
||||
_lock_idempotency_key(db, "n8n_workflow_failure", body.execution_id)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
select(AuditEvent.id).where(
|
||||
AuditEvent.action == "n8n_workflow_failure_registered",
|
||||
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
if not already_recorded:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="service",
|
||||
actor_label="n8n error handler",
|
||||
action="n8n_workflow_failure_registered",
|
||||
entity_type="automation",
|
||||
correlation_id=body.correlation_id,
|
||||
after={
|
||||
"workflow_id": body.workflow_id,
|
||||
"workflow_name": body.workflow_name,
|
||||
"error_category": body.error_category,
|
||||
"error_summary": body.error_summary,
|
||||
"trigger_context": body.trigger_context,
|
||||
"attempt": body.attempt,
|
||||
"retry_action": body.retry_action,
|
||||
"failed_at": body.failed_at.isoformat(),
|
||||
},
|
||||
metadata={"execution_id": body.execution_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return WorkflowErrorReportResult(
|
||||
status="already_registered" if already_recorded else "registered",
|
||||
execution_id=body.execution_id,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/procedures", response_model=ProcedureListOut)
|
||||
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
|
||||
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
|
||||
Markdown file Fleet Ops ships, across every supported language, with a stable
|
||||
per-document id (source_id) and a content hash so the caller can detect changes
|
||||
without re-fetching content it already has."""
|
||||
_require_service_token(service_token)
|
||||
|
||||
documents = [
|
||||
ProcedureDocumentOut(
|
||||
id=doc.source_id,
|
||||
language=doc.language,
|
||||
document_id=doc.document_id,
|
||||
title=doc.title,
|
||||
version=doc.version,
|
||||
content=doc.content,
|
||||
content_hash=doc.content_hash,
|
||||
)
|
||||
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
|
||||
]
|
||||
return ProcedureListOut(documents=documents)
|
||||
|
||||
|
||||
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
|
||||
def procedures_sync_result(
|
||||
body: ProcedureSyncResultIn,
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ProcedureSyncResultResult:
|
||||
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
|
||||
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
||||
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
||||
_require_service_token(service_token)
|
||||
_lock_idempotency_key(db, "n8n_procedure_sync", body.execution_id)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
select(AuditEvent.id).where(
|
||||
AuditEvent.action == "n8n_procedures_synced",
|
||||
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
if not already_recorded:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="service",
|
||||
actor_label="n8n procedure sync",
|
||||
action="n8n_procedures_synced",
|
||||
entity_type="automation",
|
||||
after={"synced": body.synced, "failed": body.failed},
|
||||
metadata={"execution_id": body.execution_id},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return ProcedureSyncResultResult(
|
||||
status="already_registered" if already_recorded else "registered",
|
||||
execution_id=body.execution_id,
|
||||
occurred_at=datetime.now(UTC),
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
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.core.ratelimit import SlidingWindowLimiter
|
||||
from app.models.audit import AuditEvent
|
||||
from app.schemas import CurrentUser
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
|
||||
|
||||
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||
settings = get_settings()
|
||||
_question_limiter = (
|
||||
SlidingWindowLimiter(
|
||||
max_requests=settings.knowledge_max_requests,
|
||||
window_seconds=settings.knowledge_rate_limit_window_seconds,
|
||||
)
|
||||
if settings.knowledge_max_requests > 0
|
||||
else None
|
||||
)
|
||||
|
||||
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
|
||||
|
||||
|
||||
class AskQuestionRequest(BaseModel):
|
||||
question: str = Field(min_length=3, max_length=1000)
|
||||
language: SupportedLanguage = "en-GB"
|
||||
|
||||
|
||||
class KnowledgeFeedbackRequest(BaseModel):
|
||||
correlation_id: uuid.UUID
|
||||
helpful: bool
|
||||
|
||||
|
||||
@router.post("/questions", response_model=GroundedAnswer)
|
||||
def ask_question(
|
||||
body: AskQuestionRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> GroundedAnswer:
|
||||
if _question_limiter is not None:
|
||||
forwarded = request.headers.get("x-forwarded-for", "")
|
||||
client_ip = (
|
||||
forwarded.split(",")[-1].strip()
|
||||
if forwarded
|
||||
else request.client.host
|
||||
if request.client
|
||||
else "unknown"
|
||||
)
|
||||
token = request.cookies.get(settings.session_cookie_name, "")
|
||||
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
retry_after = max(
|
||||
_question_limiter.consume(f"ip:{client_ip}"),
|
||||
_question_limiter.consume(f"session:{session_key}"),
|
||||
)
|
||||
if retry_after:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Too many knowledge questions. Try again later.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
correlation_id = str(uuid.uuid4())
|
||||
provider = get_knowledge_provider()
|
||||
answer = provider.ask(body.question, correlation_id, body.language)
|
||||
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="knowledge_question_asked",
|
||||
entity_type="knowledge",
|
||||
correlation_id=uuid.UUID(correlation_id),
|
||||
metadata={
|
||||
"evidence_state": answer.evidence_state,
|
||||
"provider": answer.provider,
|
||||
"source_ids": [s.document_id for s in answer.sources],
|
||||
"question_length": len(body.question),
|
||||
"language": body.language,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return answer
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
def record_feedback(
|
||||
body: KnowledgeFeedbackRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict[str, str]:
|
||||
question_event = db.scalar(
|
||||
select(AuditEvent.id).where(
|
||||
AuditEvent.action == "knowledge_question_asked",
|
||||
AuditEvent.correlation_id == body.correlation_id,
|
||||
AuditEvent.actor_label == user.display_name,
|
||||
)
|
||||
)
|
||||
if question_event is None:
|
||||
raise HTTPException(status_code=404, detail="Knowledge exchange not found")
|
||||
|
||||
existing = db.scalar(
|
||||
select(AuditEvent).where(
|
||||
AuditEvent.action == "knowledge_feedback_recorded",
|
||||
AuditEvent.correlation_id == body.correlation_id,
|
||||
AuditEvent.actor_label == user.display_name,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
existing.metadata_json = {"helpful": body.helpful}
|
||||
else:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="knowledge_feedback_recorded",
|
||||
entity_type="knowledge",
|
||||
correlation_id=body.correlation_id,
|
||||
metadata={"helpful": body.helpful},
|
||||
)
|
||||
db.commit()
|
||||
return {"status": "recorded"}
|
||||
|
||||
|
||||
@router.get("/status", response_model=KnowledgeHealth)
|
||||
def knowledge_status(
|
||||
language: SupportedLanguage = "en-GB",
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> KnowledgeHealth:
|
||||
health = get_knowledge_provider().health(language)
|
||||
if health.provider != "ragcore":
|
||||
return health
|
||||
|
||||
latest_sync = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "n8n_procedures_synced")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if latest_sync is None:
|
||||
return health
|
||||
|
||||
reported = latest_sync.after_json or {}
|
||||
synced = reported.get("synced")
|
||||
failed = reported.get("failed")
|
||||
return health.model_copy(
|
||||
update={
|
||||
"reported_synced_document_count": synced if isinstance(synced, int) else None,
|
||||
"reported_failed_document_count": failed if isinstance(failed, int) else None,
|
||||
"last_sync_at": latest_sync.occurred_at,
|
||||
# A persisted sync callback is useful additional provenance, but must not
|
||||
# downgrade stronger provider-side verification to merely "reported".
|
||||
"statistics_state": (
|
||||
health.statistics_state
|
||||
if health.statistics_state == "verified"
|
||||
else "sync_reported"
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import McpClientContext, get_db, require_mcp_service_token
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import AppError
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
AttentionVehicleOut,
|
||||
McpKnowledgeSearchRequest,
|
||||
McpVehicleDetailOut,
|
||||
OperationsSummaryOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.knowledge import GroundedAnswer, get_knowledge_provider
|
||||
from app.services.operations import compute_metrics, list_attention_vehicles
|
||||
|
||||
router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def get_correlation_id(
|
||||
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
|
||||
) -> str:
|
||||
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
|
||||
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
|
||||
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
|
||||
if x_correlation_id:
|
||||
try:
|
||||
return str(uuid.UUID(x_correlation_id))
|
||||
except ValueError:
|
||||
pass
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _audit_service_request(
|
||||
db: Session,
|
||||
*,
|
||||
reported_client_id: str,
|
||||
tool: str,
|
||||
status_label: str,
|
||||
correlation_id: str,
|
||||
metadata: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="service",
|
||||
# The shared service token authenticates the Hub, not the caller identity that
|
||||
# the Hub reports in a header. Keep attribution authoritative and retain the
|
||||
# reported value only as explicitly non-authenticated diagnostic metadata.
|
||||
actor_label="itworx-mcp-hub",
|
||||
action="mcp_tool_request",
|
||||
entity_type="mcp_tool",
|
||||
correlation_id=uuid.UUID(correlation_id),
|
||||
metadata={
|
||||
"tool": tool,
|
||||
"status": status_label,
|
||||
"reported_client_id": reported_client_id,
|
||||
**(metadata or {}),
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _set_trace_headers(response: Response, correlation_id: str, tenant: str) -> None:
|
||||
response.headers["X-Correlation-Id"] = correlation_id
|
||||
response.headers["X-Tenant-Id"] = tenant
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
|
||||
|
||||
@router.get("/operations-summary", response_model=OperationsSummaryOut)
|
||||
def operations_summary(
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
client: McpClientContext = Depends(require_mcp_service_token),
|
||||
correlation_id: str = Depends(get_correlation_id),
|
||||
) -> OperationsSummaryOut:
|
||||
metrics = compute_metrics(db)
|
||||
_set_trace_headers(response, correlation_id, client.tenant)
|
||||
_audit_service_request(
|
||||
db,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_get_operations_summary",
|
||||
status_label="ok",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return OperationsSummaryOut(tenant=client.tenant, metrics=metrics)
|
||||
|
||||
|
||||
@router.get("/attention-vehicles", response_model=list[AttentionVehicleOut])
|
||||
def attention_vehicles(
|
||||
response: Response,
|
||||
minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"),
|
||||
date_filter: date | None = Query(default=None, alias="date"),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
client: McpClientContext = Depends(require_mcp_service_token),
|
||||
correlation_id: str = Depends(get_correlation_id),
|
||||
) -> list[AttentionVehicleOut]:
|
||||
results = list_attention_vehicles(
|
||||
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
|
||||
)
|
||||
_set_trace_headers(response, correlation_id, client.tenant)
|
||||
_audit_service_request(
|
||||
db,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_list_attention_vehicles",
|
||||
status_label="ok",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return [AttentionVehicleOut(**r) for r in results]
|
||||
|
||||
|
||||
@router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut)
|
||||
def vehicle_details(
|
||||
vehicle_ref: str,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
client: McpClientContext = Depends(require_mcp_service_token),
|
||||
correlation_id: str = Depends(get_correlation_id),
|
||||
) -> McpVehicleDetailOut:
|
||||
_set_trace_headers(response, correlation_id, client.tenant)
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||
if vehicle is None:
|
||||
_audit_service_request(
|
||||
db,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_get_vehicle_details",
|
||||
status_label="not_found",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
||||
|
||||
open_issue_count = len(
|
||||
db.scalars(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
).all()
|
||||
)
|
||||
current_booking = db.scalar(
|
||||
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
||||
)
|
||||
|
||||
_audit_service_request(
|
||||
db,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_get_vehicle_details",
|
||||
status_label="ok",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return McpVehicleDetailOut(
|
||||
public_ref=vehicle.public_ref,
|
||||
make=vehicle.make,
|
||||
model=vehicle.model,
|
||||
model_year=vehicle.model_year,
|
||||
location=vehicle.location,
|
||||
operational_status=vehicle.operational_status,
|
||||
odometer_km=vehicle.odometer_km,
|
||||
next_service_km=vehicle.next_service_km,
|
||||
open_quality_issue_count=open_issue_count,
|
||||
current_booking_ref=current_booking.public_ref if current_booking else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/search-knowledge", response_model=GroundedAnswer)
|
||||
def search_knowledge(
|
||||
body: McpKnowledgeSearchRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
client: McpClientContext = Depends(require_mcp_service_token),
|
||||
correlation_id: str = Depends(get_correlation_id),
|
||||
) -> GroundedAnswer:
|
||||
provider = get_knowledge_provider()
|
||||
answer = provider.ask(body.question, correlation_id, language=body.locale)
|
||||
source_count_available = len(answer.sources)
|
||||
answer.sources = answer.sources[: body.max_sources]
|
||||
_set_trace_headers(response, correlation_id, client.tenant)
|
||||
response.headers["X-Sources-Available"] = str(source_count_available)
|
||||
response.headers["X-Sources-Returned"] = str(len(answer.sources))
|
||||
_audit_service_request(
|
||||
db,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_search_knowledge",
|
||||
status_label=answer.evidence_state,
|
||||
correlation_id=correlation_id,
|
||||
metadata={
|
||||
"tenant": client.tenant,
|
||||
"locale": body.locale,
|
||||
"sources_available": source_count_available,
|
||||
"sources_returned": len(answer.sources),
|
||||
},
|
||||
)
|
||||
return answer
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.observability import OUTBOX_EVENTS
|
||||
from app.models.outbox import DELIVERY_STATUSES, DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||
|
||||
router = APIRouter(tags=["observability"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _refresh_database_metrics() -> None:
|
||||
with SessionLocal() as db:
|
||||
rows = db.execute(
|
||||
select(
|
||||
OutboxEvent.delivery_status,
|
||||
(OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE).label("demo"),
|
||||
func.count(),
|
||||
).group_by(OutboxEvent.delivery_status, "demo")
|
||||
).all()
|
||||
OUTBOX_EVENTS.clear()
|
||||
# Keep every time series present even when a state currently contains no rows.
|
||||
# Stable zero-valued series make dashboards and alerts deterministic after resets,
|
||||
# restores and fresh installations instead of turning "zero" into "no data".
|
||||
for scenario in ("synthetic", "operational"):
|
||||
for status in DELIVERY_STATUSES:
|
||||
OUTBOX_EVENTS.labels(scenario, status).set(0)
|
||||
for status, is_demo, count in rows:
|
||||
OUTBOX_EVENTS.labels("synthetic" if is_demo else "operational", str(status)).set(count)
|
||||
|
||||
|
||||
@router.get("/metrics", include_in_schema=False)
|
||||
def metrics(authorization: str | None = Header(default=None)) -> Response:
|
||||
if settings.metrics_bearer_token:
|
||||
supplied = authorization.removeprefix("Bearer ") if authorization else ""
|
||||
if not hmac.compare_digest(supplied, settings.metrics_bearer_token):
|
||||
raise HTTPException(status_code=401, detail="Metrics token required")
|
||||
_refresh_database_metrics()
|
||||
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||
@@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.config import get_settings
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.schemas import (
|
||||
CurrentUser,
|
||||
CustomerAnonymizeRequest,
|
||||
CustomerAnonymizeResult,
|
||||
PrivacyRetentionOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _retention_cutoff() -> datetime:
|
||||
return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days)
|
||||
|
||||
|
||||
def _customer_is_eligible(db: Session, customer_id) -> bool:
|
||||
blocking = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(Booking)
|
||||
.where(
|
||||
Booking.customer_id == customer_id,
|
||||
or_(
|
||||
Booking.status.in_(("reserved", "active")),
|
||||
Booking.ends_at > _retention_cutoff(),
|
||||
),
|
||||
)
|
||||
)
|
||||
return not blocking
|
||||
|
||||
|
||||
@router.get("/retention", response_model=PrivacyRetentionOut)
|
||||
def retention_status(
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> PrivacyRetentionOut:
|
||||
customers = db.scalars(select(Customer)).all()
|
||||
return PrivacyRetentionOut(
|
||||
minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days,
|
||||
audit_retention_days=settings.privacy_audit_retention_days,
|
||||
customers_total=len(customers),
|
||||
customers_anonymized=sum(customer.anonymized_at is not None for customer in customers),
|
||||
customers_eligible=sum(
|
||||
customer.anonymized_at is None and _customer_is_eligible(db, customer.id)
|
||||
for customer in customers
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/customers/{public_ref}/export")
|
||||
def export_customer_data(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> JSONResponse:
|
||||
customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref))
|
||||
if customer is None:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
bookings = db.scalars(
|
||||
select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at)
|
||||
).all()
|
||||
payload = {
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"customer": {
|
||||
"public_ref": customer.public_ref,
|
||||
"first_name": customer.first_name,
|
||||
"last_name": customer.last_name,
|
||||
"email": customer.email,
|
||||
"phone": customer.phone,
|
||||
"postal_code": customer.postal_code,
|
||||
"city": customer.city,
|
||||
"date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None,
|
||||
"anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None,
|
||||
},
|
||||
"bookings": [
|
||||
{
|
||||
"public_ref": booking.public_ref,
|
||||
"starts_at": booking.starts_at.isoformat(),
|
||||
"ends_at": booking.ends_at.isoformat(),
|
||||
"status": booking.status,
|
||||
}
|
||||
for booking in bookings
|
||||
],
|
||||
}
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="privacy_customer_exported",
|
||||
entity_type="customer",
|
||||
entity_id=customer.id,
|
||||
metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)},
|
||||
)
|
||||
db.commit()
|
||||
return JSONResponse(
|
||||
payload,
|
||||
headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult)
|
||||
def anonymize_customer(
|
||||
public_ref: str,
|
||||
body: CustomerAnonymizeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
actor: CurrentUser = Depends(require_operations_manager),
|
||||
) -> CustomerAnonymizeResult:
|
||||
customer = db.scalar(
|
||||
select(Customer).where(Customer.public_ref == public_ref).with_for_update()
|
||||
)
|
||||
if customer is None:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
if body.confirmation != public_ref:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="Customer reference confirmation does not match"
|
||||
)
|
||||
if customer.anonymized_at is not None:
|
||||
return CustomerAnonymizeResult(
|
||||
public_ref=public_ref,
|
||||
anonymized_at=customer.anonymized_at,
|
||||
status="already_anonymized",
|
||||
)
|
||||
if not _customer_is_eligible(db, customer.id):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Customer has an active/recent booking within the minimum retention period",
|
||||
)
|
||||
anonymized_at = datetime.now(UTC)
|
||||
customer.first_name = "Anoniem"
|
||||
customer.last_name = public_ref
|
||||
customer.email = None
|
||||
customer.phone = None
|
||||
customer.postal_code = None
|
||||
customer.city = None
|
||||
customer.date_of_birth = None
|
||||
customer.anonymized_at = anonymized_at
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=actor.display_name,
|
||||
action="privacy_customer_anonymized",
|
||||
entity_type="customer",
|
||||
entity_id=customer.id,
|
||||
before={"anonymized": False},
|
||||
after={"anonymized": True},
|
||||
metadata={"reason": body.reason, "customer_ref": public_ref},
|
||||
)
|
||||
db.commit()
|
||||
return CustomerAnonymizeResult(
|
||||
public_ref=public_ref,
|
||||
anonymized_at=anonymized_at,
|
||||
status="anonymized",
|
||||
)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db
|
||||
from app.models.booking import Booking
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
||||
|
||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||
|
||||
# Static application sections. `id` is a stable code matching navigation.json's
|
||||
# `items.*` keys -- the frontend localizes both the section label and its one-line
|
||||
# detail from `id`, so no English prose is sent over the wire (search.sections.<id> in
|
||||
# every locale; see docs/fleet-ops-correction/i18n-inventory.md). Manager-only sections
|
||||
# are filtered by role, mirroring the same nav visibility rule Layout.tsx applies --
|
||||
# search must never surface a destination the current role can't actually reach.
|
||||
_SECTIONS: list[dict] = [
|
||||
{
|
||||
"id": "overview",
|
||||
"link": "/dashboard",
|
||||
# Search terms deliberately span all three supported UI languages (not just
|
||||
# English) so a query never depends on the operator's selected locale.
|
||||
"terms": ["overview", "dashboard", "readiness", "overzicht", "aperçu", "tableau de bord"],
|
||||
},
|
||||
{
|
||||
"id": "fleet",
|
||||
"link": "/vehicles",
|
||||
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
|
||||
},
|
||||
{
|
||||
"id": "bookings",
|
||||
"link": "/bookings",
|
||||
"terms": [
|
||||
"booking",
|
||||
"bookings",
|
||||
"rental",
|
||||
"boeking",
|
||||
"boekingen",
|
||||
"verhuur",
|
||||
"réservation",
|
||||
"réservations",
|
||||
"location",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "quality",
|
||||
"link": "/data-quality",
|
||||
"terms": [
|
||||
"quality",
|
||||
"data quality",
|
||||
"issues",
|
||||
"kwaliteit",
|
||||
"datakwaliteit",
|
||||
"problemen",
|
||||
"qualité",
|
||||
"problèmes",
|
||||
],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"id": "knowledge",
|
||||
"link": "/knowledge",
|
||||
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
|
||||
},
|
||||
{
|
||||
"id": "integrations",
|
||||
"link": "/automation",
|
||||
"terms": [
|
||||
"automation",
|
||||
"integrations",
|
||||
"systems",
|
||||
"n8n",
|
||||
"automatisering",
|
||||
"integraties",
|
||||
"systemen",
|
||||
"automatisation",
|
||||
"intégrations",
|
||||
"systèmes",
|
||||
],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"id": "audit",
|
||||
"link": "/audit",
|
||||
"terms": ["audit", "history", "geschiedenis", "historique"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"id": "privacy",
|
||||
"link": "/privacy",
|
||||
"terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
def search(
|
||||
q: str = Query(min_length=1, max_length=100),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> SearchResponse:
|
||||
query = q.strip()
|
||||
normalized = query.lower()
|
||||
results: list[SearchResultItem] = []
|
||||
|
||||
for section in _SECTIONS:
|
||||
role = section.get("role")
|
||||
if role and user.role != role:
|
||||
continue
|
||||
terms: list[str] = section["terms"]
|
||||
if any(term in normalized or normalized in term for term in terms):
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="section",
|
||||
label=section["id"],
|
||||
detail_code=section["id"],
|
||||
link=section["link"],
|
||||
)
|
||||
)
|
||||
|
||||
like = f"%{query}%"
|
||||
for v in db.scalars(
|
||||
select(Vehicle)
|
||||
.where(
|
||||
or_(
|
||||
Vehicle.public_ref.ilike(like),
|
||||
Vehicle.make.ilike(like),
|
||||
Vehicle.model.ilike(like),
|
||||
Vehicle.registration_number.ilike(like),
|
||||
Vehicle.location.ilike(like),
|
||||
)
|
||||
)
|
||||
.order_by(Vehicle.public_ref)
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="vehicle",
|
||||
label=v.public_ref,
|
||||
detail_code="vehicleSummary",
|
||||
detail_params={"make": v.make, "model": v.model, "location": v.location},
|
||||
link=f"/vehicles/{v.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
for b in db.scalars(
|
||||
select(Booking)
|
||||
.where(Booking.public_ref.ilike(like))
|
||||
.order_by(Booking.starts_at.desc())
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="booking",
|
||||
label=b.public_ref,
|
||||
detail_code=b.status,
|
||||
link=f"/bookings/{b.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
# No customer detail route exists in this proof of concept, so customers are
|
||||
# deliberately never returned here -- there is nowhere useful to send the user.
|
||||
if user.role == "operations_manager":
|
||||
for i in db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(DataQualityIssue.public_ref.ilike(like))
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
type="data_quality_issue",
|
||||
label=i.public_ref,
|
||||
detail_code=i.rule_type,
|
||||
link=f"/data-quality/{i.public_ref}",
|
||||
)
|
||||
)
|
||||
|
||||
return SearchResponse(query=query, results=results[:10])
|
||||
@@ -0,0 +1,100 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
|
||||
def _to_out(user: User) -> UserOut:
|
||||
return UserOut(
|
||||
public_ref=user.public_ref,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
role=user.role, # type: ignore[arg-type]
|
||||
active=user.active,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_manager: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[UserOut]:
|
||||
return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()]
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=201)
|
||||
def create_user(
|
||||
body: CreateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
manager: CurrentUser = Depends(require_operations_manager),
|
||||
) -> UserOut:
|
||||
email = body.email.strip().lower()
|
||||
if db.scalar(select(User.id).where(User.email == email)) is not None:
|
||||
raise HTTPException(status_code=409, detail="A user with this email already exists")
|
||||
user = User(
|
||||
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
|
||||
email=email,
|
||||
password_hash=hash_password(body.password),
|
||||
display_name=body.display_name.strip(),
|
||||
role=body.role,
|
||||
active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=manager.display_name,
|
||||
action="user_created",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
after={"public_ref": user.public_ref, "role": user.role, "active": user.active},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(user)
|
||||
|
||||
|
||||
@router.patch("/{public_ref}", response_model=UserOut)
|
||||
def update_user(
|
||||
public_ref: str,
|
||||
body: UpdateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
manager: CurrentUser = Depends(require_operations_manager),
|
||||
) -> UserOut:
|
||||
user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update())
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.public_ref == manager.public_ref and body.active is False:
|
||||
raise HTTPException(status_code=409, detail="You cannot deactivate your own account")
|
||||
if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"):
|
||||
raise HTTPException(status_code=409, detail="You cannot remove your own manager role")
|
||||
before = {"display_name": user.display_name, "role": user.role, "active": user.active}
|
||||
if body.display_name is not None:
|
||||
user.display_name = body.display_name.strip()
|
||||
if body.role is not None:
|
||||
user.role = body.role
|
||||
if body.active is not None:
|
||||
user.active = body.active
|
||||
if body.password is not None:
|
||||
user.password_hash = hash_password(body.password)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=manager.display_name,
|
||||
action="user_updated",
|
||||
entity_type="user",
|
||||
entity_id=user.id,
|
||||
before=before,
|
||||
after={"display_name": user.display_name, "role": user.role, "active": user.active},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(user)
|
||||
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
from app.models.booking import Booking
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.models.inspection import Inspection
|
||||
from app.models.maintenance import MaintenanceRecord
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
BookingSummaryOut,
|
||||
CreateMaintenanceRequest,
|
||||
CurrentUser,
|
||||
DataQualityIssueOut,
|
||||
InspectionOut,
|
||||
MaintenanceOut,
|
||||
ReleaseVehicleRequest,
|
||||
VehicleDetailOut,
|
||||
VehicleOut,
|
||||
VehiclePageOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import open_odometer_regression_issue
|
||||
|
||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||
|
||||
|
||||
def _attention_vehicle_ids(db: Session) -> set:
|
||||
rows = db.scalars(
|
||||
select(DataQualityIssue.entity_id).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.status == "open",
|
||||
)
|
||||
).all()
|
||||
return set(rows)
|
||||
|
||||
|
||||
@router.get("", response_model=list[VehicleOut] | VehiclePageOut)
|
||||
def list_vehicles(
|
||||
status: str | None = Query(default=None),
|
||||
attention_only: bool = Query(default=False),
|
||||
location: str | None = Query(default=None, min_length=1, max_length=120),
|
||||
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||
page: int | None = Query(default=None, ge=1),
|
||||
page_size: int = Query(default=25, ge=1, le=25),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[VehicleOut] | VehiclePageOut:
|
||||
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
||||
if status:
|
||||
stmt = stmt.where(Vehicle.operational_status == status)
|
||||
if location:
|
||||
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
|
||||
if query:
|
||||
term = f"%{query.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Vehicle.public_ref.ilike(term),
|
||||
Vehicle.make.ilike(term),
|
||||
Vehicle.model.ilike(term),
|
||||
Vehicle.location.ilike(term),
|
||||
Vehicle.registration_number.ilike(term),
|
||||
)
|
||||
)
|
||||
attention_ids = _attention_vehicle_ids(db)
|
||||
if attention_only:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Vehicle.id.in_(attention_ids),
|
||||
Vehicle.operational_status == "blocked",
|
||||
Vehicle.next_service_km <= Vehicle.odometer_km,
|
||||
)
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||
page_number = page or 1
|
||||
vehicles = db.scalars(
|
||||
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||
).all()
|
||||
vehicle_ids = [vehicle.id for vehicle in vehicles]
|
||||
next_bookings: dict[uuid.UUID, Booking] = {}
|
||||
if vehicle_ids:
|
||||
for booking in db.scalars(
|
||||
select(Booking)
|
||||
.where(
|
||||
Booking.vehicle_id.in_(vehicle_ids),
|
||||
Booking.status == "reserved",
|
||||
Booking.starts_at >= datetime.now(UTC),
|
||||
)
|
||||
.order_by(Booking.starts_at.asc())
|
||||
).all():
|
||||
next_bookings.setdefault(booking.vehicle_id, booking)
|
||||
items = [
|
||||
VehicleOut(
|
||||
public_ref=v.public_ref,
|
||||
make=v.make,
|
||||
model=v.model,
|
||||
model_year=v.model_year,
|
||||
registration_number=v.registration_number,
|
||||
location=v.location,
|
||||
operational_status=v.operational_status,
|
||||
odometer_km=v.odometer_km,
|
||||
next_service_km=v.next_service_km,
|
||||
active=v.active,
|
||||
attention=(
|
||||
v.id in attention_ids
|
||||
or v.operational_status == "blocked"
|
||||
or v.next_service_km <= v.odometer_km
|
||||
),
|
||||
attention_reason=(
|
||||
"blocked_status"
|
||||
if v.operational_status == "blocked"
|
||||
else "service_due"
|
||||
if v.next_service_km <= v.odometer_km
|
||||
else "data_quality"
|
||||
if v.id in attention_ids
|
||||
else None
|
||||
),
|
||||
service_remaining_km=v.next_service_km - v.odometer_km,
|
||||
next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None),
|
||||
next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None),
|
||||
)
|
||||
for v in vehicles
|
||||
]
|
||||
if page is None:
|
||||
return items
|
||||
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||
return VehiclePageOut(
|
||||
items=items,
|
||||
page=min(page_number, total_pages),
|
||||
page_size=page_size,
|
||||
total=total,
|
||||
total_pages=total_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
||||
def get_vehicle(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> VehicleDetailOut:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
|
||||
bookings = db.scalars(
|
||||
select(Booking).where(Booking.vehicle_id == vehicle.id).order_by(Booking.starts_at.desc())
|
||||
).all()
|
||||
customer_ref_by_id = {c.id: c.public_ref for c in db.scalars(select(Customer)).all()}
|
||||
inspections = db.scalars(
|
||||
select(Inspection)
|
||||
.where(Inspection.vehicle_id == vehicle.id)
|
||||
.order_by(Inspection.completed_at.desc())
|
||||
).all()
|
||||
maintenance = db.scalars(
|
||||
select(MaintenanceRecord)
|
||||
.where(MaintenanceRecord.vehicle_id == vehicle.id)
|
||||
.order_by(MaintenanceRecord.occurred_at.desc())
|
||||
).all()
|
||||
# Detailed data-quality evidence is an operations-manager surface. Employees still
|
||||
# get the operational vehicle record they need, but never receive hidden evidence in
|
||||
# the payload merely because the frontend omits the Quality tab.
|
||||
issues = (
|
||||
db.scalars(
|
||||
select(DataQualityIssue)
|
||||
.where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
)
|
||||
.order_by(DataQualityIssue.detected_at.desc())
|
||||
).all()
|
||||
if user.role == "operations_manager"
|
||||
else []
|
||||
)
|
||||
|
||||
booking_by_id = {b.id: b.public_ref for b in bookings}
|
||||
next_booking = next(
|
||||
(
|
||||
booking
|
||||
for booking in sorted(bookings, key=lambda item: item.starts_at)
|
||||
if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
attention_ids = _attention_vehicle_ids(db)
|
||||
return VehicleDetailOut(
|
||||
public_ref=vehicle.public_ref,
|
||||
make=vehicle.make,
|
||||
model=vehicle.model,
|
||||
model_year=vehicle.model_year,
|
||||
registration_number=vehicle.registration_number,
|
||||
location=vehicle.location,
|
||||
operational_status=vehicle.operational_status,
|
||||
odometer_km=vehicle.odometer_km,
|
||||
next_service_km=vehicle.next_service_km,
|
||||
active=vehicle.active,
|
||||
attention=(
|
||||
vehicle.id in attention_ids
|
||||
or vehicle.operational_status == "blocked"
|
||||
or vehicle.next_service_km <= vehicle.odometer_km
|
||||
),
|
||||
attention_reason=(
|
||||
"blocked_status"
|
||||
if vehicle.operational_status == "blocked"
|
||||
else "service_due"
|
||||
if vehicle.next_service_km <= vehicle.odometer_km
|
||||
else "data_quality"
|
||||
if vehicle.id in attention_ids
|
||||
else None
|
||||
),
|
||||
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
|
||||
next_booking_ref=next_booking.public_ref if next_booking else None,
|
||||
next_booking_at=next_booking.starts_at if next_booking else None,
|
||||
bookings=[
|
||||
BookingSummaryOut(
|
||||
public_ref=b.public_ref,
|
||||
customer_ref=customer_ref_by_id.get(b.customer_id, ""),
|
||||
vehicle_ref=vehicle.public_ref,
|
||||
starts_at=b.starts_at,
|
||||
ends_at=b.ends_at,
|
||||
status=b.status,
|
||||
)
|
||||
for b in bookings
|
||||
],
|
||||
inspections=[
|
||||
InspectionOut(
|
||||
public_ref=i.public_ref,
|
||||
booking_ref=booking_by_id.get(i.booking_id, ""),
|
||||
type=i.type,
|
||||
fuel_level_percent=i.fuel_level_percent,
|
||||
cleanliness_ok=i.cleanliness_ok,
|
||||
damage_reported=i.damage_reported,
|
||||
technical_warning=i.technical_warning,
|
||||
odometer_km=i.odometer_km,
|
||||
completed_at=i.completed_at,
|
||||
)
|
||||
for i in inspections
|
||||
],
|
||||
maintenance=[
|
||||
MaintenanceOut(
|
||||
public_ref=m.public_ref,
|
||||
occurred_at=m.occurred_at,
|
||||
odometer_km=m.odometer_km,
|
||||
category=m.category,
|
||||
summary=m.summary,
|
||||
)
|
||||
for m in maintenance
|
||||
],
|
||||
quality_issues=[
|
||||
DataQualityIssueOut(
|
||||
public_ref=q.public_ref,
|
||||
rule_type=q.rule_type,
|
||||
entity_type=q.entity_type,
|
||||
entity_ref=vehicle.public_ref,
|
||||
severity=q.severity,
|
||||
status=q.status,
|
||||
evidence=q.evidence_json,
|
||||
detected_at=q.detected_at,
|
||||
due_at=q.due_at,
|
||||
assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None),
|
||||
assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None),
|
||||
overdue=(
|
||||
q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC)
|
||||
),
|
||||
resolved_at=q.resolved_at,
|
||||
)
|
||||
for q in issues
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201)
|
||||
def create_maintenance_record(
|
||||
public_ref: str,
|
||||
body: CreateMaintenanceRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> MaintenanceOut:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
correlation_id = uuid.uuid4()
|
||||
before_vehicle = {
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
"next_service_km": vehicle.next_service_km,
|
||||
}
|
||||
record = MaintenanceRecord(
|
||||
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
|
||||
vehicle_id=vehicle.id,
|
||||
occurred_at=body.occurred_at,
|
||||
odometer_km=body.odometer_km,
|
||||
category=body.category,
|
||||
summary=body.summary.strip(),
|
||||
)
|
||||
db.add(record)
|
||||
open_odometer_regression_issue(
|
||||
db,
|
||||
vehicle=vehicle,
|
||||
reading_ref=record.public_ref,
|
||||
reading_km=body.odometer_km,
|
||||
canonical_km=vehicle.odometer_km,
|
||||
source_type="maintenance",
|
||||
related_refs=[record.public_ref],
|
||||
actor_label=user.display_name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
|
||||
if body.next_service_km is not None:
|
||||
if body.next_service_km < vehicle.odometer_km:
|
||||
raise HTTPException(status_code=422, detail="Next service must not be below odometer")
|
||||
vehicle.next_service_km = body.next_service_km
|
||||
if body.mark_maintenance:
|
||||
vehicle.operational_status = "maintenance"
|
||||
vehicle.version += 1
|
||||
db.flush()
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="maintenance_record_created",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
correlation_id=correlation_id,
|
||||
before=before_vehicle,
|
||||
after={
|
||||
"maintenance_ref": record.public_ref,
|
||||
"operational_status": vehicle.operational_status,
|
||||
"odometer_km": vehicle.odometer_km,
|
||||
"next_service_km": vehicle.next_service_km,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return MaintenanceOut(
|
||||
public_ref=record.public_ref,
|
||||
occurred_at=record.occurred_at,
|
||||
odometer_km=record.odometer_km,
|
||||
category=record.category,
|
||||
summary=record.summary,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/release", response_model=VehicleOut)
|
||||
def release_vehicle(
|
||||
public_ref: str,
|
||||
body: ReleaseVehicleRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> VehicleOut:
|
||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}:
|
||||
raise HTTPException(status_code=409, detail="Vehicle does not require release")
|
||||
active_booking = db.scalar(
|
||||
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
||||
)
|
||||
open_high_issue = db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.entity_type == "vehicle",
|
||||
DataQualityIssue.entity_id == vehicle.id,
|
||||
DataQualityIssue.status == "open",
|
||||
DataQualityIssue.severity == "high",
|
||||
)
|
||||
)
|
||||
if active_booking is not None or open_high_issue is not None:
|
||||
raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition")
|
||||
before = {"status": vehicle.operational_status}
|
||||
vehicle.operational_status = "available"
|
||||
vehicle.version += 1
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="vehicle_released",
|
||||
entity_type="vehicle",
|
||||
entity_id=vehicle.id,
|
||||
before=before,
|
||||
after={"status": "available", "reason": body.reason.strip()},
|
||||
)
|
||||
db.commit()
|
||||
return VehicleOut(
|
||||
public_ref=vehicle.public_ref,
|
||||
make=vehicle.make,
|
||||
model=vehicle.model,
|
||||
model_year=vehicle.model_year,
|
||||
registration_number=vehicle.registration_number,
|
||||
location=vehicle.location,
|
||||
operational_status=vehicle.operational_status,
|
||||
odometer_km=vehicle.odometer_km,
|
||||
next_service_km=vehicle.next_service_km,
|
||||
active=vehicle.active,
|
||||
attention=vehicle.next_service_km <= vehicle.odometer_km,
|
||||
attention_reason=(
|
||||
"service_due" if vehicle.next_service_km <= vehicle.odometer_km else None
|
||||
),
|
||||
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
|
||||
next_booking_ref=None,
|
||||
next_booking_at=None,
|
||||
)
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
from app.core.errors import AppError
|
||||
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||
from app.schemas import AutomationRunOut, CurrentUser
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
|
||||
|
||||
|
||||
def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
||||
return AutomationRunOut(
|
||||
event_id=str(event.event_id),
|
||||
event_type=event.event_type,
|
||||
aggregate_ref=event.payload_json.get("aggregate_ref", ""),
|
||||
status=event.delivery_status,
|
||||
attempts=event.attempts,
|
||||
last_error=event.last_error,
|
||||
last_error_code=event.last_error_code,
|
||||
is_demo_scenario=is_demo_scenario_failure(event),
|
||||
occurred_at=event.occurred_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AutomationRunOut])
|
||||
def list_workflows(
|
||||
status: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> list[AutomationRunOut]:
|
||||
stmt = select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc())
|
||||
if status:
|
||||
stmt = stmt.where(OutboxEvent.delivery_status == status)
|
||||
events = db.scalars(stmt).all()
|
||||
return [_to_out(e) for e in events]
|
||||
|
||||
|
||||
@router.post("/{event_id}/retry", response_model=AutomationRunOut)
|
||||
def retry_workflow(
|
||||
event_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> AutomationRunOut:
|
||||
try:
|
||||
parsed_id = uuid.UUID(event_id)
|
||||
except ValueError as exc:
|
||||
raise AppError("INVALID_EVENT_ID", "event_id must be a UUID.", status_code=422) from exc
|
||||
|
||||
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == parsed_id))
|
||||
if event is None:
|
||||
raise AppError("EVENT_NOT_FOUND", "Workflow event not found.", status_code=404)
|
||||
if event.delivery_status != "failed":
|
||||
raise AppError(
|
||||
"NOT_RETRYABLE",
|
||||
f"Event is '{event.delivery_status}', not 'failed'; "
|
||||
"only failed deliveries can be retried.",
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# Captured before the status flips, so the audit records what was actually retried.
|
||||
was_demo_scenario = is_demo_scenario_failure(event)
|
||||
|
||||
event.delivery_status = "pending"
|
||||
event.next_attempt_at = None
|
||||
# The retry itself is real either way: the event goes back on the outbox and the
|
||||
# dispatcher delivers it to the configured n8n webhook like any other. The only
|
||||
# difference recorded here is *what* was retried -- a staged demo failure or a real
|
||||
# one -- so the audit trail never implies a production incident was resolved when a
|
||||
# prop was.
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
actor_label=user.display_name,
|
||||
action="workflow_retry",
|
||||
entity_type="outbox_event",
|
||||
metadata={
|
||||
"event_id": event_id,
|
||||
"previous_attempts": event.attempts,
|
||||
"demo_scenario": was_demo_scenario,
|
||||
},
|
||||
)
|
||||
db.commit()
|
||||
return _to_out(event)
|
||||
Reference in new issue
Block a user