Publish curated MobilityOps source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:03:33 +02:00
commit 7adc072f5e
317 files changed
+48851

No files matched your search

View File
Whitespace-only changes.
+214
View File
@@ -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,
)
+312
View File
@@ -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"}
+565
View File
@@ -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)
+42
View File
@@ -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
]
+151
View File
@@ -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,
)
+446
View File
@@ -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)
+186
View File
@@ -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),
)
+302
View File
@@ -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),
)
+169
View File
@@ -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"
),
}
)
+204
View File
@@ -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
+46
View File
@@ -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)
+166
View File
@@ -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",
)
+184
View File
@@ -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])
+100
View File
@@ -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)
+408
View File
@@ -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,
)
+90
View File
@@ -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)