Publish curated MobilityOps source snapshot
This commit is contained in:
commit
7adc072f5e
317 files changed
+48851
No files matched your search
@@ -0,0 +1,57 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
.agents
|
||||||
|
.codex
|
||||||
|
.claude
|
||||||
|
.dyad
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.vs
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
dist
|
||||||
|
artifacts
|
||||||
|
docs
|
||||||
|
deploy
|
||||||
|
n8n/**
|
||||||
|
!n8n/workflows/
|
||||||
|
!n8n/workflows/**
|
||||||
|
frontend
|
||||||
|
*.tgz
|
||||||
|
*.tar.gz
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
secrets
|
||||||
|
credentials
|
||||||
|
.state
|
||||||
|
data
|
||||||
|
backups
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
COMPOSE_PROJECT_NAME=mobilityops
|
||||||
|
MOBILITYOPS_ENV=development
|
||||||
|
MOBILITYOPS_DEMO_MODE=true
|
||||||
|
MOBILITYOPS_PUBLIC_URL=http://localhost:1228
|
||||||
|
# Build-time API origin baked into the web bundle. Leave empty: the SPA calls its own
|
||||||
|
# origin and nginx proxies /api to the API (required by the CSP connect-src 'self').
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
DATABASE_URL=postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops
|
||||||
|
POSTGRES_DB=mobilityops
|
||||||
|
POSTGRES_USER=mobilityops
|
||||||
|
POSTGRES_PASSWORD=mobilityops
|
||||||
|
# Signs session cookies. With MOBILITYOPS_ENV=production the API refuses to start while
|
||||||
|
# this (or MOBILITYOPS_CALLBACK_TOKEN) still holds its placeholder value.
|
||||||
|
APP_SECRET=replace-in-production
|
||||||
|
TZ=Europe/Brussels
|
||||||
|
# Session cookie Secure flag. Development on localhost may use false; production startup
|
||||||
|
# requires both an HTTPS public URL and this value set to true.
|
||||||
|
SESSION_COOKIE_SECURE=false
|
||||||
|
|
||||||
|
# Optional OpenID Connect login. Public demo role buttons remain available when enabled.
|
||||||
|
OIDC_ENABLED=false
|
||||||
|
OIDC_PROVIDER_NAME=Organisatieaccount
|
||||||
|
OIDC_ISSUER_URL=
|
||||||
|
OIDC_CLIENT_ID=
|
||||||
|
OIDC_CLIENT_SECRET=
|
||||||
|
OIDC_REDIRECT_URI=
|
||||||
|
OIDC_ALLOWED_EMAIL_DOMAINS=
|
||||||
|
OIDC_AUTO_PROVISION=true
|
||||||
|
OIDC_DEFAULT_ROLE=rental_employee
|
||||||
|
|
||||||
|
# Observability: JSON logs are always enabled. Set a token only if /metrics is exposed
|
||||||
|
# outside the private Compose network; Prometheus can send it as a bearer token.
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
# Required when the observability profile is enabled. Keep private and high entropy.
|
||||||
|
METRICS_BEARER_TOKEN=replace-me-private-metrics-token
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=change-me-before-start
|
||||||
|
# Alertmanager sends every firing/resolved alert and the continuous watchdog to this
|
||||||
|
# owner-managed receiver. Production must route it to a channel that is actually watched.
|
||||||
|
ALERTMANAGER_WEBHOOK_URL=https://n8n.example.test/webhook/mobilityops-alerts
|
||||||
|
|
||||||
|
# Verified scheduled PostgreSQL backups (Unraid override).
|
||||||
|
BACKUP_INTERVAL_SECONDS=86400
|
||||||
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
BACKUP_MINIMUM_COPIES=7
|
||||||
|
# Restore the newest dump into a disposable database at least weekly. Backup health also
|
||||||
|
# requires a successful drill within eight days.
|
||||||
|
BACKUP_RESTORE_DRILL_INTERVAL_SECONDS=604800
|
||||||
|
# Set both values to copy every verified backup to an independently mounted path.
|
||||||
|
BACKUP_SECONDARY_DESTINATION=
|
||||||
|
MOBILITYOPS_BACKUP_DIR=./backups/postgres
|
||||||
|
MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite
|
||||||
|
# Optional real off-site copy through the official rclone OneDrive adapter. OAuth state
|
||||||
|
# lives only in MOBILITYOPS_RCLONE_CONFIG_DIR and must never be committed.
|
||||||
|
BACKUP_OFFSITE_INTERVAL_SECONDS=900
|
||||||
|
RCLONE_ONEDRIVE_REMOTE=onedrive
|
||||||
|
RCLONE_ONEDRIVE_PATH=FleetOps/backups
|
||||||
|
MOBILITYOPS_RCLONE_CONFIG_DIR=./.secrets/rclone
|
||||||
|
MOBILITYOPS_OFFSITE_VERIFY_DIR=./backups/offsite-verify
|
||||||
|
|
||||||
|
# Privacy governance defaults.
|
||||||
|
PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS=30
|
||||||
|
PRIVACY_AUDIT_RETENTION_DAYS=2555
|
||||||
|
PRIVACY_AUDIT_EXPORT_MAX_ROWS=10000
|
||||||
|
|
||||||
|
# Demo presentation (fictional org identity, badge/manifest, reset safety valve).
|
||||||
|
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
|
||||||
|
# of role -- a safety valve for any environment where the dataset must not be rebuildable.
|
||||||
|
DEMO_ORGANIZATION_NAME=Northstar Mobility
|
||||||
|
DEMO_TIMEZONE=Europe/Brussels
|
||||||
|
DEMO_ALLOW_RESET=true
|
||||||
|
# Prevent public visitors from repeatedly rebuilding the shared dataset. Concurrent
|
||||||
|
# resets are always rejected using both process and PostgreSQL advisory locks.
|
||||||
|
DEMO_RESET_COOLDOWN_SECONDS=60
|
||||||
|
|
||||||
|
# Operational mode: set MOBILITYOPS_DEMO_MODE=false and provide the first manager.
|
||||||
|
# Keep these values in a secret store or an untracked production .env file.
|
||||||
|
INITIAL_ADMIN_EMAIL=
|
||||||
|
INITIAL_ADMIN_PASSWORD=
|
||||||
|
INITIAL_ADMIN_DISPLAY_NAME=Operations Manager
|
||||||
|
|
||||||
|
# n8n
|
||||||
|
N8N_BASE_URL=http://n8n:5678
|
||||||
|
N8N_WEBHOOK_URL=http://n8n:5678/webhook/mobilityops-return
|
||||||
|
N8N_ENCRYPTION_KEY=replace-me
|
||||||
|
N8N_BASIC_AUTH_ACTIVE=true
|
||||||
|
N8N_BASIC_AUTH_USER=admin
|
||||||
|
N8N_BASIC_AUTH_PASSWORD=change-me
|
||||||
|
# Recipient and sender used by the importable alert workflow. Configure real,
|
||||||
|
# monitored addresses in the deployment environment; repository defaults stay synthetic.
|
||||||
|
MOBILITYOPS_ALERT_RECIPIENT=alerts@example.test
|
||||||
|
MOBILITYOPS_ALERT_SENDER=n8n@example.test
|
||||||
|
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
|
||||||
|
# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return-
|
||||||
|
# processing webhook, so the webhook trigger can require Header Auth instead of being
|
||||||
|
# publicly callable by anyone who discovers the URL. Must match the value stored in
|
||||||
|
# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential.
|
||||||
|
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
|
||||||
|
|
||||||
|
# RAGcore integration
|
||||||
|
KNOWLEDGE_PROVIDER=demo
|
||||||
|
RAGCORE_BASE_URL=http://ragcore-api:8000
|
||||||
|
# Optional existing Docker network used by hosted deployments to reach RAGcore through
|
||||||
|
# its private service alias instead of exposing RAGcore on the LAN.
|
||||||
|
RAGCORE_DOCKER_NETWORK=
|
||||||
|
RAGCORE_TENANT=northstar-mobility-demo
|
||||||
|
RAGCORE_WORKSPACE=mobilityops
|
||||||
|
RAGCORE_COLLECTION=internal-procedures
|
||||||
|
RAGCORE_API_TOKEN=
|
||||||
|
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
|
||||||
|
RAGCORE_SPACE_ID=
|
||||||
|
# Skip the slower generation endpoint temporarily after a timeout/non-2xx response and
|
||||||
|
# use the still-grounded extractive search fallback immediately.
|
||||||
|
RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS=60
|
||||||
|
|
||||||
|
# ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own
|
||||||
|
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
|
||||||
|
# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability
|
||||||
|
# health check surfaced on the integration status page.
|
||||||
|
MCP_HUB_REGISTRATION_ENABLED=false
|
||||||
|
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
|
||||||
|
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
|
||||||
|
MCP_PROVIDER_ID=fleet-ops
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.webp binary
|
||||||
|
*.zip binary
|
||||||
|
|
||||||
|
# Generated operational evidence is not part of a source release archive.
|
||||||
|
/artifacts export-ignore
|
||||||
|
/PROJECT_STATE.md export-ignore
|
||||||
|
/MASTER_BUILD_PROMPT.md export-ignore
|
||||||
|
/FILE_INDEX.md export-ignore
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
# Secrets and local configuration
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
secrets/
|
||||||
|
credentials/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.venv/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Frontend and test output
|
||||||
|
node_modules/
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Runtime data and local infrastructure state
|
||||||
|
.state/
|
||||||
|
data/
|
||||||
|
backups/local/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# Generated release/design evidence. Maintained documentation belongs in docs/.
|
||||||
|
artifacts/**/final-summary.md
|
||||||
|
artifacts/deployment/
|
||||||
|
artifacts/design-validation/current/
|
||||||
|
artifacts/**/screenshots/generated/
|
||||||
|
|
||||||
|
# Local AI/editor state
|
||||||
|
.codex/
|
||||||
|
.claude/
|
||||||
|
.agents/
|
||||||
|
.dyad/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.vs/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Archives and local release bundles
|
||||||
|
*.zip
|
||||||
|
*.tar
|
||||||
|
*.tar.gz
|
||||||
|
*.tgz
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
MobilityOps contributions must preserve fleet-data privacy, deterministic demo behaviour and the fail-closed integration boundaries documented in `SECURITY.md`.
|
||||||
|
|
||||||
|
- use synthetic vehicles, customers, bookings, returns, telematics events and identity claims in tests and screenshots;
|
||||||
|
- never commit production databases, exports, operator inventories, private service URLs, tokens, backups or generated browser evidence;
|
||||||
|
- keep external integrations configurable through environment variables or explicit deployment configuration;
|
||||||
|
- document new personal-data fields, retention, authorization, audit and deletion/export behaviour;
|
||||||
|
- add negative tests for authentication, authorization, duplicate handling, webhook validation, path containment and stale/unavailable providers;
|
||||||
|
- review dependencies, images and browser assets for provenance and redistribution rights.
|
||||||
|
|
||||||
|
Run the relevant backend, frontend, migration, integration, Compose and managed-validation gates before review. Security-sensitive findings belong through the private process in `SECURITY.md`.
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Jens Caers
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.PHONY: up down logs test lint contracts seed reset n8n-setup n8n-setup-scan demo e2e live-smoke
|
||||||
|
|
||||||
|
up:
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f --tail=200
|
||||||
|
|
||||||
|
test:
|
||||||
|
sh scripts/run-isolated-tests.sh
|
||||||
|
|
||||||
|
lint:
|
||||||
|
docker compose -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
|
||||||
|
docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app
|
||||||
|
cd frontend && npm run lint
|
||||||
|
|
||||||
|
contracts:
|
||||||
|
docker compose -f compose.yaml -f compose.test.yaml run --build --rm \
|
||||||
|
-v "$(CURDIR):/repo:ro" api python /repo/scripts/check-contracts.py
|
||||||
|
python scripts/check-source-budgets.py
|
||||||
|
|
||||||
|
seed:
|
||||||
|
docker compose exec api python -m app.cli seed --reset
|
||||||
|
|
||||||
|
reset:
|
||||||
|
docker compose down -v
|
||||||
|
docker compose up --build -d
|
||||||
|
|
||||||
|
# One-time per environment: imports and activates the n8n return-processing workflow.
|
||||||
|
# The n8n owner account itself cannot be scripted safely and must be created once at
|
||||||
|
# http://localhost:5678/setup (any email/password, no verification required) before
|
||||||
|
# this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
|
||||||
|
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
|
||||||
|
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
|
||||||
|
n8n-setup:
|
||||||
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
|
||||||
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||||
|
docker compose restart n8n
|
||||||
|
|
||||||
|
# One-time per environment: imports and activates the scheduled quality-scan workflow.
|
||||||
|
# Same owner-account and credential preconditions as n8n-setup above (this workflow only
|
||||||
|
# needs "Fleet Ops Service Token").
|
||||||
|
n8n-setup-scan:
|
||||||
|
docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
|
||||||
|
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
|
||||||
|
docker compose restart n8n
|
||||||
|
|
||||||
|
# Full deterministic demo bootstrap: build, migrate (automatic on api startup), seed.
|
||||||
|
demo: up
|
||||||
|
docker compose exec api python -m app.cli seed --reset
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
cd frontend && npx playwright test
|
||||||
|
|
||||||
|
live-smoke:
|
||||||
|
cd frontend && npx playwright test --config=playwright.live.config.ts
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Public source export
|
||||||
|
|
||||||
|
This source snapshot was generated from the private canonical repository at revision `0458403e679d5e76f48b4b49d235476631fecf08`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
|||||||
|
# MobilityOps · public source
|
||||||
|
|
||||||
|
MobilityOps (Fleet Ops) is a synthetic-data operations demo for vehicle rental and service teams. This repository is the reviewed public source snapshot. Development and deployment happen in a private canonical repository; each public update is generated from a committed revision with a fresh content review.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env`, then run `make demo`. Open <http://localhost:1228>. The demo uses fictional records and a local knowledge provider.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- `backend/` — FastAPI domain and API
|
||||||
|
- `frontend/` — React interface and browser tests
|
||||||
|
- `contracts/` — API and event contracts
|
||||||
|
- `knowledge/` and `seed/` — synthetic procedures and records
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Please report vulnerabilities through the contact in [SECURITY.md](SECURITY.md). No operational deployment state or private Git history is included in this repository.
|
||||||
|
|
||||||
|
Licensed under the terms in [LICENSE](LICENSE).
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
| Version | Security support |
|
||||||
|
|---|---|
|
||||||
|
| Latest tagged PoC release and current `master` | Supported |
|
||||||
|
| Older commits, branches and untagged deployments | Not supported |
|
||||||
|
|
||||||
|
MobilityOps is a synthetic-data proof of concept, not a production identity,
|
||||||
|
payments or public reservation platform. Security fixes target the current
|
||||||
|
release line only.
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
Do not disclose suspected vulnerabilities through a public issue.
|
||||||
|
|
||||||
|
Report them privately to `security@itworx.tech` with:
|
||||||
|
|
||||||
|
- the affected revision, endpoint or component;
|
||||||
|
- reproduction steps and prerequisites;
|
||||||
|
- the observed and expected behaviour;
|
||||||
|
- the security impact;
|
||||||
|
- a minimal proof of concept, without unnecessary personal or secret data.
|
||||||
|
|
||||||
|
Receipt should be acknowledged within three business days. An initial
|
||||||
|
assessment or request for additional evidence should follow within ten
|
||||||
|
business days. Remediation timing depends on severity and reproducibility.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- MobilityOps backend, frontend, container and deployment code;
|
||||||
|
- authentication, authorization, tenant boundaries and audit integrity;
|
||||||
|
- database, outbox, backup and restore behaviour;
|
||||||
|
- MobilityOps-owned n8n workflow definitions;
|
||||||
|
- RAGcore and MCP Hub integration boundaries implemented in this repository.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- denial-of-service or destructive testing against the hosted demo;
|
||||||
|
- social engineering, credential stuffing or physical attacks;
|
||||||
|
- synthetic demo-data exposure without a security-boundary failure;
|
||||||
|
- vulnerabilities solely inside RAGcore, ITWorx MCP Hub, n8n or another
|
||||||
|
third-party service. Report those to their respective owners.
|
||||||
|
|
||||||
|
Do not access data beyond what is required to demonstrate the issue, modify
|
||||||
|
shared infrastructure, interrupt other services or retain obtained secrets.
|
||||||
|
|
||||||
|
## Coordinated disclosure
|
||||||
|
|
||||||
|
Good-faith research that respects this policy and applicable law will be
|
||||||
|
handled constructively. Allow a reasonable remediation period before public
|
||||||
|
disclosure. Submitted reports and evidence are used only for investigation,
|
||||||
|
remediation and verification.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base
|
||||||
|
ARG VCS_REF=development
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
LABEL org.opencontainers.image.title="Fleet Ops API" \
|
||||||
|
org.opencontainers.image.revision="$VCS_REF" \
|
||||||
|
org.opencontainers.image.created="$BUILD_DATE" \
|
||||||
|
org.opencontainers.image.source="https://fleetops.example.test"
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
COPY backend/requirements-prod.lock ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements-prod.lock
|
||||||
|
COPY backend/pyproject.toml ./
|
||||||
|
COPY backend/app ./app
|
||||||
|
COPY backend/alembic ./alembic
|
||||||
|
COPY backend/alembic.ini ./
|
||||||
|
COPY seed ./seed
|
||||||
|
COPY knowledge ./knowledge
|
||||||
|
COPY backend/entrypoint.sh ./entrypoint.sh
|
||||||
|
RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh \
|
||||||
|
&& addgroup --system app && adduser --system --ingroup app --home /app app \
|
||||||
|
&& chown -R app:app /app
|
||||||
|
|
||||||
|
FROM runtime-base AS test
|
||||||
|
COPY backend/requirements.lock ./requirements.lock
|
||||||
|
RUN pip install --no-cache-dir -r requirements.lock
|
||||||
|
COPY backend/tests ./tests
|
||||||
|
COPY contracts ./contracts
|
||||||
|
COPY scripts/check-contracts.py ./scripts/check-contracts.py
|
||||||
|
COPY n8n/workflows ./n8n/workflows
|
||||||
|
USER app
|
||||||
|
|
||||||
|
FROM runtime-base AS runtime
|
||||||
|
# Run migrations and the API as an unprivileged user; nothing here needs root.
|
||||||
|
USER app
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["./entrypoint.sh"]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""idempotency request fingerprint
|
||||||
|
|
||||||
|
Revision ID: 0a4c1d2e3f5b
|
||||||
|
Revises: c24f6a9d013e
|
||||||
|
Create Date: 2026-08-16 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0a4c1d2e3f5b"
|
||||||
|
down_revision: Union[str, None] = "c24f6a9d013e"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"idempotency_records",
|
||||||
|
sa.Column("request_fingerprint", sa.String(length=64), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("idempotency_records", "request_fingerprint")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""enforce one open issue per detected condition
|
||||||
|
|
||||||
|
Revision ID: 4f2b9c8d7e61
|
||||||
|
Revises: 0a4c1d2e3f5b
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "4f2b9c8d7e61"
|
||||||
|
down_revision = "0a4c1d2e3f5b"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_index(
|
||||||
|
"uq_data_quality_one_open_condition",
|
||||||
|
"data_quality_issues",
|
||||||
|
["rule_type", "entity_type", "entity_id"],
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=sa.text("status = 'open'"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_data_quality_one_open_condition", table_name="data_quality_issues")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""outbox last_error_code
|
||||||
|
|
||||||
|
Revision ID: 799d8800e241
|
||||||
|
Revises: e7b08389f47f
|
||||||
|
Create Date: 2026-08-03 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '799d8800e241'
|
||||||
|
down_revision: Union[str, None] = 'e7b08389f47f'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('outbox_events', sa.Column('last_error_code', sa.String(length=60), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('outbox_events', 'last_error_code')
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""add optional external OIDC identity
|
||||||
|
|
||||||
|
Revision ID: a81d0ce9f662
|
||||||
|
Revises: f43d829ab610
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "a81d0ce9f662"
|
||||||
|
down_revision = "f43d829ab610"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("identity_provider", sa.String(80), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("external_subject", sa.String(255), nullable=True))
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_user_external_identity", "users", ["identity_provider", "external_subject"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("uq_user_external_identity", "users", type_="unique")
|
||||||
|
op.drop_column("users", "external_subject")
|
||||||
|
op.drop_column("users", "identity_provider")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""operational user credentials
|
||||||
|
|
||||||
|
Revision ID: b7c7b536df85
|
||||||
|
Revises: 799d8800e241
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "b7c7b536df85"
|
||||||
|
down_revision = "799d8800e241"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("email", sa.String(length=320), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("password_hash", sa.String(length=512), nullable=True))
|
||||||
|
op.create_unique_constraint("uq_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("uq_users_email", "users", type_="unique")
|
||||||
|
op.drop_column("users", "password_hash")
|
||||||
|
op.drop_column("users", "email")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""add explicit customer anonymisation state
|
||||||
|
|
||||||
|
Revision ID: b913a72e8c14
|
||||||
|
Revises: a81d0ce9f662
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "b913a72e8c14"
|
||||||
|
down_revision = "a81d0ce9f662"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("customers", sa.Column("anonymized_at", sa.DateTime(timezone=True)))
|
||||||
|
op.create_index("ix_customers_anonymized_at", "customers", ["anonymized_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_customers_anonymized_at", table_name="customers")
|
||||||
|
op.drop_column("customers", "anonymized_at")
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""add domain constraints and operational indexes
|
||||||
|
|
||||||
|
Revision ID: c24f6a9d013e
|
||||||
|
Revises: b913a72e8c14
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "c24f6a9d013e"
|
||||||
|
down_revision = "b913a72e8c14"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
checks = (
|
||||||
|
("bookings", "ck_bookings_status", "status IN ('reserved','active','returned','cancelled','blocked')"),
|
||||||
|
("bookings", "ck_bookings_time_window", "ends_at > starts_at"),
|
||||||
|
("bookings", "ck_bookings_start_odometer", "start_odometer_km IS NULL OR start_odometer_km >= 0"),
|
||||||
|
("bookings", "ck_bookings_end_odometer", "end_odometer_km IS NULL OR end_odometer_km >= 0"),
|
||||||
|
("vehicles", "ck_vehicles_operational_status", "operational_status IN ('available','rented','cleaning','maintenance','blocked')"),
|
||||||
|
("vehicles", "ck_vehicles_model_year", "model_year BETWEEN 1900 AND 2100"),
|
||||||
|
("vehicles", "ck_vehicles_odometer", "odometer_km >= 0"),
|
||||||
|
("vehicles", "ck_vehicles_next_service", "next_service_km >= 0"),
|
||||||
|
("vehicles", "ck_vehicles_version", "version >= 1"),
|
||||||
|
("data_quality_issues", "ck_data_quality_rule_type", "rule_type IN ('possible_duplicate_customer','missing_required_field','odometer_regression','booking_overlap','vehicle_status_conflict')"),
|
||||||
|
("data_quality_issues", "ck_data_quality_severity", "severity IN ('low','medium','high')"),
|
||||||
|
("data_quality_issues", "ck_data_quality_status", "status IN ('open','deferred','resolved','rejected')"),
|
||||||
|
("outbox_events", "ck_outbox_delivery_status", "delivery_status IN ('pending','delivering','succeeded','failed')"),
|
||||||
|
("outbox_events", "ck_outbox_attempts", "attempts >= 0"),
|
||||||
|
("audit_events", "ck_audit_actor_type", "actor_type IN ('user','service','system')"),
|
||||||
|
)
|
||||||
|
for table, name, condition in checks:
|
||||||
|
op.create_check_constraint(name, table, condition)
|
||||||
|
|
||||||
|
op.create_index("ix_bookings_vehicle_status_window", "bookings", ["vehicle_id", "status", "starts_at", "ends_at"])
|
||||||
|
op.create_index("ix_data_quality_work_queue", "data_quality_issues", ["status", "due_at", "severity"])
|
||||||
|
op.create_index("ix_outbox_delivery_next_attempt", "outbox_events", ["delivery_status", "next_attempt_at"])
|
||||||
|
op.create_index("ix_audit_action_occurred", "audit_events", ["action", "occurred_at"])
|
||||||
|
op.create_index("ix_audit_entity", "audit_events", ["entity_type", "entity_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_audit_entity", table_name="audit_events")
|
||||||
|
op.drop_index("ix_audit_action_occurred", table_name="audit_events")
|
||||||
|
op.drop_index("ix_outbox_delivery_next_attempt", table_name="outbox_events")
|
||||||
|
op.drop_index("ix_data_quality_work_queue", table_name="data_quality_issues")
|
||||||
|
op.drop_index("ix_bookings_vehicle_status_window", table_name="bookings")
|
||||||
|
for table, name in (
|
||||||
|
("audit_events", "ck_audit_actor_type"),
|
||||||
|
("outbox_events", "ck_outbox_attempts"),
|
||||||
|
("outbox_events", "ck_outbox_delivery_status"),
|
||||||
|
("data_quality_issues", "ck_data_quality_status"),
|
||||||
|
("data_quality_issues", "ck_data_quality_severity"),
|
||||||
|
("data_quality_issues", "ck_data_quality_rule_type"),
|
||||||
|
("vehicles", "ck_vehicles_version"),
|
||||||
|
("vehicles", "ck_vehicles_next_service"),
|
||||||
|
("vehicles", "ck_vehicles_odometer"),
|
||||||
|
("vehicles", "ck_vehicles_model_year"),
|
||||||
|
("vehicles", "ck_vehicles_operational_status"),
|
||||||
|
("bookings", "ck_bookings_end_odometer"),
|
||||||
|
("bookings", "ck_bookings_start_odometer"),
|
||||||
|
("bookings", "ck_bookings_time_window"),
|
||||||
|
("bookings", "ck_bookings_status"),
|
||||||
|
):
|
||||||
|
op.drop_constraint(name, table, type_="check")
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""initial schema
|
||||||
|
|
||||||
|
Revision ID: c9498525abb5
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-08-01 20:50:05.997864
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c9498525abb5'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('audit_events',
|
||||||
|
sa.Column('actor_type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('actor_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('actor_label', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('action', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('entity_type', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('correlation_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('before_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('after_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('metadata_json', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_table('customers',
|
||||||
|
sa.Column('public_ref', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('first_name', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('last_name', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=200), nullable=True),
|
||||||
|
sa.Column('phone', sa.String(length=40), nullable=True),
|
||||||
|
sa.Column('postal_code', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('city', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('date_of_birth', sa.Date(), nullable=True),
|
||||||
|
sa.Column('merged_into_customer_id', sa.UUID(), nullable=True),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['merged_into_customer_id'], ['customers.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_ref')
|
||||||
|
)
|
||||||
|
op.create_table('data_quality_issues',
|
||||||
|
sa.Column('public_ref', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('rule_type', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('entity_type', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('severity', sa.String(length=10), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('evidence_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('proposed_action_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('detected_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('resolved_by', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_ref')
|
||||||
|
)
|
||||||
|
op.create_table('outbox_events',
|
||||||
|
sa.Column('event_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('event_type', sa.String(length=60), nullable=False),
|
||||||
|
sa.Column('aggregate_type', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('aggregate_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('payload_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('delivery_status', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('attempts', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('next_attempt_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('last_error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('external_run_id', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('event_id')
|
||||||
|
)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('public_ref', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('display_name', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('role', sa.String(length=30), nullable=False),
|
||||||
|
sa.Column('active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_ref')
|
||||||
|
)
|
||||||
|
op.create_table('vehicles',
|
||||||
|
sa.Column('public_ref', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('make', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('model', sa.String(length=80), nullable=False),
|
||||||
|
sa.Column('model_year', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('registration_number', sa.String(length=40), nullable=False),
|
||||||
|
sa.Column('location', sa.String(length=120), nullable=False),
|
||||||
|
sa.Column('operational_status', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('odometer_km', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('next_service_km', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('version', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_ref'),
|
||||||
|
sa.UniqueConstraint('registration_number')
|
||||||
|
)
|
||||||
|
op.create_table('bookings',
|
||||||
|
sa.Column('public_ref', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('customer_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('vehicle_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('starts_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('ends_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('start_odometer_km', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('end_odometer_km', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('requirements_complete', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_ref')
|
||||||
|
)
|
||||||
|
op.create_table('maintenance_records',
|
||||||
|
sa.Column('public_ref', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('vehicle_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('odometer_km', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('category', sa.String(length=60), nullable=False),
|
||||||
|
sa.Column('summary', sa.Text(), nullable=False),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_ref')
|
||||||
|
)
|
||||||
|
op.create_table('inspections',
|
||||||
|
sa.Column('public_ref', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('booking_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('vehicle_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=20), nullable=False),
|
||||||
|
sa.Column('fuel_level_percent', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('cleanliness_ok', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('damage_reported', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('technical_warning', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('odometer_km', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('completed_by', sa.String(length=120), nullable=True),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('public_ref')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('inspections')
|
||||||
|
op.drop_table('maintenance_records')
|
||||||
|
op.drop_table('bookings')
|
||||||
|
op.drop_table('vehicles')
|
||||||
|
op.drop_table('users')
|
||||||
|
op.drop_table('outbox_events')
|
||||||
|
op.drop_table('data_quality_issues')
|
||||||
|
op.drop_table('customers')
|
||||||
|
op.drop_table('audit_events')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""persist revoked sessions
|
||||||
|
|
||||||
|
Revision ID: d1f83bc64170
|
||||||
|
Revises: b7c7b536df85
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "d1f83bc64170"
|
||||||
|
down_revision = "b7c7b536df85"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"revoked_sessions",
|
||||||
|
sa.Column("token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("id", sa.Uuid(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_revoked_sessions_expires_at", "revoked_sessions", ["expires_at"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_revoked_sessions_token_hash",
|
||||||
|
"revoked_sessions",
|
||||||
|
["token_hash"],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_revoked_sessions_token_hash", table_name="revoked_sessions")
|
||||||
|
op.drop_index("ix_revoked_sessions_expires_at", table_name="revoked_sessions")
|
||||||
|
op.drop_table("revoked_sessions")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""idempotency records
|
||||||
|
|
||||||
|
Revision ID: e7b08389f47f
|
||||||
|
Revises: c9498525abb5
|
||||||
|
Create Date: 2026-08-01 21:24:35.472070
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e7b08389f47f'
|
||||||
|
down_revision: Union[str, None] = 'c9498525abb5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('idempotency_records',
|
||||||
|
sa.Column('idempotency_key', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('booking_id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('response_status', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('response_body', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column('id', sa.UUID(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('idempotency_key')
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_table('idempotency_records')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""add ownership and SLA deadlines to data quality issues
|
||||||
|
|
||||||
|
Revision ID: f43d829ab610
|
||||||
|
Revises: d1f83bc64170
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "f43d829ab610"
|
||||||
|
down_revision = "d1f83bc64170"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"data_quality_issues",
|
||||||
|
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"data_quality_issues",
|
||||||
|
sa.Column("assigned_to_user_id", sa.Uuid(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_data_quality_issues_assigned_user",
|
||||||
|
"data_quality_issues",
|
||||||
|
"users",
|
||||||
|
["assigned_to_user_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index("ix_data_quality_issues_due_at", "data_quality_issues", ["due_at"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_data_quality_issues_assigned_to_user_id",
|
||||||
|
"data_quality_issues",
|
||||||
|
["assigned_to_user_id"],
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE data_quality_issues
|
||||||
|
SET due_at = detected_at + CASE severity
|
||||||
|
WHEN 'high' THEN interval '4 hours'
|
||||||
|
WHEN 'low' THEN interval '3 days'
|
||||||
|
ELSE interval '1 day'
|
||||||
|
END
|
||||||
|
WHERE status = 'open' AND due_at IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
"ix_data_quality_issues_assigned_to_user_id",
|
||||||
|
table_name="data_quality_issues",
|
||||||
|
)
|
||||||
|
op.drop_index("ix_data_quality_issues_due_at", table_name="data_quality_issues")
|
||||||
|
op.drop_constraint(
|
||||||
|
"fk_data_quality_issues_assigned_user",
|
||||||
|
"data_quality_issues",
|
||||||
|
type_="foreignkey",
|
||||||
|
)
|
||||||
|
op.drop_column("data_quality_issues", "assigned_to_user_id")
|
||||||
|
op.drop_column("data_quality_issues", "due_at")
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,94 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import re
|
||||||
|
from collections.abc import Generator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.core.security import SessionPayload, read_session_token
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas import CurrentUser, Role
|
||||||
|
from app.services.sessions import is_session_revoked
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
payload: SessionPayload | None = read_session_token(token) if token else None
|
||||||
|
revoked = token is not None and is_session_revoked(db, token)
|
||||||
|
if payload is None or payload.role not in _VALID_ROLES or revoked:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
# Demo reset deliberately rebuilds the deterministic users table. Retaining the
|
||||||
|
# signed demo session until the reset endpoint clears its cookie keeps existing demo
|
||||||
|
# workflows stable; operational sessions are always checked against the live record.
|
||||||
|
if settings.mobilityops_demo_mode:
|
||||||
|
demo_role: Role = payload.role # type: ignore[assignment]
|
||||||
|
return CurrentUser(
|
||||||
|
public_ref=payload.public_ref,
|
||||||
|
display_name=payload.display_name,
|
||||||
|
role=demo_role,
|
||||||
|
)
|
||||||
|
user = db.get(User, payload.user_id)
|
||||||
|
if (
|
||||||
|
user is None
|
||||||
|
or not user.active
|
||||||
|
or user.public_ref != payload.public_ref
|
||||||
|
or user.role not in _VALID_ROLES
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
role: Role = user.role # type: ignore[assignment]
|
||||||
|
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=role)
|
||||||
|
|
||||||
|
|
||||||
|
def require_operations_manager(
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if user.role != "operations_manager":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Operations Manager role required"
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class McpClientContext:
|
||||||
|
reported_client_id: str
|
||||||
|
tenant: str
|
||||||
|
|
||||||
|
|
||||||
|
_MCP_CLIENT_ID = re.compile(
|
||||||
|
r"^itworx-mcp-hub:(?:readiness|mobilityops:[A-Za-z0-9][A-Za-z0-9._:-]{0,127})$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_mcp_service_token(
|
||||||
|
x_service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
x_client_id: str = Header(..., alias="X-Client-Id", min_length=1, max_length=180),
|
||||||
|
x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id", max_length=120),
|
||||||
|
) -> McpClientContext:
|
||||||
|
if not hmac.compare_digest(x_service_token, settings.mcp_hub_service_token):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token"
|
||||||
|
)
|
||||||
|
if not _MCP_CLIENT_ID.fullmatch(x_client_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Untrusted MCP client identity"
|
||||||
|
)
|
||||||
|
if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch")
|
||||||
|
return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant)
|
||||||
Whitespace-only changes.
@@ -0,0 +1,214 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Only entity types with a stable public reference and (optionally) a real frontend route
|
||||||
|
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
|
||||||
|
# entity_id and are left as plain labels.
|
||||||
|
_ENTITY_MODELS: dict[str, Any] = {
|
||||||
|
"vehicle": Vehicle,
|
||||||
|
"booking": Booking,
|
||||||
|
"customer": Customer,
|
||||||
|
"data_quality_issue": DataQualityIssue,
|
||||||
|
}
|
||||||
|
_ROUTE_TEMPLATES: dict[str, str] = {
|
||||||
|
"vehicle": "/vehicles/{ref}",
|
||||||
|
"booking": "/bookings/{ref}",
|
||||||
|
"data_quality_issue": "/data-quality/{ref}",
|
||||||
|
# No customer detail route exists in this proof of concept; still resolve the
|
||||||
|
# reference for display, just without a link.
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_utc(value: datetime | None) -> datetime | None:
|
||||||
|
"""Treat naive query datetimes as UTC so they compare safely with aware values."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export.csv")
|
||||||
|
def export_audit_csv(
|
||||||
|
occurred_from: datetime | None = Query(default=None),
|
||||||
|
occurred_to: datetime | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> Response:
|
||||||
|
end = _as_utc(occurred_to) or datetime.now(UTC)
|
||||||
|
start = _as_utc(occurred_from) or end - timedelta(days=30)
|
||||||
|
if end <= start or end - start > timedelta(days=90):
|
||||||
|
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
|
||||||
|
events = db.scalars(
|
||||||
|
select(AuditEvent)
|
||||||
|
.where(AuditEvent.occurred_at >= start, AuditEvent.occurred_at <= end)
|
||||||
|
.order_by(AuditEvent.occurred_at)
|
||||||
|
.limit(settings.privacy_audit_export_max_rows + 1)
|
||||||
|
).all()
|
||||||
|
if len(events) > settings.privacy_audit_export_max_rows:
|
||||||
|
raise HTTPException(status_code=413, detail="Audit export exceeds configured row limit")
|
||||||
|
output = io.StringIO(newline="")
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow(
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
"occurred_at",
|
||||||
|
"actor_type",
|
||||||
|
"actor_label",
|
||||||
|
"action",
|
||||||
|
"entity_type",
|
||||||
|
"entity_id",
|
||||||
|
"correlation_id",
|
||||||
|
"before",
|
||||||
|
"after",
|
||||||
|
"metadata",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for event in events:
|
||||||
|
writer.writerow(
|
||||||
|
(
|
||||||
|
event.id,
|
||||||
|
event.occurred_at.isoformat(),
|
||||||
|
event.actor_type,
|
||||||
|
event.actor_label,
|
||||||
|
event.action,
|
||||||
|
event.entity_type,
|
||||||
|
event.entity_id or "",
|
||||||
|
event.correlation_id,
|
||||||
|
json.dumps(event.before_json, separators=(",", ":"), default=str),
|
||||||
|
json.dumps(event.after_json, separators=(",", ":"), default=str),
|
||||||
|
json.dumps(event.metadata_json, separators=(",", ":"), default=str),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="audit_exported",
|
||||||
|
entity_type="audit",
|
||||||
|
metadata={"from": start.isoformat(), "to": end.isoformat(), "rows": len(events)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return Response(
|
||||||
|
output.getvalue(),
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="mobilityops-audit.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
|
||||||
|
ids_by_type: dict[str, set[uuid.UUID]] = {}
|
||||||
|
for event in events:
|
||||||
|
if event.entity_id is not None and event.entity_type in _ENTITY_MODELS:
|
||||||
|
ids_by_type.setdefault(event.entity_type, set()).add(event.entity_id)
|
||||||
|
|
||||||
|
refs: dict[uuid.UUID, str] = {}
|
||||||
|
for entity_type, ids in ids_by_type.items():
|
||||||
|
model = _ENTITY_MODELS[entity_type]
|
||||||
|
rows: Sequence[Any] = db.scalars(select(model).where(model.id.in_(ids))).all()
|
||||||
|
for row in rows:
|
||||||
|
refs[row.id] = row.public_ref
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
|
||||||
|
def list_audit_events(
|
||||||
|
actor_label: str | None = Query(default=None),
|
||||||
|
action: str | None = Query(default=None),
|
||||||
|
entity_type: str | None = Query(default=None),
|
||||||
|
entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
|
correlation_id: uuid.UUID | None = Query(default=None),
|
||||||
|
occurred_from: datetime | None = Query(default=None),
|
||||||
|
occurred_to: datetime | None = Query(default=None),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> list[AuditEventOut] | AuditEventPageOut:
|
||||||
|
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
|
||||||
|
if actor_label:
|
||||||
|
stmt = stmt.where(AuditEvent.actor_label == actor_label)
|
||||||
|
if action:
|
||||||
|
stmt = stmt.where(AuditEvent.action == action)
|
||||||
|
if entity_type:
|
||||||
|
stmt = stmt.where(AuditEvent.entity_type == entity_type)
|
||||||
|
if entity_ref:
|
||||||
|
matched_ids: set[uuid.UUID] = set()
|
||||||
|
for model in _ENTITY_MODELS.values():
|
||||||
|
matched_ids.update(
|
||||||
|
db.scalars(
|
||||||
|
select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if not matched_ids:
|
||||||
|
if page is None:
|
||||||
|
return []
|
||||||
|
return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||||
|
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
|
||||||
|
if correlation_id:
|
||||||
|
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
|
||||||
|
if occurred_from:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
|
||||||
|
if occurred_to:
|
||||||
|
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
events = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
entity_refs = _resolve_entity_refs(db, events)
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for e in events:
|
||||||
|
ref = entity_refs.get(e.entity_id) if e.entity_id else None
|
||||||
|
route = _ROUTE_TEMPLATES.get(e.entity_type)
|
||||||
|
out.append(
|
||||||
|
AuditEventOut(
|
||||||
|
id=str(e.id),
|
||||||
|
actor_type=e.actor_type,
|
||||||
|
actor_label=e.actor_label,
|
||||||
|
action=e.action,
|
||||||
|
entity_type=e.entity_type,
|
||||||
|
entity_id=str(e.entity_id) if e.entity_id else None,
|
||||||
|
entity_ref=ref,
|
||||||
|
entity_link=route.format(ref=ref) if route and ref else None,
|
||||||
|
correlation_id=str(e.correlation_id),
|
||||||
|
occurred_at=e.occurred_at,
|
||||||
|
before=e.before_json,
|
||||||
|
after=e.after_json,
|
||||||
|
metadata=e.metadata_json,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if page is None:
|
||||||
|
return out
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return AuditEventPageOut(
|
||||||
|
items=out,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.ratelimit import FailedAttemptLimiter
|
||||||
|
from app.core.security import (
|
||||||
|
SessionPayload,
|
||||||
|
create_session_token,
|
||||||
|
hash_password,
|
||||||
|
read_session_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.sessions import revoke_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||||
|
settings = get_settings()
|
||||||
|
_login_limiter = (
|
||||||
|
FailedAttemptLimiter(
|
||||||
|
max_failures=settings.login_max_failures,
|
||||||
|
window_seconds=settings.login_failure_window_seconds,
|
||||||
|
)
|
||||||
|
if settings.login_max_failures > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_key(request: Request) -> str:
|
||||||
|
# The API sits behind the web container's reverse proxy in every documented
|
||||||
|
# deployment. The proxy appends/overwrites the socket peer as the final hop, so an
|
||||||
|
# attacker-controlled leading value must never select a fresh limiter bucket.
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[-1].strip()
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
oauth = OAuth()
|
||||||
|
if settings.oidc_enabled and settings.oidc_issuer_url:
|
||||||
|
oauth.register(
|
||||||
|
name="oidc",
|
||||||
|
client_id=settings.oidc_client_id,
|
||||||
|
client_secret=settings.oidc_client_secret,
|
||||||
|
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid email profile"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _current_user_out(user: User) -> CurrentUser:
|
||||||
|
return CurrentUser(
|
||||||
|
public_ref=user.public_ref,
|
||||||
|
display_name=user.display_name,
|
||||||
|
role=user.role, # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session(response: Response, user: User) -> None:
|
||||||
|
token = create_session_token(
|
||||||
|
SessionPayload(
|
||||||
|
user_id=str(user.id),
|
||||||
|
public_ref=user.public_ref,
|
||||||
|
role=user.role,
|
||||||
|
display_name=user.display_name,
|
||||||
|
issued_at=int(time.time()),
|
||||||
|
session_id=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
settings.session_cookie_name,
|
||||||
|
token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=settings.session_cookie_secure,
|
||||||
|
max_age=settings.session_ttl_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_initial_admin(db: Session) -> None:
|
||||||
|
"""Create or rotate the explicitly configured first manager in operational mode."""
|
||||||
|
if (
|
||||||
|
settings.mobilityops_demo_mode
|
||||||
|
or not settings.initial_admin_email
|
||||||
|
or not settings.initial_admin_password
|
||||||
|
):
|
||||||
|
return
|
||||||
|
email = settings.initial_admin_email.strip().lower()
|
||||||
|
user = db.scalar(select(User).where(User.email == email))
|
||||||
|
if user is None:
|
||||||
|
user = User(
|
||||||
|
public_ref="USR-ADMIN",
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(settings.initial_admin_password),
|
||||||
|
display_name=settings.initial_admin_display_name,
|
||||||
|
role="operations_manager",
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="system",
|
||||||
|
actor_label="bootstrap",
|
||||||
|
action="operational_admin_created",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_configured() -> bool:
|
||||||
|
return bool(
|
||||||
|
settings.oidc_enabled
|
||||||
|
and settings.oidc_issuer_url
|
||||||
|
and settings.oidc_client_id
|
||||||
|
and settings.oidc_client_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_redirect_uri() -> str:
|
||||||
|
return settings.oidc_redirect_uri or (
|
||||||
|
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _allowed_oidc_email(email: str) -> bool:
|
||||||
|
domains = {
|
||||||
|
value.strip().casefold()
|
||||||
|
for value in settings.oidc_allowed_email_domains.split(",")
|
||||||
|
if value.strip()
|
||||||
|
}
|
||||||
|
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
|
||||||
|
subject = str(claims.get("sub") or "").strip()
|
||||||
|
email = str(claims.get("email") or "").strip().lower()
|
||||||
|
if not subject or not email or claims.get("email_verified") is not True:
|
||||||
|
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
|
||||||
|
if not _allowed_oidc_email(email):
|
||||||
|
raise HTTPException(status_code=403, detail="Email domain is not allowed")
|
||||||
|
|
||||||
|
provider = settings.oidc_issuer_url.rstrip("/")
|
||||||
|
user = db.scalar(
|
||||||
|
select(User).where(
|
||||||
|
User.identity_provider == provider,
|
||||||
|
User.external_subject == subject,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
user = db.scalar(select(User).where(User.email == email))
|
||||||
|
if user is not None and user.external_subject not in (None, subject):
|
||||||
|
raise HTTPException(status_code=409, detail="Email is linked to another identity")
|
||||||
|
if user is not None and claims.get("email_verified") is not True:
|
||||||
|
# Linking an existing local account (possibly the bootstrap admin) purely on an
|
||||||
|
# email match requires the IdP to explicitly assert the address is verified;
|
||||||
|
# an absent claim is treated as unverified.
|
||||||
|
raise HTTPException(status_code=401, detail="Verified OIDC email is required")
|
||||||
|
created = user is None
|
||||||
|
if created:
|
||||||
|
if not settings.oidc_auto_provision:
|
||||||
|
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
|
||||||
|
role = settings.oidc_default_role
|
||||||
|
if role not in {"operations_manager", "rental_employee"}:
|
||||||
|
role = "rental_employee"
|
||||||
|
user = User(
|
||||||
|
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
|
||||||
|
email=email,
|
||||||
|
password_hash=None,
|
||||||
|
display_name=str(claims.get("name") or email),
|
||||||
|
role=role,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
assert user is not None
|
||||||
|
if not user.active:
|
||||||
|
raise HTTPException(status_code=403, detail="User is inactive")
|
||||||
|
user.identity_provider = provider
|
||||||
|
user.external_subject = subject
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="system" if created else "user",
|
||||||
|
actor_id=None if created else user.id,
|
||||||
|
actor_label=settings.oidc_provider_name,
|
||||||
|
action="oidc_user_provisioned" if created else "oidc_identity_linked",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
metadata={"provider": provider},
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oidc/status", response_model=OidcStatusOut)
|
||||||
|
def oidc_status() -> OidcStatusOut:
|
||||||
|
return OidcStatusOut(
|
||||||
|
enabled=_oidc_configured(),
|
||||||
|
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oidc/login")
|
||||||
|
async def oidc_login(request: Request) -> Response:
|
||||||
|
if not _oidc_configured():
|
||||||
|
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
||||||
|
client = oauth.create_client("oidc")
|
||||||
|
if client is None:
|
||||||
|
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
||||||
|
return await client.authorize_redirect(request, _oidc_redirect_uri())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/oidc/callback")
|
||||||
|
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
|
||||||
|
if not _oidc_configured():
|
||||||
|
raise HTTPException(status_code=404, detail="OIDC login is not configured")
|
||||||
|
client = oauth.create_client("oidc")
|
||||||
|
if client is None:
|
||||||
|
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
|
||||||
|
try:
|
||||||
|
token = await client.authorize_access_token(request)
|
||||||
|
except OAuthError as exc:
|
||||||
|
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
|
||||||
|
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
|
||||||
|
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
|
||||||
|
_set_session(response, user)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=user.id,
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="oidc_login",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=CurrentUser)
|
||||||
|
def password_login(
|
||||||
|
body: PasswordLoginRequest,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if settings.mobilityops_demo_mode:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Password login is unavailable in demo mode",
|
||||||
|
)
|
||||||
|
limiter_key = _client_key(request)
|
||||||
|
retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0
|
||||||
|
if retry_after:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many failed login attempts. Try again later.",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
|
||||||
|
if user is None or not user.active or not verify_password(body.password, user.password_hash):
|
||||||
|
if _login_limiter:
|
||||||
|
_login_limiter.record_failure(limiter_key)
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||||
|
if _login_limiter:
|
||||||
|
_login_limiter.reset(limiter_key)
|
||||||
|
_set_session(response, user)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=user.id,
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="password_login",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _current_user_out(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=CurrentUser)
|
||||||
|
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
payload = read_session_token(token) if token else None
|
||||||
|
if payload is not None and token is not None:
|
||||||
|
revoke_session(db, token, payload)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=uuid.UUID(payload.user_id),
|
||||||
|
actor_label=payload.display_name,
|
||||||
|
action="logout",
|
||||||
|
entity_type="user",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
|
return {"status": "logged_out"}
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
|
||||||
|
from sqlalchemy import case, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import (
|
||||||
|
AvailableVehicleOut,
|
||||||
|
BookingOut,
|
||||||
|
BookingPageOut,
|
||||||
|
CancelBookingRequest,
|
||||||
|
CheckoutBookingRequest,
|
||||||
|
CheckoutBookingResult,
|
||||||
|
CompleteBookingRequirementsRequest,
|
||||||
|
CreateBookingRequest,
|
||||||
|
CurrentUser,
|
||||||
|
NextBookingRisk,
|
||||||
|
RegisterReturnRequest,
|
||||||
|
RegisterReturnResult,
|
||||||
|
RescheduleBookingRequest,
|
||||||
|
ReturnPreviewResult,
|
||||||
|
)
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality import open_odometer_regression_issue
|
||||||
|
from app.services.returns import preview_vehicle_return, register_vehicle_return
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOut:
|
||||||
|
return BookingOut(
|
||||||
|
public_ref=booking.public_ref,
|
||||||
|
customer_ref=customer.public_ref,
|
||||||
|
vehicle_ref=vehicle.public_ref,
|
||||||
|
starts_at=booking.starts_at,
|
||||||
|
ends_at=booking.ends_at,
|
||||||
|
status=booking.status,
|
||||||
|
start_odometer_km=booking.start_odometer_km,
|
||||||
|
end_odometer_km=booking.end_odometer_km,
|
||||||
|
requirements_complete=booking.requirements_complete,
|
||||||
|
customer_name=f"{customer.first_name} {customer.last_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[BookingOut] | BookingPageOut)
|
||||||
|
def list_bookings(
|
||||||
|
status: str | None = Query(default=None),
|
||||||
|
vehicle_ref: str | None = Query(default=None),
|
||||||
|
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
|
starts_from: datetime | None = Query(default=None),
|
||||||
|
starts_to: datetime | None = Query(default=None),
|
||||||
|
location: str | None = Query(default=None, min_length=1, max_length=120),
|
||||||
|
sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[BookingOut] | BookingPageOut:
|
||||||
|
stmt = select(Booking)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(Booking.status == status)
|
||||||
|
if vehicle_ref:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
|
if vehicle is None:
|
||||||
|
if page is None:
|
||||||
|
return []
|
||||||
|
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
|
||||||
|
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
|
||||||
|
if starts_from:
|
||||||
|
stmt = stmt.where(Booking.ends_at >= starts_from)
|
||||||
|
if starts_to:
|
||||||
|
stmt = stmt.where(Booking.starts_at < starts_to)
|
||||||
|
if query or location:
|
||||||
|
stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join(
|
||||||
|
Vehicle, Booking.vehicle_id == Vehicle.id
|
||||||
|
)
|
||||||
|
if location:
|
||||||
|
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
|
||||||
|
if query:
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Booking.public_ref.ilike(term),
|
||||||
|
Customer.first_name.ilike(term),
|
||||||
|
Customer.last_name.ilike(term),
|
||||||
|
Vehicle.public_ref.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if sort == "starts_asc":
|
||||||
|
stmt = stmt.order_by(Booking.starts_at.asc())
|
||||||
|
elif sort == "starts_desc":
|
||||||
|
stmt = stmt.order_by(Booking.starts_at.desc())
|
||||||
|
else:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
operational_bucket = case(
|
||||||
|
(Booking.status == "active", 0),
|
||||||
|
(Booking.starts_at >= now, 1),
|
||||||
|
else_=2,
|
||||||
|
)
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
operational_bucket,
|
||||||
|
case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(),
|
||||||
|
Booking.starts_at.desc(),
|
||||||
|
)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
bookings = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
customer_ids = {booking.customer_id for booking in bookings}
|
||||||
|
vehicle_ids = {booking.vehicle_id for booking in bookings}
|
||||||
|
customers = {
|
||||||
|
customer.id: customer
|
||||||
|
for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all()
|
||||||
|
}
|
||||||
|
vehicles = {
|
||||||
|
vehicle.id: vehicle
|
||||||
|
for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
|
||||||
|
}
|
||||||
|
items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
|
||||||
|
if page is None:
|
||||||
|
return items
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return BookingPageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BookingOut, status_code=201)
|
||||||
|
def create_booking(
|
||||||
|
body: CreateBookingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> BookingOut:
|
||||||
|
if body.ends_at <= body.starts_at:
|
||||||
|
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||||
|
customer = db.scalar(
|
||||||
|
select(Customer).where(Customer.public_ref == body.customer_ref).with_for_update()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
customer is None
|
||||||
|
or customer.merged_into_customer_id is not None
|
||||||
|
or customer.anonymized_at is not None
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
|
||||||
|
# Serialise booking creation per vehicle. The overlap check must run after
|
||||||
|
# acquiring this lock, otherwise two concurrent requests can both pass it.
|
||||||
|
vehicle = db.scalar(
|
||||||
|
select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update()
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
vehicle is None
|
||||||
|
or not vehicle.active
|
||||||
|
or vehicle.operational_status in {"maintenance", "blocked"}
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking")
|
||||||
|
overlap = db.scalar(
|
||||||
|
select(Booking.id).where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status.in_(("reserved", "active")),
|
||||||
|
Booking.starts_at < body.ends_at,
|
||||||
|
Booking.ends_at > body.starts_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if overlap is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
|
||||||
|
booking = Booking(
|
||||||
|
public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}",
|
||||||
|
customer_id=customer.id,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
ends_at=body.ends_at,
|
||||||
|
status="reserved",
|
||||||
|
start_odometer_km=None,
|
||||||
|
end_odometer_km=None,
|
||||||
|
# Requirements are intentionally confirmed in a separate, audited action.
|
||||||
|
# Never allow booking creation to bypass that operational checkpoint.
|
||||||
|
requirements_complete=False,
|
||||||
|
)
|
||||||
|
db.add(booking)
|
||||||
|
db.flush()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="booking_created",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(booking, customer, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult)
|
||||||
|
def checkout_booking(
|
||||||
|
public_ref: str,
|
||||||
|
body: CheckoutBookingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CheckoutBookingResult:
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||||
|
if booking is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
if booking.status != "reserved":
|
||||||
|
raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out")
|
||||||
|
if not booking.requirements_complete:
|
||||||
|
raise HTTPException(status_code=409, detail="Booking requirements are incomplete")
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Booking references a missing vehicle")
|
||||||
|
if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout")
|
||||||
|
active_conflict = db.scalar(
|
||||||
|
select(Booking.id).where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status == "active",
|
||||||
|
Booking.id != booking.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if active_conflict is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
before_booking = {
|
||||||
|
"status": booking.status,
|
||||||
|
"start_odometer_km": booking.start_odometer_km,
|
||||||
|
}
|
||||||
|
before_vehicle = {
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
}
|
||||||
|
attention_reasons: list[str] = []
|
||||||
|
if body.start_odometer_km < vehicle.odometer_km:
|
||||||
|
attention_reasons.append("odometer_regression")
|
||||||
|
if not body.cleanliness_ok:
|
||||||
|
attention_reasons.append("cleanliness")
|
||||||
|
if body.damage_reported:
|
||||||
|
attention_reasons.append("damage")
|
||||||
|
if body.technical_warning:
|
||||||
|
attention_reasons.append("technical_warning")
|
||||||
|
|
||||||
|
inspection = Inspection(
|
||||||
|
public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}",
|
||||||
|
booking_id=booking.id,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
type="checkout",
|
||||||
|
fuel_level_percent=body.fuel_level_percent,
|
||||||
|
cleanliness_ok=body.cleanliness_ok,
|
||||||
|
damage_reported=body.damage_reported,
|
||||||
|
technical_warning=body.technical_warning,
|
||||||
|
notes=body.notes,
|
||||||
|
odometer_km=body.start_odometer_km,
|
||||||
|
completed_at=datetime.now(UTC),
|
||||||
|
completed_by=user.display_name,
|
||||||
|
)
|
||||||
|
db.add(inspection)
|
||||||
|
if "odometer_regression" in attention_reasons:
|
||||||
|
open_odometer_regression_issue(
|
||||||
|
db,
|
||||||
|
vehicle=vehicle,
|
||||||
|
reading_ref=inspection.public_ref,
|
||||||
|
reading_km=body.start_odometer_km,
|
||||||
|
canonical_km=vehicle.odometer_km,
|
||||||
|
source_type="checkout",
|
||||||
|
related_refs=[booking.public_ref, inspection.public_ref],
|
||||||
|
actor_label=user.display_name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
if attention_reasons:
|
||||||
|
booking.status = "blocked"
|
||||||
|
vehicle.operational_status = (
|
||||||
|
"maintenance" if body.damage_reported or body.technical_warning else "cleaning"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
booking.status = "active"
|
||||||
|
booking.start_odometer_km = body.start_odometer_km
|
||||||
|
vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km)
|
||||||
|
vehicle.operational_status = "rented"
|
||||||
|
vehicle.version += 1
|
||||||
|
db.flush()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="booking_checkout_recorded",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before_booking,
|
||||||
|
after={
|
||||||
|
"inspection_ref": inspection.public_ref,
|
||||||
|
"booking_status": booking.status,
|
||||||
|
"start_odometer_km": booking.start_odometer_km,
|
||||||
|
"vehicle_status": vehicle.operational_status,
|
||||||
|
"attention_reasons": attention_reasons,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="vehicle_status_changed",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before_vehicle,
|
||||||
|
after={
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
},
|
||||||
|
metadata={"booking_ref": booking.public_ref, "inspection_ref": inspection.public_ref},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return CheckoutBookingResult(
|
||||||
|
booking_ref=booking.public_ref,
|
||||||
|
vehicle_ref=vehicle.public_ref,
|
||||||
|
inspection_ref=inspection.public_ref,
|
||||||
|
booking_status=booking.status,
|
||||||
|
resulting_vehicle_status=vehicle.operational_status,
|
||||||
|
activated=booking.status == "active",
|
||||||
|
attention_reasons=attention_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/availability", response_model=list[AvailableVehicleOut])
|
||||||
|
def list_available_vehicles(
|
||||||
|
starts_at: datetime,
|
||||||
|
ends_at: datetime,
|
||||||
|
query: str | None = Query(default=None, max_length=100),
|
||||||
|
limit: int = Query(default=25, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[AvailableVehicleOut]:
|
||||||
|
if ends_at <= starts_at:
|
||||||
|
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||||
|
overlapping_vehicle_ids = select(Booking.vehicle_id).where(
|
||||||
|
Booking.status.in_(("reserved", "active")),
|
||||||
|
Booking.starts_at < ends_at,
|
||||||
|
Booking.ends_at > starts_at,
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Vehicle)
|
||||||
|
.where(
|
||||||
|
Vehicle.active.is_(True),
|
||||||
|
Vehicle.operational_status.not_in(("maintenance", "blocked")),
|
||||||
|
Vehicle.id.not_in(overlapping_vehicle_ids),
|
||||||
|
)
|
||||||
|
.order_by(Vehicle.location, Vehicle.public_ref)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if query and query.strip():
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(term),
|
||||||
|
Vehicle.make.ilike(term),
|
||||||
|
Vehicle.model.ilike(term),
|
||||||
|
Vehicle.registration_number.ilike(term),
|
||||||
|
Vehicle.location.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
AvailableVehicleOut(
|
||||||
|
public_ref=vehicle.public_ref,
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
registration_number=vehicle.registration_number,
|
||||||
|
location=vehicle.location,
|
||||||
|
operational_status=vehicle.operational_status,
|
||||||
|
)
|
||||||
|
for vehicle in db.scalars(stmt).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{public_ref}", response_model=BookingOut)
|
||||||
|
def get_booking(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> BookingOut:
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref))
|
||||||
|
if booking is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
customer = db.get(Customer, booking.customer_id)
|
||||||
|
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||||
|
if customer is None or vehicle is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||||
|
return _to_out(booking, customer, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/complete-requirements", response_model=BookingOut)
|
||||||
|
def complete_booking_requirements(
|
||||||
|
public_ref: str,
|
||||||
|
body: CompleteBookingRequirementsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> BookingOut:
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||||
|
if booking is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
if booking.status != "reserved":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Requirements can only be confirmed for a reserved booking",
|
||||||
|
)
|
||||||
|
customer = db.get(Customer, booking.customer_id)
|
||||||
|
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||||
|
if customer is None or vehicle is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||||
|
if not booking.requirements_complete:
|
||||||
|
booking.requirements_complete = True
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="booking_requirements_completed",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
before={"requirements_complete": False},
|
||||||
|
after={"requirements_complete": True},
|
||||||
|
metadata={"confirmation": body.confirmation.strip()},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(booking, customer, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{public_ref}/schedule", response_model=BookingOut)
|
||||||
|
def reschedule_booking(
|
||||||
|
public_ref: str,
|
||||||
|
body: RescheduleBookingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> BookingOut:
|
||||||
|
if body.ends_at <= body.starts_at:
|
||||||
|
raise HTTPException(status_code=422, detail="Booking end must be after its start")
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||||
|
if booking is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
if booking.status != "reserved":
|
||||||
|
raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled")
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
|
||||||
|
customer = db.get(Customer, booking.customer_id)
|
||||||
|
if customer is None or vehicle is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||||
|
overlap = db.scalar(
|
||||||
|
select(Booking.id).where(
|
||||||
|
Booking.vehicle_id == booking.vehicle_id,
|
||||||
|
Booking.id != booking.id,
|
||||||
|
Booking.status.in_(("reserved", "active")),
|
||||||
|
Booking.starts_at < body.ends_at,
|
||||||
|
Booking.ends_at > body.starts_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if overlap is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
|
||||||
|
before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}
|
||||||
|
booking.starts_at = body.starts_at
|
||||||
|
booking.ends_at = body.ends_at
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="booking_rescheduled",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
before=before,
|
||||||
|
after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()},
|
||||||
|
metadata={"reason": body.reason.strip()},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(booking, customer, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/cancel", response_model=BookingOut)
|
||||||
|
def cancel_booking(
|
||||||
|
public_ref: str,
|
||||||
|
body: CancelBookingRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> BookingOut:
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
|
||||||
|
if booking is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Booking not found")
|
||||||
|
if booking.status not in ("reserved", "blocked"):
|
||||||
|
# A booking blocked at checkout (damage, technical warning, ...) has no other exit:
|
||||||
|
# it never became active, so it can neither be returned nor completed. Cancelling
|
||||||
|
# it (audited, with a reason) is the only way to close the file.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail="Only a reserved or blocked booking can be cancelled"
|
||||||
|
)
|
||||||
|
customer = db.get(Customer, booking.customer_id)
|
||||||
|
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||||
|
if customer is None or vehicle is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Booking references a missing record")
|
||||||
|
before = {"status": booking.status}
|
||||||
|
booking.status = "cancelled"
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="booking_cancelled",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
before=before,
|
||||||
|
after={"status": booking.status, "reason": body.reason.strip()},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(booking, customer, vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
|
||||||
|
def preview_return(
|
||||||
|
public_ref: str,
|
||||||
|
body: RegisterReturnRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> ReturnPreviewResult:
|
||||||
|
booking, vehicle, evaluation = preview_vehicle_return(db, public_ref, body)
|
||||||
|
return ReturnPreviewResult(
|
||||||
|
booking_ref=booking.public_ref,
|
||||||
|
vehicle_ref=vehicle.public_ref,
|
||||||
|
canonical_odometer_km=evaluation.canonical_odometer_km,
|
||||||
|
submitted_odometer_km=evaluation.submitted_odometer_km,
|
||||||
|
odometer_regression=evaluation.odometer_regression,
|
||||||
|
resulting_odometer_km=evaluation.resulting_odometer_km,
|
||||||
|
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
||||||
|
status_reason=evaluation.status_reason,
|
||||||
|
status_reason_code=evaluation.status_reason_code,
|
||||||
|
status_reason_params=evaluation.status_reason_params,
|
||||||
|
would_create_quality_issue=evaluation.would_create_quality_issue,
|
||||||
|
attention_reasons=evaluation.attention_reasons,
|
||||||
|
next_booking_risk=(
|
||||||
|
NextBookingRisk(**evaluation.next_booking_risk)
|
||||||
|
if evaluation.next_booking_risk is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/return", response_model=RegisterReturnResult)
|
||||||
|
def register_return(
|
||||||
|
public_ref: str,
|
||||||
|
body: RegisterReturnRequest,
|
||||||
|
response: Response,
|
||||||
|
idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> RegisterReturnResult:
|
||||||
|
status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user)
|
||||||
|
response.status_code = status_code
|
||||||
|
return RegisterReturnResult(**result)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.schemas import CurrentUser, CustomerOptionOut
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CustomerOptionOut])
|
||||||
|
def search_customers(
|
||||||
|
query: str = Query(min_length=2, max_length=100),
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[CustomerOptionOut]:
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
customers = db.scalars(
|
||||||
|
select(Customer)
|
||||||
|
.where(
|
||||||
|
Customer.merged_into_customer_id.is_(None),
|
||||||
|
Customer.anonymized_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Customer.public_ref.ilike(term),
|
||||||
|
Customer.first_name.ilike(term),
|
||||||
|
Customer.last_name.ilike(term),
|
||||||
|
Customer.email.ilike(term),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(Customer.last_name, Customer.first_name)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
return [
|
||||||
|
CustomerOptionOut(
|
||||||
|
public_ref=customer.public_ref,
|
||||||
|
display_name=f"{customer.first_name} {customer.last_name}",
|
||||||
|
email=customer.email,
|
||||||
|
)
|
||||||
|
for customer in customers
|
||||||
|
]
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Literal
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import (
|
||||||
|
AttentionItem,
|
||||||
|
AutomationRunOut,
|
||||||
|
CurrentUser,
|
||||||
|
DashboardOut,
|
||||||
|
EvidenceSignalOut,
|
||||||
|
TodayItem,
|
||||||
|
)
|
||||||
|
from app.services.operations import compute_metrics
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def _local_tz() -> ZoneInfo:
|
||||||
|
return ZoneInfo(settings.demo_timezone)
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> date:
|
||||||
|
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
|
||||||
|
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
|
||||||
|
# Timestamps are stored in UTC but the operational day is the local (Europe/Brussels)
|
||||||
|
# calendar day, so a 23:30Z departure belongs to tomorrow's schedule in summer.
|
||||||
|
return datetime.now(_local_tz()).date()
|
||||||
|
|
||||||
|
|
||||||
|
def _local_date(value: datetime) -> date:
|
||||||
|
return value.astimezone(_local_tz()).date()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DashboardOut)
|
||||||
|
def get_dashboard(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> DashboardOut:
|
||||||
|
metrics = compute_metrics(db)
|
||||||
|
|
||||||
|
vehicles_by_id = {v.id: v for v in db.scalars(select(Vehicle)).all()}
|
||||||
|
customers_by_id = {c.id: c for c in db.scalars(select(Customer)).all()}
|
||||||
|
|
||||||
|
issues = db.scalars(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(DataQualityIssue.status == "open")
|
||||||
|
.order_by(DataQualityIssue.detected_at.asc())
|
||||||
|
).all()
|
||||||
|
attention_items = []
|
||||||
|
for issue in issues:
|
||||||
|
entity: Vehicle | Customer | None
|
||||||
|
link_type: Literal["vehicle", "customer"]
|
||||||
|
if issue.entity_type == "vehicle":
|
||||||
|
entity = vehicles_by_id.get(issue.entity_id)
|
||||||
|
link_type = "vehicle"
|
||||||
|
else:
|
||||||
|
entity = customers_by_id.get(issue.entity_id)
|
||||||
|
link_type = "customer"
|
||||||
|
link_ref = entity.public_ref if entity else ""
|
||||||
|
# The backend never emits prose for the attention queue -- only stable signal
|
||||||
|
# codes + raw data params, exactly like the issue detail page's evidence list
|
||||||
|
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
|
||||||
|
# that turns these into the operator's selected language; `evidence_json["summary"]`
|
||||||
|
# is a technical fallback only, never rendered here.
|
||||||
|
signals = [
|
||||||
|
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
|
||||||
|
for s in issue.evidence_json.get("signals", [])
|
||||||
|
]
|
||||||
|
attention_items.append(
|
||||||
|
AttentionItem(
|
||||||
|
kind="quality_issue",
|
||||||
|
severity=issue.severity,
|
||||||
|
rule_type=issue.rule_type,
|
||||||
|
evidence_signals=signals,
|
||||||
|
link_type=link_type,
|
||||||
|
link_ref=link_ref,
|
||||||
|
issue_ref=issue.public_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Curate a credible severity mix instead of letting `high` dominate every slot:
|
||||||
|
# each item's real severity is unchanged, only the display selection is capped per
|
||||||
|
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
|
||||||
|
# issues doesn't crowd out medium/low ones the operator should still see.
|
||||||
|
high_items = [i for i in attention_items if i.severity == "high"]
|
||||||
|
medium_items = [i for i in attention_items if i.severity == "medium"]
|
||||||
|
low_items = [i for i in attention_items if i.severity == "low"]
|
||||||
|
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
|
||||||
|
|
||||||
|
today = _today()
|
||||||
|
bookings = db.scalars(select(Booking)).all()
|
||||||
|
today_items: list[TodayItem] = []
|
||||||
|
for b in bookings:
|
||||||
|
vehicle = vehicles_by_id.get(b.vehicle_id)
|
||||||
|
vehicle_ref = vehicle.public_ref if vehicle else ""
|
||||||
|
if _local_date(b.starts_at) == today and b.status in ("reserved", "active"):
|
||||||
|
today_items.append(
|
||||||
|
TodayItem(
|
||||||
|
kind="departure",
|
||||||
|
booking_ref=b.public_ref,
|
||||||
|
vehicle_ref=vehicle_ref,
|
||||||
|
scheduled_at=b.starts_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if _local_date(b.ends_at) == today and b.status in ("active", "returned"):
|
||||||
|
today_items.append(
|
||||||
|
TodayItem(
|
||||||
|
kind="return",
|
||||||
|
booking_ref=b.public_ref,
|
||||||
|
vehicle_ref=vehicle_ref,
|
||||||
|
scheduled_at=b.ends_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
today_items.sort(key=lambda item: item.scheduled_at)
|
||||||
|
|
||||||
|
recent = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all()
|
||||||
|
recent_automation = [
|
||||||
|
AutomationRunOut(
|
||||||
|
event_id=str(r.event_id),
|
||||||
|
event_type=r.event_type,
|
||||||
|
aggregate_ref=r.payload_json.get("aggregate_ref", ""),
|
||||||
|
status=r.delivery_status,
|
||||||
|
attempts=r.attempts,
|
||||||
|
last_error=r.last_error,
|
||||||
|
last_error_code=r.last_error_code,
|
||||||
|
occurred_at=r.occurred_at,
|
||||||
|
)
|
||||||
|
for r in recent
|
||||||
|
]
|
||||||
|
|
||||||
|
return DashboardOut(
|
||||||
|
metrics=metrics,
|
||||||
|
attention_items=attention_items,
|
||||||
|
today=today_items,
|
||||||
|
recent_automation=recent_automation,
|
||||||
|
)
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import case, func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.maintenance import MaintenanceRecord
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import (
|
||||||
|
ApplyRecommendedStatusRequest,
|
||||||
|
ApplyRecommendedStatusResult,
|
||||||
|
BulkDataQualityWorkRequest,
|
||||||
|
BulkDataQualityWorkResult,
|
||||||
|
CurrentUser,
|
||||||
|
DataQualityIssueDetailOut,
|
||||||
|
DataQualityIssueOut,
|
||||||
|
DataQualityIssuePageOut,
|
||||||
|
MergeCustomersRequest,
|
||||||
|
MergeCustomersResult,
|
||||||
|
ProvideFieldsRequest,
|
||||||
|
ResolveOdometerRegressionRequest,
|
||||||
|
ResolveOverlapRequest,
|
||||||
|
ScanResultOut,
|
||||||
|
StatusRecommendationOut,
|
||||||
|
VehicleStatusFactsOut,
|
||||||
|
)
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality import (
|
||||||
|
apply_recommended_status,
|
||||||
|
defer_issue,
|
||||||
|
merge_customers,
|
||||||
|
preview_vehicle_status_recommendation,
|
||||||
|
provide_missing_fields,
|
||||||
|
reject_issue,
|
||||||
|
resolve_booking_overlap,
|
||||||
|
resolve_odometer_regression,
|
||||||
|
run_scan,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||||
|
assignee = issue.assigned_to_user
|
||||||
|
return DataQualityIssueOut(
|
||||||
|
public_ref=issue.public_ref,
|
||||||
|
rule_type=issue.rule_type,
|
||||||
|
entity_type=issue.entity_type,
|
||||||
|
entity_ref=issue.evidence_json.get("entity_ref", ""),
|
||||||
|
severity=issue.severity,
|
||||||
|
status=issue.status,
|
||||||
|
evidence=issue.evidence_json,
|
||||||
|
detected_at=issue.detected_at,
|
||||||
|
due_at=issue.due_at,
|
||||||
|
assigned_to_ref=assignee.public_ref if assignee else None,
|
||||||
|
assigned_to_name=assignee.display_name if assignee else None,
|
||||||
|
overdue=(
|
||||||
|
issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC)
|
||||||
|
),
|
||||||
|
resolved_at=issue.resolved_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
|
||||||
|
def list_issues(
|
||||||
|
status: str | None = Query(default=None),
|
||||||
|
rule_type: str | None = Query(default=None),
|
||||||
|
severity: str | None = Query(default=None),
|
||||||
|
assigned_to_ref: str | None = Query(default=None),
|
||||||
|
overdue: bool | None = Query(default=None),
|
||||||
|
demo_only: bool | None = Query(default=None),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
|
||||||
|
severity_order = case(
|
||||||
|
(DataQualityIssue.severity == "high", 0),
|
||||||
|
(DataQualityIssue.severity == "medium", 1),
|
||||||
|
else_=2,
|
||||||
|
)
|
||||||
|
stmt = select(DataQualityIssue).order_by(
|
||||||
|
DataQualityIssue.due_at.asc().nulls_last(),
|
||||||
|
severity_order,
|
||||||
|
DataQualityIssue.detected_at.desc(),
|
||||||
|
)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(DataQualityIssue.status == status)
|
||||||
|
if rule_type:
|
||||||
|
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
|
||||||
|
if severity:
|
||||||
|
stmt = stmt.where(DataQualityIssue.severity == severity)
|
||||||
|
if assigned_to_ref == "unassigned":
|
||||||
|
stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
|
||||||
|
elif assigned_to_ref:
|
||||||
|
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
|
||||||
|
User.public_ref == assigned_to_ref
|
||||||
|
)
|
||||||
|
if overdue is True:
|
||||||
|
stmt = stmt.where(
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.due_at < datetime.now(UTC),
|
||||||
|
)
|
||||||
|
if demo_only is True:
|
||||||
|
# Server-side so the guided demo scenarios are found on any page, not only the
|
||||||
|
# 25 rows currently loaded in the browser.
|
||||||
|
stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%"))
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
issues = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
items = [_to_out(i) for i in issues]
|
||||||
|
if page is None:
|
||||||
|
return items
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return DataQualityIssuePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
|
||||||
|
def update_issue_work_queue(
|
||||||
|
body: BulkDataQualityWorkRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> BulkDataQualityWorkResult:
|
||||||
|
refs = list(dict.fromkeys(body.issue_refs))
|
||||||
|
if (
|
||||||
|
body.assigned_to_ref is None
|
||||||
|
and not body.clear_assignment
|
||||||
|
and body.due_at is None
|
||||||
|
and not body.clear_due_at
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=422, detail="No work queue change was requested")
|
||||||
|
if body.assigned_to_ref is not None and body.clear_assignment:
|
||||||
|
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
|
||||||
|
if body.due_at is not None and body.clear_due_at:
|
||||||
|
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
|
||||||
|
if body.due_at is not None and body.due_at.tzinfo is None:
|
||||||
|
raise HTTPException(status_code=422, detail="Due date must include a timezone")
|
||||||
|
|
||||||
|
assignee = None
|
||||||
|
if body.assigned_to_ref is not None:
|
||||||
|
assignee = db.scalar(
|
||||||
|
select(User).where(
|
||||||
|
User.public_ref == body.assigned_to_ref,
|
||||||
|
User.active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if assignee is None:
|
||||||
|
raise HTTPException(status_code=422, detail="Active assignee not found")
|
||||||
|
|
||||||
|
issues = list(
|
||||||
|
db.scalars(
|
||||||
|
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
if len(issues) != len(refs):
|
||||||
|
found = {issue.public_ref for issue in issues}
|
||||||
|
missing = next(ref for ref in refs if ref not in found)
|
||||||
|
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
|
||||||
|
|
||||||
|
for issue in issues:
|
||||||
|
if issue.status != "open":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Data quality issue {issue.public_ref} is not open",
|
||||||
|
)
|
||||||
|
before = {
|
||||||
|
"assigned_to_ref": issue.assigned_to_user.public_ref
|
||||||
|
if issue.assigned_to_user
|
||||||
|
else None,
|
||||||
|
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||||
|
}
|
||||||
|
if body.assigned_to_ref is not None:
|
||||||
|
issue.assigned_to_user = assignee
|
||||||
|
elif body.clear_assignment:
|
||||||
|
issue.assigned_to_user = None
|
||||||
|
if body.due_at is not None:
|
||||||
|
issue.due_at = body.due_at
|
||||||
|
elif body.clear_due_at:
|
||||||
|
issue.due_at = None
|
||||||
|
after = {
|
||||||
|
"assigned_to_ref": assignee.public_ref
|
||||||
|
if body.assigned_to_ref is not None and assignee
|
||||||
|
else (None if body.clear_assignment else before["assigned_to_ref"]),
|
||||||
|
"due_at": issue.due_at.isoformat() if issue.due_at else None,
|
||||||
|
}
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="data_quality_work_updated",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
for issue in issues:
|
||||||
|
db.refresh(issue)
|
||||||
|
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
|
||||||
|
|
||||||
|
|
||||||
|
# Every public reference in this system carries its entity type in its own prefix
|
||||||
|
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
|
||||||
|
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
|
||||||
|
# bookings, not vehicles, and an inline odometer_regression issue's related refs mix a
|
||||||
|
# booking and an inspection ref in the same list.
|
||||||
|
_PREFIX_TO_TYPE = {
|
||||||
|
"CUS-": "customer",
|
||||||
|
"MO-": "vehicle",
|
||||||
|
"BK-": "booking",
|
||||||
|
"INSP-": "inspection",
|
||||||
|
"MAINT-": "maintenance",
|
||||||
|
"MNT-": "maintenance",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _entity_type_for_ref(ref: str) -> str | None:
|
||||||
|
for prefix, entity_type in _PREFIX_TO_TYPE.items():
|
||||||
|
if ref.startswith(prefix):
|
||||||
|
return entity_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
|
||||||
|
if entity_type == "customer":
|
||||||
|
customer = db.scalar(select(Customer).where(Customer.public_ref == ref))
|
||||||
|
if customer is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"entity_type": "customer",
|
||||||
|
"public_ref": customer.public_ref,
|
||||||
|
"first_name": customer.first_name,
|
||||||
|
"last_name": customer.last_name,
|
||||||
|
"email": customer.email,
|
||||||
|
"phone": customer.phone,
|
||||||
|
"postal_code": customer.postal_code,
|
||||||
|
"city": customer.city,
|
||||||
|
}
|
||||||
|
if entity_type == "vehicle":
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == ref))
|
||||||
|
if vehicle is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"entity_type": "vehicle",
|
||||||
|
"public_ref": vehicle.public_ref,
|
||||||
|
"registration_number": vehicle.registration_number,
|
||||||
|
"make": vehicle.make,
|
||||||
|
"model": vehicle.model,
|
||||||
|
"location": vehicle.location,
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
}
|
||||||
|
if entity_type == "booking":
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == ref))
|
||||||
|
if booking is None:
|
||||||
|
return None
|
||||||
|
vehicle = db.get(Vehicle, booking.vehicle_id)
|
||||||
|
customer = db.get(Customer, booking.customer_id)
|
||||||
|
return {
|
||||||
|
"entity_type": "booking",
|
||||||
|
"public_ref": booking.public_ref,
|
||||||
|
"status": booking.status,
|
||||||
|
"starts_at": booking.starts_at.isoformat(),
|
||||||
|
"ends_at": booking.ends_at.isoformat(),
|
||||||
|
"vehicle_ref": vehicle.public_ref if vehicle else None,
|
||||||
|
"customer_ref": customer.public_ref if customer else None,
|
||||||
|
"end_odometer_km": booking.end_odometer_km,
|
||||||
|
}
|
||||||
|
if entity_type == "inspection":
|
||||||
|
inspection = db.scalar(select(Inspection).where(Inspection.public_ref == ref))
|
||||||
|
if inspection is None:
|
||||||
|
return None
|
||||||
|
booking = db.get(Booking, inspection.booking_id)
|
||||||
|
return {
|
||||||
|
"entity_type": "inspection",
|
||||||
|
"public_ref": inspection.public_ref,
|
||||||
|
"type": inspection.type,
|
||||||
|
"odometer_km": inspection.odometer_km,
|
||||||
|
"completed_at": inspection.completed_at.isoformat(),
|
||||||
|
"booking_ref": booking.public_ref if booking else None,
|
||||||
|
}
|
||||||
|
if entity_type == "maintenance":
|
||||||
|
record = db.scalar(select(MaintenanceRecord).where(MaintenanceRecord.public_ref == ref))
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"entity_type": "maintenance",
|
||||||
|
"public_ref": record.public_ref,
|
||||||
|
"odometer_km": record.odometer_km,
|
||||||
|
"occurred_at": record.occurred_at.isoformat(),
|
||||||
|
"category": record.category,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/issues/{public_ref}", response_model=DataQualityIssueDetailOut)
|
||||||
|
def get_issue(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueDetailOut:
|
||||||
|
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
|
||||||
|
if issue is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Data quality issue not found")
|
||||||
|
base = _to_out(issue)
|
||||||
|
related_refs = issue.evidence_json.get("related_refs", [])
|
||||||
|
related_snapshots = []
|
||||||
|
for ref in related_refs:
|
||||||
|
entity_type = _entity_type_for_ref(ref)
|
||||||
|
if entity_type is None:
|
||||||
|
continue
|
||||||
|
snap = _snapshot(entity_type, ref, db)
|
||||||
|
if snap is not None:
|
||||||
|
related_snapshots.append(snap)
|
||||||
|
return DataQualityIssueDetailOut(
|
||||||
|
**base.model_dump(),
|
||||||
|
entity_snapshot=_snapshot(issue.entity_type, base.entity_ref, db),
|
||||||
|
related_snapshots=related_snapshots,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/defer", response_model=DataQualityIssueOut)
|
||||||
|
def defer(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = defer_issue(db, public_ref, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/reject", response_model=DataQualityIssueOut)
|
||||||
|
def reject(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = reject_issue(db, public_ref, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/merge-customers", response_model=MergeCustomersResult)
|
||||||
|
def merge(
|
||||||
|
public_ref: str,
|
||||||
|
body: MergeCustomersRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> MergeCustomersResult:
|
||||||
|
result = merge_customers(db, public_ref, body.survivor_ref, body.field_overrides, user)
|
||||||
|
return MergeCustomersResult(**result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/provide-fields", response_model=DataQualityIssueOut)
|
||||||
|
def provide_fields(
|
||||||
|
public_ref: str,
|
||||||
|
body: ProvideFieldsRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = provide_missing_fields(db, public_ref, body.fields, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
|
||||||
|
def resolve_odometer(
|
||||||
|
public_ref: str,
|
||||||
|
body: ResolveOdometerRegressionRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = resolve_odometer_regression(db, public_ref, body, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/resolve-overlap", response_model=DataQualityIssueOut)
|
||||||
|
def resolve_overlap(
|
||||||
|
public_ref: str,
|
||||||
|
body: ResolveOverlapRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> DataQualityIssueOut:
|
||||||
|
issue = resolve_booking_overlap(db, public_ref, body.booking_ref, body.note, user)
|
||||||
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
|
||||||
|
def status_recommendation(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> StatusRecommendationOut:
|
||||||
|
"""Non-mutating preview: computes the recommendation without changing anything,
|
||||||
|
resolving no issue and writing no audit event. Safe to call repeatedly."""
|
||||||
|
_issue, _vehicle, recommendation, token = preview_vehicle_status_recommendation(db, public_ref)
|
||||||
|
return StatusRecommendationOut(
|
||||||
|
current_status=recommendation.current_status,
|
||||||
|
recommended_status=recommendation.recommended_status,
|
||||||
|
recommendation_code=recommendation.recommendation_code,
|
||||||
|
safe_to_apply=recommendation.safe_to_apply,
|
||||||
|
manual_review_required=recommendation.manual_review_required,
|
||||||
|
facts=VehicleStatusFactsOut(**recommendation.facts.as_dict()),
|
||||||
|
blocking_reasons=recommendation.blocking_reasons,
|
||||||
|
recommendation_token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
||||||
|
)
|
||||||
|
def apply_status(
|
||||||
|
public_ref: str,
|
||||||
|
body: ApplyRecommendedStatusRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> ApplyRecommendedStatusResult:
|
||||||
|
issue, applied_status, reason_code = apply_recommended_status(
|
||||||
|
db, public_ref, user, body.recommendation_token
|
||||||
|
)
|
||||||
|
return ApplyRecommendedStatusResult(
|
||||||
|
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scan", response_model=ScanResultOut)
|
||||||
|
def scan(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> ScanResultOut:
|
||||||
|
result = run_scan(db, actor_label=user.display_name, actor_type="user")
|
||||||
|
return ScanResultOut(created=result.created)
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset, engine
|
||||||
|
from app.core.security import SessionPayload, create_session_token, read_session_token
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
|
||||||
|
from app.services.sessions import revoke_session
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
|
||||||
|
settings = get_settings()
|
||||||
|
_reset_guard = threading.Lock()
|
||||||
|
_RESET_ADVISORY_LOCK_ID = 706_533_149
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/manifest", response_model=DemoManifestOut)
|
||||||
|
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||||
|
if not settings.mobilityops_demo_mode:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||||
|
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
|
||||||
|
# both need this before any session exists. Nothing here is sensitive — it's the same
|
||||||
|
# honest "what is this demo" summary a logged-in user would see.
|
||||||
|
return build_demo_manifest(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=CurrentUser)
|
||||||
|
def demo_login(
|
||||||
|
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
|
||||||
|
) -> CurrentUser:
|
||||||
|
if not settings.mobilityops_demo_mode:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||||
|
public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP"
|
||||||
|
user = db.scalar(select(User).where(User.public_ref == public_ref))
|
||||||
|
if user is None:
|
||||||
|
raise LookupError("Demo users are missing; run the seed loader first.")
|
||||||
|
|
||||||
|
token = create_session_token(
|
||||||
|
SessionPayload(
|
||||||
|
user_id=str(user.id),
|
||||||
|
public_ref=user.public_ref,
|
||||||
|
role=user.role,
|
||||||
|
display_name=user.display_name,
|
||||||
|
issued_at=int(time.time()),
|
||||||
|
session_id=str(uuid.uuid4()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
settings.session_cookie_name,
|
||||||
|
token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=settings.session_cookie_secure,
|
||||||
|
max_age=settings.session_ttl_seconds,
|
||||||
|
)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=user.id,
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="demo_login",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=body.role)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/session", response_model=CurrentUser)
|
||||||
|
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
|
||||||
|
# Never let the browser (or an intermediary) cache an authentication check — a stale
|
||||||
|
# cached 200 here would keep showing a logged-out browser as authenticated.
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
|
||||||
|
token = request.cookies.get(settings.session_cookie_name)
|
||||||
|
payload = read_session_token(token) if token else None
|
||||||
|
if payload is not None and token is not None:
|
||||||
|
revoke_session(db, token, payload)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_id=uuid.UUID(payload.user_id),
|
||||||
|
actor_label=payload.display_name,
|
||||||
|
action="demo_logout",
|
||||||
|
entity_type="user",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
|
return {"status": "logged_out"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset")
|
||||||
|
def demo_reset(
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> dict:
|
||||||
|
if not settings.mobilityops_demo_mode:
|
||||||
|
# Outside demo mode the reset endpoint must not exist at all: it wipes
|
||||||
|
# operational data and replaces it with synthetic records.
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
|
||||||
|
if not settings.demo_allow_reset:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Demo reset is disabled on this deployment.",
|
||||||
|
)
|
||||||
|
if not _reset_guard.acquire(blocking=False):
|
||||||
|
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||||
|
replica_lock_connection = None
|
||||||
|
try:
|
||||||
|
# A session-level lock on its own connection rejects another replica immediately;
|
||||||
|
# the main DB session can then safely end its auth read transaction and wait on
|
||||||
|
# the normal shared/exclusive data barrier without releasing this replica guard.
|
||||||
|
replica_lock_connection = engine.connect()
|
||||||
|
locked = replica_lock_connection.scalar(
|
||||||
|
select(func.pg_try_advisory_lock(_RESET_ADVISORY_LOCK_ID))
|
||||||
|
)
|
||||||
|
if not locked:
|
||||||
|
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||||
|
begin_exclusive_demo_reset(db)
|
||||||
|
# The audit timestamp is shared by every replica. A process-local monotonic
|
||||||
|
# timestamp cannot protect a multi-replica deployment.
|
||||||
|
last_reset_at = db.scalar(
|
||||||
|
select(AuditEvent.occurred_at)
|
||||||
|
.where(AuditEvent.action == "demo_reset")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
elapsed = (datetime.now(UTC) - last_reset_at).total_seconds() if last_reset_at else None
|
||||||
|
if elapsed is not None and elapsed < settings.demo_reset_cooldown_seconds:
|
||||||
|
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
result = reset_and_seed(db, preserve_integration_telemetry=True, commit=False)
|
||||||
|
integrity = scenario_integrity_report(db)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="demo_reset",
|
||||||
|
entity_type="system",
|
||||||
|
metadata={
|
||||||
|
"counts": result.counts,
|
||||||
|
"anchor_date": result.anchor_date.isoformat(),
|
||||||
|
"scenario_integrity": integrity,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
end_exclusive_demo_reset(db)
|
||||||
|
finally:
|
||||||
|
if replica_lock_connection is not None:
|
||||||
|
try:
|
||||||
|
replica_lock_connection.scalar(
|
||||||
|
select(func.pg_advisory_unlock(_RESET_ADVISORY_LOCK_ID))
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
replica_lock_connection.close()
|
||||||
|
_reset_guard.release()
|
||||||
|
response.delete_cookie(settings.session_cookie_name)
|
||||||
|
return {
|
||||||
|
"status": "reset",
|
||||||
|
"counts": result.counts,
|
||||||
|
"anchor_date": result.anchor_date.isoformat(),
|
||||||
|
"seeded_at": result.seeded_at.isoformat(),
|
||||||
|
"scenario_integrity": integrity,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.schemas import CurrentUser, IntegrationStatusOut
|
||||||
|
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=IntegrationStatusOut)
|
||||||
|
def integration_status(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> IntegrationStatusOut:
|
||||||
|
return IntegrationStatusOut(
|
||||||
|
n8n=derive_n8n_status(db),
|
||||||
|
mcp_hub=derive_mcp_hub_status(db),
|
||||||
|
)
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.schemas import (
|
||||||
|
N8nHeartbeatIn,
|
||||||
|
N8nHeartbeatResult,
|
||||||
|
ProcedureDocumentOut,
|
||||||
|
ProcedureListOut,
|
||||||
|
ProcedureSyncResultIn,
|
||||||
|
ProcedureSyncResultResult,
|
||||||
|
ReturnCallbackIn,
|
||||||
|
ScanResultOut,
|
||||||
|
WorkflowErrorReportIn,
|
||||||
|
WorkflowErrorReportResult,
|
||||||
|
)
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality import run_scan
|
||||||
|
from app.services.knowledge.procedures import iter_procedure_documents
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_CANONICAL_WORKFLOW_NAMES = frozenset(
|
||||||
|
{
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_service_token(service_token: str) -> None:
|
||||||
|
# Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched.
|
||||||
|
if not hmac.compare_digest(
|
||||||
|
service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8")
|
||||||
|
):
|
||||||
|
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_idempotency_key(db: Session, namespace: str, key: str) -> None:
|
||||||
|
"""Serialize callback check+insert by a stable, transaction-scoped key."""
|
||||||
|
digest = hashlib.sha256(f"{namespace}:{key}".encode()).digest()
|
||||||
|
lock_id = int.from_bytes(digest[:8], byteorder="big", signed=True)
|
||||||
|
db.scalar(select(func.pg_advisory_xact_lock(lock_id)))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
|
||||||
|
def workflow_heartbeat(
|
||||||
|
body: N8nHeartbeatIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> N8nHeartbeatResult:
|
||||||
|
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
|
||||||
|
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
|
||||||
|
_lock_idempotency_key(db, "n8n_workflow_heartbeat", f"{body.execution_id}:{body.status}")
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_heartbeat",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
AuditEvent.after_json["status"].astext == body.status,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n workflow heartbeat",
|
||||||
|
action="n8n_workflow_heartbeat",
|
||||||
|
entity_type="automation",
|
||||||
|
after={
|
||||||
|
"workflow_id": body.workflow_id,
|
||||||
|
"workflow_name": body.workflow_name,
|
||||||
|
"status": body.status,
|
||||||
|
},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return N8nHeartbeatResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/return-callback")
|
||||||
|
def return_callback(
|
||||||
|
body: ReturnCallbackIn,
|
||||||
|
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
_require_service_token(service_token)
|
||||||
|
|
||||||
|
try:
|
||||||
|
event_id = uuid.UUID(idempotency_key)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_IDEMPOTENCY_KEY", "Idempotency-Key must be the event's UUID.", status_code=422
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update())
|
||||||
|
if event is None:
|
||||||
|
raise AppError("EVENT_NOT_FOUND", "No outbox event matches this event ID.", status_code=404)
|
||||||
|
if body.event_id != event_id:
|
||||||
|
raise AppError(
|
||||||
|
"CALLBACK_EVENT_MISMATCH",
|
||||||
|
"Callback event_id does not match Idempotency-Key.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
expected_correlation_id = uuid.UUID(str(event.payload_json["correlation_id"]))
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_EVENT_CORRELATION",
|
||||||
|
"The stored outbox event has no valid correlation ID.",
|
||||||
|
status_code=409,
|
||||||
|
) from exc
|
||||||
|
if body.correlation_id != expected_correlation_id:
|
||||||
|
raise AppError(
|
||||||
|
"CALLBACK_CORRELATION_MISMATCH",
|
||||||
|
"Callback correlation_id does not match the outbox event.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Idempotent by event ID: n8n or our own dispatcher may redeliver the same event
|
||||||
|
# (e.g. a lost response after a timeout), so this callback must not double-record.
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_return_followup_recorded",
|
||||||
|
AuditEvent.metadata_json["event_id"].astext == str(event_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n",
|
||||||
|
action="n8n_return_followup_recorded",
|
||||||
|
entity_type="booking",
|
||||||
|
correlation_id=expected_correlation_id,
|
||||||
|
after={"follow_up": body.follow_up, "summary": body.summary},
|
||||||
|
metadata={"event_id": str(event_id)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "recorded",
|
||||||
|
"event_id": str(event_id),
|
||||||
|
"occurred_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scheduled-scan", response_model=ScanResultOut)
|
||||||
|
def scheduled_scan(
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ScanResultOut:
|
||||||
|
"""Triggered by the scheduled n8n quality-scan workflow. Narrow, read-mostly, and
|
||||||
|
safe to call repeatedly: run_scan() only ever creates an issue for a condition that
|
||||||
|
doesn't already have one open, so a duplicate or overlapping trigger does no
|
||||||
|
duplicate domain work -- it just reports zero new issues for anything already known."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
|
||||||
|
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||||
|
return ScanResultOut(created=result.created)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
|
||||||
|
def workflow_error(
|
||||||
|
body: WorkflowErrorReportIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> WorkflowErrorReportResult:
|
||||||
|
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
|
||||||
|
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
|
||||||
|
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
|
||||||
|
error report (e.g. after a timed-out response), so this must not double-record."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
_lock_idempotency_key(db, "n8n_workflow_failure", body.execution_id)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n error handler",
|
||||||
|
action="n8n_workflow_failure_registered",
|
||||||
|
entity_type="automation",
|
||||||
|
correlation_id=body.correlation_id,
|
||||||
|
after={
|
||||||
|
"workflow_id": body.workflow_id,
|
||||||
|
"workflow_name": body.workflow_name,
|
||||||
|
"error_category": body.error_category,
|
||||||
|
"error_summary": body.error_summary,
|
||||||
|
"trigger_context": body.trigger_context,
|
||||||
|
"attempt": body.attempt,
|
||||||
|
"retry_action": body.retry_action,
|
||||||
|
"failed_at": body.failed_at.isoformat(),
|
||||||
|
},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return WorkflowErrorReportResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/procedures", response_model=ProcedureListOut)
|
||||||
|
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
|
||||||
|
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
|
||||||
|
Markdown file Fleet Ops ships, across every supported language, with a stable
|
||||||
|
per-document id (source_id) and a content hash so the caller can detect changes
|
||||||
|
without re-fetching content it already has."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
|
||||||
|
documents = [
|
||||||
|
ProcedureDocumentOut(
|
||||||
|
id=doc.source_id,
|
||||||
|
language=doc.language,
|
||||||
|
document_id=doc.document_id,
|
||||||
|
title=doc.title,
|
||||||
|
version=doc.version,
|
||||||
|
content=doc.content,
|
||||||
|
content_hash=doc.content_hash,
|
||||||
|
)
|
||||||
|
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
|
||||||
|
]
|
||||||
|
return ProcedureListOut(documents=documents)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
|
||||||
|
def procedures_sync_result(
|
||||||
|
body: ProcedureSyncResultIn,
|
||||||
|
service_token: str = Header(..., alias="X-Service-Token"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> ProcedureSyncResultResult:
|
||||||
|
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
|
||||||
|
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
|
||||||
|
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
|
||||||
|
_require_service_token(service_token)
|
||||||
|
_lock_idempotency_key(db, "n8n_procedure_sync", body.execution_id)
|
||||||
|
|
||||||
|
already_recorded = (
|
||||||
|
db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "n8n_procedures_synced",
|
||||||
|
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if not already_recorded:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
actor_label="n8n procedure sync",
|
||||||
|
action="n8n_procedures_synced",
|
||||||
|
entity_type="automation",
|
||||||
|
after={"synced": body.synced, "failed": body.failed},
|
||||||
|
metadata={"execution_id": body.execution_id},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return ProcedureSyncResultResult(
|
||||||
|
status="already_registered" if already_recorded else "registered",
|
||||||
|
execution_id=body.execution_id,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.ratelimit import SlidingWindowLimiter
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.schemas import CurrentUser
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
|
||||||
|
settings = get_settings()
|
||||||
|
_question_limiter = (
|
||||||
|
SlidingWindowLimiter(
|
||||||
|
max_requests=settings.knowledge_max_requests,
|
||||||
|
window_seconds=settings.knowledge_rate_limit_window_seconds,
|
||||||
|
)
|
||||||
|
if settings.knowledge_max_requests > 0
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
|
||||||
|
|
||||||
|
|
||||||
|
class AskQuestionRequest(BaseModel):
|
||||||
|
question: str = Field(min_length=3, max_length=1000)
|
||||||
|
language: SupportedLanguage = "en-GB"
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeFeedbackRequest(BaseModel):
|
||||||
|
correlation_id: uuid.UUID
|
||||||
|
helpful: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/questions", response_model=GroundedAnswer)
|
||||||
|
def ask_question(
|
||||||
|
body: AskQuestionRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> GroundedAnswer:
|
||||||
|
if _question_limiter is not None:
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
client_ip = (
|
||||||
|
forwarded.split(",")[-1].strip()
|
||||||
|
if forwarded
|
||||||
|
else request.client.host
|
||||||
|
if request.client
|
||||||
|
else "unknown"
|
||||||
|
)
|
||||||
|
token = request.cookies.get(settings.session_cookie_name, "")
|
||||||
|
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
retry_after = max(
|
||||||
|
_question_limiter.consume(f"ip:{client_ip}"),
|
||||||
|
_question_limiter.consume(f"session:{session_key}"),
|
||||||
|
)
|
||||||
|
if retry_after:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="Too many knowledge questions. Try again later.",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
correlation_id = str(uuid.uuid4())
|
||||||
|
provider = get_knowledge_provider()
|
||||||
|
answer = provider.ask(body.question, correlation_id, body.language)
|
||||||
|
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="knowledge_question_asked",
|
||||||
|
entity_type="knowledge",
|
||||||
|
correlation_id=uuid.UUID(correlation_id),
|
||||||
|
metadata={
|
||||||
|
"evidence_state": answer.evidence_state,
|
||||||
|
"provider": answer.provider,
|
||||||
|
"source_ids": [s.document_id for s in answer.sources],
|
||||||
|
"question_length": len(body.question),
|
||||||
|
"language": body.language,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return answer
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/feedback")
|
||||||
|
def record_feedback(
|
||||||
|
body: KnowledgeFeedbackRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
question_event = db.scalar(
|
||||||
|
select(AuditEvent.id).where(
|
||||||
|
AuditEvent.action == "knowledge_question_asked",
|
||||||
|
AuditEvent.correlation_id == body.correlation_id,
|
||||||
|
AuditEvent.actor_label == user.display_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if question_event is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Knowledge exchange not found")
|
||||||
|
|
||||||
|
existing = db.scalar(
|
||||||
|
select(AuditEvent).where(
|
||||||
|
AuditEvent.action == "knowledge_feedback_recorded",
|
||||||
|
AuditEvent.correlation_id == body.correlation_id,
|
||||||
|
AuditEvent.actor_label == user.display_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
existing.metadata_json = {"helpful": body.helpful}
|
||||||
|
else:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="knowledge_feedback_recorded",
|
||||||
|
entity_type="knowledge",
|
||||||
|
correlation_id=body.correlation_id,
|
||||||
|
metadata={"helpful": body.helpful},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"status": "recorded"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", response_model=KnowledgeHealth)
|
||||||
|
def knowledge_status(
|
||||||
|
language: SupportedLanguage = "en-GB",
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> KnowledgeHealth:
|
||||||
|
health = get_knowledge_provider().health(language)
|
||||||
|
if health.provider != "ragcore":
|
||||||
|
return health
|
||||||
|
|
||||||
|
latest_sync = db.scalar(
|
||||||
|
select(AuditEvent)
|
||||||
|
.where(AuditEvent.action == "n8n_procedures_synced")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if latest_sync is None:
|
||||||
|
return health
|
||||||
|
|
||||||
|
reported = latest_sync.after_json or {}
|
||||||
|
synced = reported.get("synced")
|
||||||
|
failed = reported.get("failed")
|
||||||
|
return health.model_copy(
|
||||||
|
update={
|
||||||
|
"reported_synced_document_count": synced if isinstance(synced, int) else None,
|
||||||
|
"reported_failed_document_count": failed if isinstance(failed, int) else None,
|
||||||
|
"last_sync_at": latest_sync.occurred_at,
|
||||||
|
# A persisted sync callback is useful additional provenance, but must not
|
||||||
|
# downgrade stronger provider-side verification to merely "reported".
|
||||||
|
"statistics_state": (
|
||||||
|
health.statistics_state
|
||||||
|
if health.statistics_state == "verified"
|
||||||
|
else "sync_reported"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, Query, Response
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import McpClientContext, get_db, require_mcp_service_token
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import (
|
||||||
|
AttentionVehicleOut,
|
||||||
|
McpKnowledgeSearchRequest,
|
||||||
|
McpVehicleDetailOut,
|
||||||
|
OperationsSummaryOut,
|
||||||
|
)
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.knowledge import GroundedAnswer, get_knowledge_provider
|
||||||
|
from app.services.operations import compute_metrics, list_attention_vehicles
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def get_correlation_id(
|
||||||
|
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
|
||||||
|
) -> str:
|
||||||
|
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
|
||||||
|
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
|
||||||
|
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
|
||||||
|
if x_correlation_id:
|
||||||
|
try:
|
||||||
|
return str(uuid.UUID(x_correlation_id))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _audit_service_request(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
reported_client_id: str,
|
||||||
|
tool: str,
|
||||||
|
status_label: str,
|
||||||
|
correlation_id: str,
|
||||||
|
metadata: dict[str, object] | None = None,
|
||||||
|
) -> None:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="service",
|
||||||
|
# The shared service token authenticates the Hub, not the caller identity that
|
||||||
|
# the Hub reports in a header. Keep attribution authoritative and retain the
|
||||||
|
# reported value only as explicitly non-authenticated diagnostic metadata.
|
||||||
|
actor_label="itworx-mcp-hub",
|
||||||
|
action="mcp_tool_request",
|
||||||
|
entity_type="mcp_tool",
|
||||||
|
correlation_id=uuid.UUID(correlation_id),
|
||||||
|
metadata={
|
||||||
|
"tool": tool,
|
||||||
|
"status": status_label,
|
||||||
|
"reported_client_id": reported_client_id,
|
||||||
|
**(metadata or {}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_trace_headers(response: Response, correlation_id: str, tenant: str) -> None:
|
||||||
|
response.headers["X-Correlation-Id"] = correlation_id
|
||||||
|
response.headers["X-Tenant-Id"] = tenant
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operations-summary", response_model=OperationsSummaryOut)
|
||||||
|
def operations_summary(
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
client: McpClientContext = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
|
) -> OperationsSummaryOut:
|
||||||
|
metrics = compute_metrics(db)
|
||||||
|
_set_trace_headers(response, correlation_id, client.tenant)
|
||||||
|
_audit_service_request(
|
||||||
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_get_operations_summary",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
return OperationsSummaryOut(tenant=client.tenant, metrics=metrics)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/attention-vehicles", response_model=list[AttentionVehicleOut])
|
||||||
|
def attention_vehicles(
|
||||||
|
response: Response,
|
||||||
|
minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"),
|
||||||
|
date_filter: date | None = Query(default=None, alias="date"),
|
||||||
|
limit: int = Query(default=20, ge=1, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
client: McpClientContext = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
|
) -> list[AttentionVehicleOut]:
|
||||||
|
results = list_attention_vehicles(
|
||||||
|
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
|
||||||
|
)
|
||||||
|
_set_trace_headers(response, correlation_id, client.tenant)
|
||||||
|
_audit_service_request(
|
||||||
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_list_attention_vehicles",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
return [AttentionVehicleOut(**r) for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut)
|
||||||
|
def vehicle_details(
|
||||||
|
vehicle_ref: str,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
client: McpClientContext = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
|
) -> McpVehicleDetailOut:
|
||||||
|
_set_trace_headers(response, correlation_id, client.tenant)
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
|
if vehicle is None:
|
||||||
|
_audit_service_request(
|
||||||
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_get_vehicle_details",
|
||||||
|
status_label="not_found",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
|
||||||
|
|
||||||
|
open_issue_count = len(
|
||||||
|
db.scalars(
|
||||||
|
select(DataQualityIssue.id).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
current_booking = db.scalar(
|
||||||
|
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
||||||
|
)
|
||||||
|
|
||||||
|
_audit_service_request(
|
||||||
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_get_vehicle_details",
|
||||||
|
status_label="ok",
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
return McpVehicleDetailOut(
|
||||||
|
public_ref=vehicle.public_ref,
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
model_year=vehicle.model_year,
|
||||||
|
location=vehicle.location,
|
||||||
|
operational_status=vehicle.operational_status,
|
||||||
|
odometer_km=vehicle.odometer_km,
|
||||||
|
next_service_km=vehicle.next_service_km,
|
||||||
|
open_quality_issue_count=open_issue_count,
|
||||||
|
current_booking_ref=current_booking.public_ref if current_booking else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/search-knowledge", response_model=GroundedAnswer)
|
||||||
|
def search_knowledge(
|
||||||
|
body: McpKnowledgeSearchRequest,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
client: McpClientContext = Depends(require_mcp_service_token),
|
||||||
|
correlation_id: str = Depends(get_correlation_id),
|
||||||
|
) -> GroundedAnswer:
|
||||||
|
provider = get_knowledge_provider()
|
||||||
|
answer = provider.ask(body.question, correlation_id, language=body.locale)
|
||||||
|
source_count_available = len(answer.sources)
|
||||||
|
answer.sources = answer.sources[: body.max_sources]
|
||||||
|
_set_trace_headers(response, correlation_id, client.tenant)
|
||||||
|
response.headers["X-Sources-Available"] = str(source_count_available)
|
||||||
|
response.headers["X-Sources-Returned"] = str(len(answer.sources))
|
||||||
|
_audit_service_request(
|
||||||
|
db,
|
||||||
|
reported_client_id=client.reported_client_id,
|
||||||
|
tool="fleet_ops_search_knowledge",
|
||||||
|
status_label=answer.evidence_state,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
metadata={
|
||||||
|
"tenant": client.tenant,
|
||||||
|
"locale": body.locale,
|
||||||
|
"sources_available": source_count_available,
|
||||||
|
"sources_returned": len(answer.sources),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return answer
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.core.observability import OUTBOX_EVENTS
|
||||||
|
from app.models.outbox import DELIVERY_STATUSES, DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
|
|
||||||
|
router = APIRouter(tags=["observability"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_database_metrics() -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
select(
|
||||||
|
OutboxEvent.delivery_status,
|
||||||
|
(OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE).label("demo"),
|
||||||
|
func.count(),
|
||||||
|
).group_by(OutboxEvent.delivery_status, "demo")
|
||||||
|
).all()
|
||||||
|
OUTBOX_EVENTS.clear()
|
||||||
|
# Keep every time series present even when a state currently contains no rows.
|
||||||
|
# Stable zero-valued series make dashboards and alerts deterministic after resets,
|
||||||
|
# restores and fresh installations instead of turning "zero" into "no data".
|
||||||
|
for scenario in ("synthetic", "operational"):
|
||||||
|
for status in DELIVERY_STATUSES:
|
||||||
|
OUTBOX_EVENTS.labels(scenario, status).set(0)
|
||||||
|
for status, is_demo, count in rows:
|
||||||
|
OUTBOX_EVENTS.labels("synthetic" if is_demo else "operational", str(status)).set(count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics", include_in_schema=False)
|
||||||
|
def metrics(authorization: str | None = Header(default=None)) -> Response:
|
||||||
|
if settings.metrics_bearer_token:
|
||||||
|
supplied = authorization.removeprefix("Bearer ") if authorization else ""
|
||||||
|
if not hmac.compare_digest(supplied, settings.metrics_bearer_token):
|
||||||
|
raise HTTPException(status_code=401, detail="Metrics token required")
|
||||||
|
_refresh_database_metrics()
|
||||||
|
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.schemas import (
|
||||||
|
CurrentUser,
|
||||||
|
CustomerAnonymizeRequest,
|
||||||
|
CustomerAnonymizeResult,
|
||||||
|
PrivacyRetentionOut,
|
||||||
|
)
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _retention_cutoff() -> datetime:
|
||||||
|
return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days)
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_is_eligible(db: Session, customer_id) -> bool:
|
||||||
|
blocking = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Booking)
|
||||||
|
.where(
|
||||||
|
Booking.customer_id == customer_id,
|
||||||
|
or_(
|
||||||
|
Booking.status.in_(("reserved", "active")),
|
||||||
|
Booking.ends_at > _retention_cutoff(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return not blocking
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/retention", response_model=PrivacyRetentionOut)
|
||||||
|
def retention_status(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> PrivacyRetentionOut:
|
||||||
|
customers = db.scalars(select(Customer)).all()
|
||||||
|
return PrivacyRetentionOut(
|
||||||
|
minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days,
|
||||||
|
audit_retention_days=settings.privacy_audit_retention_days,
|
||||||
|
customers_total=len(customers),
|
||||||
|
customers_anonymized=sum(customer.anonymized_at is not None for customer in customers),
|
||||||
|
customers_eligible=sum(
|
||||||
|
customer.anonymized_at is None and _customer_is_eligible(db, customer.id)
|
||||||
|
for customer in customers
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{public_ref}/export")
|
||||||
|
def export_customer_data(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> JSONResponse:
|
||||||
|
customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref))
|
||||||
|
if customer is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
bookings = db.scalars(
|
||||||
|
select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at)
|
||||||
|
).all()
|
||||||
|
payload = {
|
||||||
|
"generated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"customer": {
|
||||||
|
"public_ref": customer.public_ref,
|
||||||
|
"first_name": customer.first_name,
|
||||||
|
"last_name": customer.last_name,
|
||||||
|
"email": customer.email,
|
||||||
|
"phone": customer.phone,
|
||||||
|
"postal_code": customer.postal_code,
|
||||||
|
"city": customer.city,
|
||||||
|
"date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None,
|
||||||
|
"anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None,
|
||||||
|
},
|
||||||
|
"bookings": [
|
||||||
|
{
|
||||||
|
"public_ref": booking.public_ref,
|
||||||
|
"starts_at": booking.starts_at.isoformat(),
|
||||||
|
"ends_at": booking.ends_at.isoformat(),
|
||||||
|
"status": booking.status,
|
||||||
|
}
|
||||||
|
for booking in bookings
|
||||||
|
],
|
||||||
|
}
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="privacy_customer_exported",
|
||||||
|
entity_type="customer",
|
||||||
|
entity_id=customer.id,
|
||||||
|
metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return JSONResponse(
|
||||||
|
payload,
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult)
|
||||||
|
def anonymize_customer(
|
||||||
|
public_ref: str,
|
||||||
|
body: CustomerAnonymizeRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
actor: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> CustomerAnonymizeResult:
|
||||||
|
customer = db.scalar(
|
||||||
|
select(Customer).where(Customer.public_ref == public_ref).with_for_update()
|
||||||
|
)
|
||||||
|
if customer is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
if body.confirmation != public_ref:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail="Customer reference confirmation does not match"
|
||||||
|
)
|
||||||
|
if customer.anonymized_at is not None:
|
||||||
|
return CustomerAnonymizeResult(
|
||||||
|
public_ref=public_ref,
|
||||||
|
anonymized_at=customer.anonymized_at,
|
||||||
|
status="already_anonymized",
|
||||||
|
)
|
||||||
|
if not _customer_is_eligible(db, customer.id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Customer has an active/recent booking within the minimum retention period",
|
||||||
|
)
|
||||||
|
anonymized_at = datetime.now(UTC)
|
||||||
|
customer.first_name = "Anoniem"
|
||||||
|
customer.last_name = public_ref
|
||||||
|
customer.email = None
|
||||||
|
customer.phone = None
|
||||||
|
customer.postal_code = None
|
||||||
|
customer.city = None
|
||||||
|
customer.date_of_birth = None
|
||||||
|
customer.anonymized_at = anonymized_at
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="privacy_customer_anonymized",
|
||||||
|
entity_type="customer",
|
||||||
|
entity_id=customer.id,
|
||||||
|
before={"anonymized": False},
|
||||||
|
after={"anonymized": True},
|
||||||
|
metadata={"reason": body.reason, "customer_ref": public_ref},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return CustomerAnonymizeResult(
|
||||||
|
public_ref=public_ref,
|
||||||
|
anonymized_at=anonymized_at,
|
||||||
|
status="anonymized",
|
||||||
|
)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||||
|
|
||||||
|
# Static application sections. `id` is a stable code matching navigation.json's
|
||||||
|
# `items.*` keys -- the frontend localizes both the section label and its one-line
|
||||||
|
# detail from `id`, so no English prose is sent over the wire (search.sections.<id> in
|
||||||
|
# every locale; see docs/fleet-ops-correction/i18n-inventory.md). Manager-only sections
|
||||||
|
# are filtered by role, mirroring the same nav visibility rule Layout.tsx applies --
|
||||||
|
# search must never surface a destination the current role can't actually reach.
|
||||||
|
_SECTIONS: list[dict] = [
|
||||||
|
{
|
||||||
|
"id": "overview",
|
||||||
|
"link": "/dashboard",
|
||||||
|
# Search terms deliberately span all three supported UI languages (not just
|
||||||
|
# English) so a query never depends on the operator's selected locale.
|
||||||
|
"terms": ["overview", "dashboard", "readiness", "overzicht", "aperçu", "tableau de bord"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fleet",
|
||||||
|
"link": "/vehicles",
|
||||||
|
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bookings",
|
||||||
|
"link": "/bookings",
|
||||||
|
"terms": [
|
||||||
|
"booking",
|
||||||
|
"bookings",
|
||||||
|
"rental",
|
||||||
|
"boeking",
|
||||||
|
"boekingen",
|
||||||
|
"verhuur",
|
||||||
|
"réservation",
|
||||||
|
"réservations",
|
||||||
|
"location",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "quality",
|
||||||
|
"link": "/data-quality",
|
||||||
|
"terms": [
|
||||||
|
"quality",
|
||||||
|
"data quality",
|
||||||
|
"issues",
|
||||||
|
"kwaliteit",
|
||||||
|
"datakwaliteit",
|
||||||
|
"problemen",
|
||||||
|
"qualité",
|
||||||
|
"problèmes",
|
||||||
|
],
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "knowledge",
|
||||||
|
"link": "/knowledge",
|
||||||
|
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "integrations",
|
||||||
|
"link": "/automation",
|
||||||
|
"terms": [
|
||||||
|
"automation",
|
||||||
|
"integrations",
|
||||||
|
"systems",
|
||||||
|
"n8n",
|
||||||
|
"automatisering",
|
||||||
|
"integraties",
|
||||||
|
"systemen",
|
||||||
|
"automatisation",
|
||||||
|
"intégrations",
|
||||||
|
"systèmes",
|
||||||
|
],
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "audit",
|
||||||
|
"link": "/audit",
|
||||||
|
"terms": ["audit", "history", "geschiedenis", "historique"],
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "privacy",
|
||||||
|
"link": "/privacy",
|
||||||
|
"terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"],
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SearchResponse)
|
||||||
|
def search(
|
||||||
|
q: str = Query(min_length=1, max_length=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> SearchResponse:
|
||||||
|
query = q.strip()
|
||||||
|
normalized = query.lower()
|
||||||
|
results: list[SearchResultItem] = []
|
||||||
|
|
||||||
|
for section in _SECTIONS:
|
||||||
|
role = section.get("role")
|
||||||
|
if role and user.role != role:
|
||||||
|
continue
|
||||||
|
terms: list[str] = section["terms"]
|
||||||
|
if any(term in normalized or normalized in term for term in terms):
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="section",
|
||||||
|
label=section["id"],
|
||||||
|
detail_code=section["id"],
|
||||||
|
link=section["link"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
like = f"%{query}%"
|
||||||
|
for v in db.scalars(
|
||||||
|
select(Vehicle)
|
||||||
|
.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(like),
|
||||||
|
Vehicle.make.ilike(like),
|
||||||
|
Vehicle.model.ilike(like),
|
||||||
|
Vehicle.registration_number.ilike(like),
|
||||||
|
Vehicle.location.ilike(like),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Vehicle.public_ref)
|
||||||
|
.limit(5)
|
||||||
|
).all():
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="vehicle",
|
||||||
|
label=v.public_ref,
|
||||||
|
detail_code="vehicleSummary",
|
||||||
|
detail_params={"make": v.make, "model": v.model, "location": v.location},
|
||||||
|
link=f"/vehicles/{v.public_ref}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for b in db.scalars(
|
||||||
|
select(Booking)
|
||||||
|
.where(Booking.public_ref.ilike(like))
|
||||||
|
.order_by(Booking.starts_at.desc())
|
||||||
|
.limit(5)
|
||||||
|
).all():
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="booking",
|
||||||
|
label=b.public_ref,
|
||||||
|
detail_code=b.status,
|
||||||
|
link=f"/bookings/{b.public_ref}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# No customer detail route exists in this proof of concept, so customers are
|
||||||
|
# deliberately never returned here -- there is nowhere useful to send the user.
|
||||||
|
if user.role == "operations_manager":
|
||||||
|
for i in db.scalars(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(DataQualityIssue.public_ref.ilike(like))
|
||||||
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
|
.limit(5)
|
||||||
|
).all():
|
||||||
|
results.append(
|
||||||
|
SearchResultItem(
|
||||||
|
type="data_quality_issue",
|
||||||
|
label=i.public_ref,
|
||||||
|
detail_code=i.rule_type,
|
||||||
|
link=f"/data-quality/{i.public_ref}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return SearchResponse(query=query, results=results[:10])
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(user: User) -> UserOut:
|
||||||
|
return UserOut(
|
||||||
|
public_ref=user.public_ref,
|
||||||
|
email=user.email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
role=user.role, # type: ignore[arg-type]
|
||||||
|
active=user.active,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserOut])
|
||||||
|
def list_users(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_manager: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> list[UserOut]:
|
||||||
|
return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserOut, status_code=201)
|
||||||
|
def create_user(
|
||||||
|
body: CreateUserRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
manager: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> UserOut:
|
||||||
|
email = body.email.strip().lower()
|
||||||
|
if db.scalar(select(User.id).where(User.email == email)) is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="A user with this email already exists")
|
||||||
|
user = User(
|
||||||
|
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(body.password),
|
||||||
|
display_name=body.display_name.strip(),
|
||||||
|
role=body.role,
|
||||||
|
active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.flush()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=manager.display_name,
|
||||||
|
action="user_created",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
after={"public_ref": user.public_ref, "role": user.role, "active": user.active},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{public_ref}", response_model=UserOut)
|
||||||
|
def update_user(
|
||||||
|
public_ref: str,
|
||||||
|
body: UpdateUserRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
manager: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> UserOut:
|
||||||
|
user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update())
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
if user.public_ref == manager.public_ref and body.active is False:
|
||||||
|
raise HTTPException(status_code=409, detail="You cannot deactivate your own account")
|
||||||
|
if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"):
|
||||||
|
raise HTTPException(status_code=409, detail="You cannot remove your own manager role")
|
||||||
|
before = {"display_name": user.display_name, "role": user.role, "active": user.active}
|
||||||
|
if body.display_name is not None:
|
||||||
|
user.display_name = body.display_name.strip()
|
||||||
|
if body.role is not None:
|
||||||
|
user.role = body.role
|
||||||
|
if body.active is not None:
|
||||||
|
user.active = body.active
|
||||||
|
if body.password is not None:
|
||||||
|
user.password_hash = hash_password(body.password)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=manager.display_name,
|
||||||
|
action="user_updated",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
before=before,
|
||||||
|
after={"display_name": user.display_name, "role": user.role, "active": user.active},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(user)
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.customer import Customer
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.maintenance import MaintenanceRecord
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import (
|
||||||
|
BookingSummaryOut,
|
||||||
|
CreateMaintenanceRequest,
|
||||||
|
CurrentUser,
|
||||||
|
DataQualityIssueOut,
|
||||||
|
InspectionOut,
|
||||||
|
MaintenanceOut,
|
||||||
|
ReleaseVehicleRequest,
|
||||||
|
VehicleDetailOut,
|
||||||
|
VehicleOut,
|
||||||
|
VehiclePageOut,
|
||||||
|
)
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality import open_odometer_regression_issue
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||||
|
|
||||||
|
|
||||||
|
def _attention_vehicle_ids(db: Session) -> set:
|
||||||
|
rows = db.scalars(
|
||||||
|
select(DataQualityIssue.entity_id).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return set(rows)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[VehicleOut] | VehiclePageOut)
|
||||||
|
def list_vehicles(
|
||||||
|
status: str | None = Query(default=None),
|
||||||
|
attention_only: bool = Query(default=False),
|
||||||
|
location: str | None = Query(default=None, min_length=1, max_length=120),
|
||||||
|
query: str | None = Query(default=None, min_length=1, max_length=100),
|
||||||
|
page: int | None = Query(default=None, ge=1),
|
||||||
|
page_size: int = Query(default=25, ge=1, le=25),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> list[VehicleOut] | VehiclePageOut:
|
||||||
|
stmt = select(Vehicle).order_by(Vehicle.public_ref)
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(Vehicle.operational_status == status)
|
||||||
|
if location:
|
||||||
|
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
|
||||||
|
if query:
|
||||||
|
term = f"%{query.strip()}%"
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.public_ref.ilike(term),
|
||||||
|
Vehicle.make.ilike(term),
|
||||||
|
Vehicle.model.ilike(term),
|
||||||
|
Vehicle.location.ilike(term),
|
||||||
|
Vehicle.registration_number.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
attention_ids = _attention_vehicle_ids(db)
|
||||||
|
if attention_only:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Vehicle.id.in_(attention_ids),
|
||||||
|
Vehicle.operational_status == "blocked",
|
||||||
|
Vehicle.next_service_km <= Vehicle.odometer_km,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
|
||||||
|
page_number = page or 1
|
||||||
|
vehicles = db.scalars(
|
||||||
|
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
|
||||||
|
).all()
|
||||||
|
vehicle_ids = [vehicle.id for vehicle in vehicles]
|
||||||
|
next_bookings: dict[uuid.UUID, Booking] = {}
|
||||||
|
if vehicle_ids:
|
||||||
|
for booking in db.scalars(
|
||||||
|
select(Booking)
|
||||||
|
.where(
|
||||||
|
Booking.vehicle_id.in_(vehicle_ids),
|
||||||
|
Booking.status == "reserved",
|
||||||
|
Booking.starts_at >= datetime.now(UTC),
|
||||||
|
)
|
||||||
|
.order_by(Booking.starts_at.asc())
|
||||||
|
).all():
|
||||||
|
next_bookings.setdefault(booking.vehicle_id, booking)
|
||||||
|
items = [
|
||||||
|
VehicleOut(
|
||||||
|
public_ref=v.public_ref,
|
||||||
|
make=v.make,
|
||||||
|
model=v.model,
|
||||||
|
model_year=v.model_year,
|
||||||
|
registration_number=v.registration_number,
|
||||||
|
location=v.location,
|
||||||
|
operational_status=v.operational_status,
|
||||||
|
odometer_km=v.odometer_km,
|
||||||
|
next_service_km=v.next_service_km,
|
||||||
|
active=v.active,
|
||||||
|
attention=(
|
||||||
|
v.id in attention_ids
|
||||||
|
or v.operational_status == "blocked"
|
||||||
|
or v.next_service_km <= v.odometer_km
|
||||||
|
),
|
||||||
|
attention_reason=(
|
||||||
|
"blocked_status"
|
||||||
|
if v.operational_status == "blocked"
|
||||||
|
else "service_due"
|
||||||
|
if v.next_service_km <= v.odometer_km
|
||||||
|
else "data_quality"
|
||||||
|
if v.id in attention_ids
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
service_remaining_km=v.next_service_km - v.odometer_km,
|
||||||
|
next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None),
|
||||||
|
next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None),
|
||||||
|
)
|
||||||
|
for v in vehicles
|
||||||
|
]
|
||||||
|
if page is None:
|
||||||
|
return items
|
||||||
|
total_pages = max(1, (total + page_size - 1) // page_size)
|
||||||
|
return VehiclePageOut(
|
||||||
|
items=items,
|
||||||
|
page=min(page_number, total_pages),
|
||||||
|
page_size=page_size,
|
||||||
|
total=total,
|
||||||
|
total_pages=total_pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{public_ref}", response_model=VehicleDetailOut)
|
||||||
|
def get_vehicle(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> VehicleDetailOut:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
|
||||||
|
bookings = db.scalars(
|
||||||
|
select(Booking).where(Booking.vehicle_id == vehicle.id).order_by(Booking.starts_at.desc())
|
||||||
|
).all()
|
||||||
|
customer_ref_by_id = {c.id: c.public_ref for c in db.scalars(select(Customer)).all()}
|
||||||
|
inspections = db.scalars(
|
||||||
|
select(Inspection)
|
||||||
|
.where(Inspection.vehicle_id == vehicle.id)
|
||||||
|
.order_by(Inspection.completed_at.desc())
|
||||||
|
).all()
|
||||||
|
maintenance = db.scalars(
|
||||||
|
select(MaintenanceRecord)
|
||||||
|
.where(MaintenanceRecord.vehicle_id == vehicle.id)
|
||||||
|
.order_by(MaintenanceRecord.occurred_at.desc())
|
||||||
|
).all()
|
||||||
|
# Detailed data-quality evidence is an operations-manager surface. Employees still
|
||||||
|
# get the operational vehicle record they need, but never receive hidden evidence in
|
||||||
|
# the payload merely because the frontend omits the Quality tab.
|
||||||
|
issues = (
|
||||||
|
db.scalars(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
)
|
||||||
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
|
).all()
|
||||||
|
if user.role == "operations_manager"
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
booking_by_id = {b.id: b.public_ref for b in bookings}
|
||||||
|
next_booking = next(
|
||||||
|
(
|
||||||
|
booking
|
||||||
|
for booking in sorted(bookings, key=lambda item: item.starts_at)
|
||||||
|
if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
attention_ids = _attention_vehicle_ids(db)
|
||||||
|
return VehicleDetailOut(
|
||||||
|
public_ref=vehicle.public_ref,
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
model_year=vehicle.model_year,
|
||||||
|
registration_number=vehicle.registration_number,
|
||||||
|
location=vehicle.location,
|
||||||
|
operational_status=vehicle.operational_status,
|
||||||
|
odometer_km=vehicle.odometer_km,
|
||||||
|
next_service_km=vehicle.next_service_km,
|
||||||
|
active=vehicle.active,
|
||||||
|
attention=(
|
||||||
|
vehicle.id in attention_ids
|
||||||
|
or vehicle.operational_status == "blocked"
|
||||||
|
or vehicle.next_service_km <= vehicle.odometer_km
|
||||||
|
),
|
||||||
|
attention_reason=(
|
||||||
|
"blocked_status"
|
||||||
|
if vehicle.operational_status == "blocked"
|
||||||
|
else "service_due"
|
||||||
|
if vehicle.next_service_km <= vehicle.odometer_km
|
||||||
|
else "data_quality"
|
||||||
|
if vehicle.id in attention_ids
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
|
||||||
|
next_booking_ref=next_booking.public_ref if next_booking else None,
|
||||||
|
next_booking_at=next_booking.starts_at if next_booking else None,
|
||||||
|
bookings=[
|
||||||
|
BookingSummaryOut(
|
||||||
|
public_ref=b.public_ref,
|
||||||
|
customer_ref=customer_ref_by_id.get(b.customer_id, ""),
|
||||||
|
vehicle_ref=vehicle.public_ref,
|
||||||
|
starts_at=b.starts_at,
|
||||||
|
ends_at=b.ends_at,
|
||||||
|
status=b.status,
|
||||||
|
)
|
||||||
|
for b in bookings
|
||||||
|
],
|
||||||
|
inspections=[
|
||||||
|
InspectionOut(
|
||||||
|
public_ref=i.public_ref,
|
||||||
|
booking_ref=booking_by_id.get(i.booking_id, ""),
|
||||||
|
type=i.type,
|
||||||
|
fuel_level_percent=i.fuel_level_percent,
|
||||||
|
cleanliness_ok=i.cleanliness_ok,
|
||||||
|
damage_reported=i.damage_reported,
|
||||||
|
technical_warning=i.technical_warning,
|
||||||
|
odometer_km=i.odometer_km,
|
||||||
|
completed_at=i.completed_at,
|
||||||
|
)
|
||||||
|
for i in inspections
|
||||||
|
],
|
||||||
|
maintenance=[
|
||||||
|
MaintenanceOut(
|
||||||
|
public_ref=m.public_ref,
|
||||||
|
occurred_at=m.occurred_at,
|
||||||
|
odometer_km=m.odometer_km,
|
||||||
|
category=m.category,
|
||||||
|
summary=m.summary,
|
||||||
|
)
|
||||||
|
for m in maintenance
|
||||||
|
],
|
||||||
|
quality_issues=[
|
||||||
|
DataQualityIssueOut(
|
||||||
|
public_ref=q.public_ref,
|
||||||
|
rule_type=q.rule_type,
|
||||||
|
entity_type=q.entity_type,
|
||||||
|
entity_ref=vehicle.public_ref,
|
||||||
|
severity=q.severity,
|
||||||
|
status=q.status,
|
||||||
|
evidence=q.evidence_json,
|
||||||
|
detected_at=q.detected_at,
|
||||||
|
due_at=q.due_at,
|
||||||
|
assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None),
|
||||||
|
assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None),
|
||||||
|
overdue=(
|
||||||
|
q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC)
|
||||||
|
),
|
||||||
|
resolved_at=q.resolved_at,
|
||||||
|
)
|
||||||
|
for q in issues
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201)
|
||||||
|
def create_maintenance_record(
|
||||||
|
public_ref: str,
|
||||||
|
body: CreateMaintenanceRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> MaintenanceOut:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
before_vehicle = {
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
"next_service_km": vehicle.next_service_km,
|
||||||
|
}
|
||||||
|
record = MaintenanceRecord(
|
||||||
|
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
occurred_at=body.occurred_at,
|
||||||
|
odometer_km=body.odometer_km,
|
||||||
|
category=body.category,
|
||||||
|
summary=body.summary.strip(),
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
open_odometer_regression_issue(
|
||||||
|
db,
|
||||||
|
vehicle=vehicle,
|
||||||
|
reading_ref=record.public_ref,
|
||||||
|
reading_km=body.odometer_km,
|
||||||
|
canonical_km=vehicle.odometer_km,
|
||||||
|
source_type="maintenance",
|
||||||
|
related_refs=[record.public_ref],
|
||||||
|
actor_label=user.display_name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
|
||||||
|
if body.next_service_km is not None:
|
||||||
|
if body.next_service_km < vehicle.odometer_km:
|
||||||
|
raise HTTPException(status_code=422, detail="Next service must not be below odometer")
|
||||||
|
vehicle.next_service_km = body.next_service_km
|
||||||
|
if body.mark_maintenance:
|
||||||
|
vehicle.operational_status = "maintenance"
|
||||||
|
vehicle.version += 1
|
||||||
|
db.flush()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="maintenance_record_created",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before_vehicle,
|
||||||
|
after={
|
||||||
|
"maintenance_ref": record.public_ref,
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
"next_service_km": vehicle.next_service_km,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return MaintenanceOut(
|
||||||
|
public_ref=record.public_ref,
|
||||||
|
occurred_at=record.occurred_at,
|
||||||
|
odometer_km=record.odometer_km,
|
||||||
|
category=record.category,
|
||||||
|
summary=record.summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{public_ref}/release", response_model=VehicleOut)
|
||||||
|
def release_vehicle(
|
||||||
|
public_ref: str,
|
||||||
|
body: ReleaseVehicleRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> VehicleOut:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||||
|
if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle does not require release")
|
||||||
|
active_booking = db.scalar(
|
||||||
|
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
||||||
|
)
|
||||||
|
open_high_issue = db.scalar(
|
||||||
|
select(DataQualityIssue.id).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.severity == "high",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if active_booking is not None or open_high_issue is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition")
|
||||||
|
before = {"status": vehicle.operational_status}
|
||||||
|
vehicle.operational_status = "available"
|
||||||
|
vehicle.version += 1
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="vehicle_released",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
before=before,
|
||||||
|
after={"status": "available", "reason": body.reason.strip()},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return VehicleOut(
|
||||||
|
public_ref=vehicle.public_ref,
|
||||||
|
make=vehicle.make,
|
||||||
|
model=vehicle.model,
|
||||||
|
model_year=vehicle.model_year,
|
||||||
|
registration_number=vehicle.registration_number,
|
||||||
|
location=vehicle.location,
|
||||||
|
operational_status=vehicle.operational_status,
|
||||||
|
odometer_km=vehicle.odometer_km,
|
||||||
|
next_service_km=vehicle.next_service_km,
|
||||||
|
active=vehicle.active,
|
||||||
|
attention=vehicle.next_service_km <= vehicle.odometer_km,
|
||||||
|
attention_reason=(
|
||||||
|
"service_due" if vehicle.next_service_km <= vehicle.odometer_km else None
|
||||||
|
),
|
||||||
|
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
|
||||||
|
next_booking_ref=None,
|
||||||
|
next_booking_at=None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_operations_manager
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||||
|
from app.schemas import AutomationRunOut, CurrentUser
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/workflows", tags=["workflows"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
||||||
|
return AutomationRunOut(
|
||||||
|
event_id=str(event.event_id),
|
||||||
|
event_type=event.event_type,
|
||||||
|
aggregate_ref=event.payload_json.get("aggregate_ref", ""),
|
||||||
|
status=event.delivery_status,
|
||||||
|
attempts=event.attempts,
|
||||||
|
last_error=event.last_error,
|
||||||
|
last_error_code=event.last_error_code,
|
||||||
|
is_demo_scenario=is_demo_scenario_failure(event),
|
||||||
|
occurred_at=event.occurred_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AutomationRunOut])
|
||||||
|
def list_workflows(
|
||||||
|
status: str | None = Query(default=None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> list[AutomationRunOut]:
|
||||||
|
stmt = select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc())
|
||||||
|
if status:
|
||||||
|
stmt = stmt.where(OutboxEvent.delivery_status == status)
|
||||||
|
events = db.scalars(stmt).all()
|
||||||
|
return [_to_out(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{event_id}/retry", response_model=AutomationRunOut)
|
||||||
|
def retry_workflow(
|
||||||
|
event_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> AutomationRunOut:
|
||||||
|
try:
|
||||||
|
parsed_id = uuid.UUID(event_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AppError("INVALID_EVENT_ID", "event_id must be a UUID.", status_code=422) from exc
|
||||||
|
|
||||||
|
event = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == parsed_id))
|
||||||
|
if event is None:
|
||||||
|
raise AppError("EVENT_NOT_FOUND", "Workflow event not found.", status_code=404)
|
||||||
|
if event.delivery_status != "failed":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_RETRYABLE",
|
||||||
|
f"Event is '{event.delivery_status}', not 'failed'; "
|
||||||
|
"only failed deliveries can be retried.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Captured before the status flips, so the audit records what was actually retried.
|
||||||
|
was_demo_scenario = is_demo_scenario_failure(event)
|
||||||
|
|
||||||
|
event.delivery_status = "pending"
|
||||||
|
event.next_attempt_at = None
|
||||||
|
# The retry itself is real either way: the event goes back on the outbox and the
|
||||||
|
# dispatcher delivers it to the configured n8n webhook like any other. The only
|
||||||
|
# difference recorded here is *what* was retried -- a staged demo failure or a real
|
||||||
|
# one -- so the audit trail never implies a production incident was resolved when a
|
||||||
|
# prop was.
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=user.display_name,
|
||||||
|
action="workflow_retry",
|
||||||
|
entity_type="outbox_event",
|
||||||
|
metadata={
|
||||||
|
"event_id": event_id,
|
||||||
|
"previous_attempts": event.attempts,
|
||||||
|
"demo_scenario": was_demo_scenario,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return _to_out(event)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(prog="app.cli")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
seed_parser = subparsers.add_parser("seed", help="Load the deterministic demo dataset")
|
||||||
|
seed_parser.add_argument(
|
||||||
|
"--reset", action="store_true", help="Clear existing data before loading"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "seed":
|
||||||
|
if not args.reset:
|
||||||
|
raise SystemExit(
|
||||||
|
"Only 'seed --reset' is supported: seeding always rebuilds the demo dataset."
|
||||||
|
)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
result = reset_and_seed(db)
|
||||||
|
for name, count in result.counts.items():
|
||||||
|
print(f"{name}: {count}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Whitespace-only changes.
@@ -0,0 +1,128 @@
|
|||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# The visible product name is fixed and never translated or configured per-deployment --
|
||||||
|
# see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers
|
||||||
|
# (package name, Compose project, database name, repository) intentionally remain
|
||||||
|
# "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title).
|
||||||
|
PRODUCT_NAME = "Fleet Ops"
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
mobilityops_env: str = "development"
|
||||||
|
mobilityops_demo_mode: bool = True
|
||||||
|
database_url: str = "postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops"
|
||||||
|
knowledge_provider: str = "demo"
|
||||||
|
ragcore_base_url: str = "http://ragcore-api:8000"
|
||||||
|
ragcore_tenant: str = "northstar-mobility-demo"
|
||||||
|
ragcore_workspace: str = "mobilityops"
|
||||||
|
ragcore_collection: str = "internal-procedures"
|
||||||
|
ragcore_api_token: str = ""
|
||||||
|
ragcore_space_id: str = ""
|
||||||
|
ragcore_http_timeout_seconds: float = 5.0
|
||||||
|
ragcore_answers_circuit_breaker_seconds: float = 60.0
|
||||||
|
# Search fallback is only labelled grounded above this explicit retrieval threshold.
|
||||||
|
# RAGcore's fused score is reciprocal-rank based (top ranks are ~1/61), so this
|
||||||
|
# accepts only leading results while still rejecting absent and low-ranked evidence.
|
||||||
|
ragcore_min_search_score: float = 0.016
|
||||||
|
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
||||||
|
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
|
||||||
|
n8n_callback_token: str = "replace-me-n8n-callback-token"
|
||||||
|
n8n_dispatch_enabled: bool = True
|
||||||
|
n8n_dispatch_interval_seconds: float = 3.0
|
||||||
|
# The synchronous n8n workflow performs two bounded, retried callbacks before it
|
||||||
|
# acknowledges an event. Keep this above that complete workflow budget, while the
|
||||||
|
# delivery lease remains the wider crash-recovery boundary (enforced by the contract
|
||||||
|
# check in scripts/check-contracts.py).
|
||||||
|
n8n_http_timeout_seconds: float = 15.0
|
||||||
|
n8n_max_attempts: int = 5
|
||||||
|
n8n_delivery_lease_seconds: float = 120.0
|
||||||
|
app_secret: str = "replace-in-production"
|
||||||
|
session_cookie_name: str = "mobilityops_session"
|
||||||
|
session_ttl_seconds: int = 60 * 60 * 8
|
||||||
|
session_cookie_secure: bool = False
|
||||||
|
seed_dir: str = "/app/seed"
|
||||||
|
knowledge_dir: str = "/app/knowledge/procedures"
|
||||||
|
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
|
||||||
|
mcp_hub_registration_enabled: bool = False
|
||||||
|
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
|
||||||
|
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
|
||||||
|
# these are only used for an honest reachability health check, not self-registration.
|
||||||
|
mcp_hub_base_url: str = ""
|
||||||
|
mcp_provider_id: str = "fleet-ops"
|
||||||
|
cors_allow_origins: str = "http://localhost:1228"
|
||||||
|
demo_organization_name: str = "Northstar Mobility"
|
||||||
|
demo_timezone: str = "Europe/Brussels"
|
||||||
|
demo_allow_reset: bool = True
|
||||||
|
demo_reset_cooldown_seconds: int = 60
|
||||||
|
mcp_hub_health_cache_seconds: int = 60
|
||||||
|
initial_admin_email: str = ""
|
||||||
|
initial_admin_password: str = ""
|
||||||
|
initial_admin_display_name: str = "Operations Manager"
|
||||||
|
mobilityops_public_url: str = "http://localhost:1228"
|
||||||
|
oidc_enabled: bool = False
|
||||||
|
oidc_provider_name: str = "Organisatieaccount"
|
||||||
|
oidc_issuer_url: str = ""
|
||||||
|
oidc_client_id: str = ""
|
||||||
|
oidc_client_secret: str = ""
|
||||||
|
oidc_redirect_uri: str = ""
|
||||||
|
oidc_allowed_email_domains: str = ""
|
||||||
|
oidc_auto_provision: bool = True
|
||||||
|
oidc_default_role: str = "rental_employee"
|
||||||
|
log_level: str = "INFO"
|
||||||
|
# Failed password logins per client IP before a temporary 429 (0 disables).
|
||||||
|
login_max_failures: int = 10
|
||||||
|
login_failure_window_seconds: int = 900
|
||||||
|
knowledge_max_requests: int = 30
|
||||||
|
knowledge_rate_limit_window_seconds: int = 60
|
||||||
|
metrics_bearer_token: str = ""
|
||||||
|
privacy_minimum_booking_retention_days: int = 30
|
||||||
|
privacy_audit_retention_days: int = 2555
|
||||||
|
privacy_audit_export_max_rows: int = 10000
|
||||||
|
|
||||||
|
|
||||||
|
# Secrets that guard *inbound* trust (session cookies, service callbacks). Running
|
||||||
|
# production with any of these at their placeholder value means forged sessions or
|
||||||
|
# unauthenticated writes, so startup refuses.
|
||||||
|
INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = (
|
||||||
|
("app_secret", "replace-in-production"),
|
||||||
|
("n8n_callback_token", "replace-me-n8n-callback-token"),
|
||||||
|
("mcp_hub_service_token", "replace-me-mcp-hub-token"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def insecure_default_secrets(settings: "Settings") -> list[str]:
|
||||||
|
"""Return the names of secret settings that still carry their placeholder value.
|
||||||
|
|
||||||
|
MCP routes are always mounted, independently of the Hub reachability-status flag, so
|
||||||
|
their inbound token must always be non-placeholder in production.
|
||||||
|
"""
|
||||||
|
insecure: list[str] = []
|
||||||
|
for name, placeholder in INSECURE_DEFAULT_SECRETS:
|
||||||
|
value = getattr(settings, name)
|
||||||
|
if not value or value == placeholder or value.startswith("replace-me"):
|
||||||
|
insecure.append(name)
|
||||||
|
return insecure
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
settings = Settings()
|
||||||
|
if settings.mobilityops_env.lower() == "production":
|
||||||
|
insecure = insecure_default_secrets(settings)
|
||||||
|
if insecure:
|
||||||
|
# Refuse to boot rather than run production with forgeable session cookies
|
||||||
|
# or guessable service tokens. Development/test/demo keep the defaults.
|
||||||
|
raise RuntimeError(
|
||||||
|
"Refusing to start in production with placeholder secrets: "
|
||||||
|
+ ", ".join(insecure)
|
||||||
|
+ ". Set real values in the environment (see .env.example)."
|
||||||
|
)
|
||||||
|
if not settings.mobilityops_public_url.lower().startswith("https://"):
|
||||||
|
raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.")
|
||||||
|
if not settings.session_cookie_secure:
|
||||||
|
raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.")
|
||||||
|
return settings
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, event, func, select
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
# Every SQLAlchemy transaction participates in a shared database-wide barrier. Normal
|
||||||
|
# reads/writes coexist; the short demo reset takes the exclusive form so it can never
|
||||||
|
# interleave deletes/inserts with an API request, scanner, callback, or dispatcher cycle.
|
||||||
|
DEMO_DATA_BARRIER_LOCK_ID = 5_344_725_149_212_793_901
|
||||||
|
_EXCLUSIVE_RESET_INFO_KEY = "mobilityops_demo_reset_exclusive"
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(Session, "after_begin")
|
||||||
|
def _acquire_demo_data_barrier(session: Session, _transaction, connection) -> None:
|
||||||
|
if connection.dialect.name != "postgresql":
|
||||||
|
return
|
||||||
|
lock = (
|
||||||
|
func.pg_advisory_xact_lock(DEMO_DATA_BARRIER_LOCK_ID)
|
||||||
|
if session.info.get(_EXCLUSIVE_RESET_INFO_KEY)
|
||||||
|
else func.pg_advisory_xact_lock_shared(DEMO_DATA_BARRIER_LOCK_ID)
|
||||||
|
)
|
||||||
|
connection.execute(select(lock))
|
||||||
|
|
||||||
|
|
||||||
|
def begin_exclusive_demo_reset(db: Session) -> None:
|
||||||
|
"""Make the session's next transaction the exclusive side of the reset barrier."""
|
||||||
|
if db.in_transaction():
|
||||||
|
# Auth normally already read the user under a shared barrier. End that read-only
|
||||||
|
# transaction before requesting exclusive; in-place lock upgrades can deadlock.
|
||||||
|
db.rollback()
|
||||||
|
db.info[_EXCLUSIVE_RESET_INFO_KEY] = True
|
||||||
|
|
||||||
|
|
||||||
|
def end_exclusive_demo_reset(db: Session) -> None:
|
||||||
|
db.info.pop(_EXCLUSIVE_RESET_INFO_KEY, None)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
status_code: int = 400,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details or {}
|
||||||
|
self.correlation_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def error_body(code: str, message: str, correlation_id: str, details: dict[str, Any]) -> dict:
|
||||||
|
return {
|
||||||
|
"error": {
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
"details": details,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from prometheus_client import Counter, Gauge, Histogram
|
||||||
|
|
||||||
|
correlation_id_context: ContextVar[str] = ContextVar("correlation_id", default="")
|
||||||
|
|
||||||
|
HTTP_REQUESTS = Counter(
|
||||||
|
"mobilityops_http_requests_total",
|
||||||
|
"Completed MobilityOps HTTP requests.",
|
||||||
|
("method", "route", "status"),
|
||||||
|
)
|
||||||
|
HTTP_DURATION = Histogram(
|
||||||
|
"mobilityops_http_request_duration_seconds",
|
||||||
|
"MobilityOps HTTP request duration.",
|
||||||
|
("method", "route"),
|
||||||
|
buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
|
||||||
|
)
|
||||||
|
HTTP_IN_PROGRESS = Gauge(
|
||||||
|
"mobilityops_http_requests_in_progress",
|
||||||
|
"MobilityOps HTTP requests currently executing.",
|
||||||
|
)
|
||||||
|
OUTBOX_EVENTS = Gauge(
|
||||||
|
"mobilityops_outbox_events",
|
||||||
|
"Persisted outbox events by state and scenario type.",
|
||||||
|
("scenario", "status"),
|
||||||
|
)
|
||||||
|
DATABASE_READY = Gauge(
|
||||||
|
"mobilityops_database_ready",
|
||||||
|
"Whether the canonical PostgreSQL database answered the most recent readiness probe.",
|
||||||
|
)
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS = Counter(
|
||||||
|
"mobilityops_knowledge_provider_requests_total",
|
||||||
|
"RAGcore adapter requests by stage and outcome.",
|
||||||
|
("stage", "outcome"),
|
||||||
|
)
|
||||||
|
KNOWLEDGE_RETRIEVAL_SCORE = Histogram(
|
||||||
|
"mobilityops_knowledge_retrieval_score",
|
||||||
|
"Observed RAGcore fused/rerank retrieval scores.",
|
||||||
|
buckets=(0.005, 0.01, 0.015, 0.016, 0.0162, 0.0164, 0.02, 0.05, 0.1, 0.5, 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"level": record.levelname.lower(),
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
correlation_id = correlation_id_context.get()
|
||||||
|
if correlation_id:
|
||||||
|
payload["correlation_id"] = correlation_id
|
||||||
|
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
|
||||||
|
value = getattr(record, key, None)
|
||||||
|
if value is not None:
|
||||||
|
payload[key] = value
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(payload, separators=(",", ":"), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str) -> None:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers = [handler]
|
||||||
|
root.setLevel(level.upper())
|
||||||
|
|
||||||
|
|
||||||
|
def correlation_id_for(request: Request) -> str:
|
||||||
|
candidate = request.headers.get("X-Correlation-Id", "").strip()
|
||||||
|
try:
|
||||||
|
return str(uuid.UUID(candidate)) if candidate else str(uuid.uuid4())
|
||||||
|
except ValueError:
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
UNMATCHED_ROUTE_LABEL = "<unmatched>"
|
||||||
|
|
||||||
|
|
||||||
|
def route_label(request: Request) -> str:
|
||||||
|
"""Return the route *template* for metrics labels.
|
||||||
|
|
||||||
|
Unmatched paths (404 probes, scanners) must not become their own label value:
|
||||||
|
every distinct URL would otherwise create a new Prometheus time series and the
|
||||||
|
metric cardinality would grow without bound.
|
||||||
|
"""
|
||||||
|
route = request.scope.get("route")
|
||||||
|
path = getattr(route, "path", None)
|
||||||
|
return str(path) if path else UNMATCHED_ROUTE_LABEL
|
||||||
|
|
||||||
|
|
||||||
|
def request_started() -> float:
|
||||||
|
HTTP_IN_PROGRESS.inc()
|
||||||
|
return time.perf_counter()
|
||||||
|
|
||||||
|
|
||||||
|
def request_finished(request: Request, status_code: int, started_at: float) -> float:
|
||||||
|
duration = time.perf_counter() - started_at
|
||||||
|
route = route_label(request)
|
||||||
|
HTTP_REQUESTS.labels(request.method, route, str(status_code)).inc()
|
||||||
|
HTTP_DURATION.labels(request.method, route).observe(duration)
|
||||||
|
HTTP_IN_PROGRESS.dec()
|
||||||
|
return duration
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Small in-process failed-attempt limiter for credential endpoints.
|
||||||
|
|
||||||
|
Fleet Ops runs as a single API process per deployment, so an in-memory sliding window
|
||||||
|
is sufficient to blunt online password guessing (and the scrypt CPU amplification that
|
||||||
|
comes with it) without adding Redis. Only *failed* attempts count, so legitimate users
|
||||||
|
and the automated test suite are never throttled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
|
||||||
|
class FailedAttemptLimiter:
|
||||||
|
def __init__(self, *, max_failures: int, window_seconds: float) -> None:
|
||||||
|
self.max_failures = max_failures
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self._failures: dict[str, deque[float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _prune(self, key: str, now: float) -> deque[float]:
|
||||||
|
bucket = self._failures.setdefault(key, deque())
|
||||||
|
cutoff = now - self.window_seconds
|
||||||
|
while bucket and bucket[0] <= cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
if not bucket:
|
||||||
|
self._failures.pop(key, None)
|
||||||
|
return bucket
|
||||||
|
|
||||||
|
def retry_after_seconds(self, key: str) -> int:
|
||||||
|
"""Return >0 seconds to wait when the key is currently blocked, else 0."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
bucket = self._prune(key, now)
|
||||||
|
if len(bucket) < self.max_failures:
|
||||||
|
return 0
|
||||||
|
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
|
||||||
|
|
||||||
|
def record_failure(self, key: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
self._prune(key, now)
|
||||||
|
self._failures.setdefault(key, deque()).append(now)
|
||||||
|
|
||||||
|
def reset(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._failures.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
class SlidingWindowLimiter:
|
||||||
|
"""Thread-safe request limiter where every accepted request consumes capacity."""
|
||||||
|
|
||||||
|
def __init__(self, *, max_requests: int, window_seconds: float) -> None:
|
||||||
|
self.max_requests = max_requests
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self._requests: dict[str, deque[float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def consume(self, key: str) -> int:
|
||||||
|
"""Record an accepted request, or return the seconds until capacity is available."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
bucket = self._requests.setdefault(key, deque())
|
||||||
|
cutoff = now - self.window_seconds
|
||||||
|
while bucket and bucket[0] <= cutoff:
|
||||||
|
bucket.popleft()
|
||||||
|
if len(bucket) >= self.max_requests:
|
||||||
|
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
|
||||||
|
bucket.append(now)
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SessionPayload:
|
||||||
|
user_id: str
|
||||||
|
public_ref: str
|
||||||
|
role: str
|
||||||
|
display_name: str
|
||||||
|
issued_at: int
|
||||||
|
session_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(data: bytes) -> str:
|
||||||
|
digest = hmac.new(settings.app_secret.encode(), data, hashlib.sha256).digest()
|
||||||
|
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def session_token_hash(token: str) -> str:
|
||||||
|
"""Return a non-reversible identifier safe to persist for token revocation."""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_token(payload: SessionPayload) -> str:
|
||||||
|
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
|
||||||
|
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
|
||||||
|
signature = _sign(encoded_body.encode())
|
||||||
|
return f"{encoded_body}.{signature}"
|
||||||
|
|
||||||
|
|
||||||
|
def read_session_token(token: str) -> SessionPayload | None:
|
||||||
|
try:
|
||||||
|
encoded_body, signature = token.split(".", 1)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
expected = _sign(encoded_body.encode())
|
||||||
|
if not hmac.compare_digest(expected, signature):
|
||||||
|
return None
|
||||||
|
padding = "=" * (-len(encoded_body) % 4)
|
||||||
|
try:
|
||||||
|
body = json.loads(base64.urlsafe_b64decode(encoded_body + padding))
|
||||||
|
except (ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
payload = SessionPayload(**body)
|
||||||
|
if time.time() - payload.issued_at > settings.session_ttl_seconds:
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
salt = os.urandom(16)
|
||||||
|
derived = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32)
|
||||||
|
encoded_salt = base64.urlsafe_b64encode(salt).decode()
|
||||||
|
encoded_hash = base64.urlsafe_b64encode(derived).decode()
|
||||||
|
return f"scrypt$16384$8$1${encoded_salt}${encoded_hash}"
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password: str, encoded: str | None) -> bool:
|
||||||
|
if not encoded:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
algorithm, n, r, p, salt, expected = encoded.split("$")
|
||||||
|
if algorithm != "scrypt":
|
||||||
|
return False
|
||||||
|
derived = hashlib.scrypt(
|
||||||
|
password.encode(),
|
||||||
|
salt=base64.urlsafe_b64decode(salt.encode()),
|
||||||
|
n=int(n),
|
||||||
|
r=int(r),
|
||||||
|
p=int(p),
|
||||||
|
dklen=32,
|
||||||
|
)
|
||||||
|
return hmac.compare_digest(derived, base64.urlsafe_b64decode(expected.encode()))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy import text
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
|
from app.api.routers import (
|
||||||
|
audit,
|
||||||
|
auth,
|
||||||
|
bookings,
|
||||||
|
customers,
|
||||||
|
dashboard,
|
||||||
|
data_quality,
|
||||||
|
demo,
|
||||||
|
integration_status,
|
||||||
|
integrations,
|
||||||
|
knowledge,
|
||||||
|
mcp_integrations,
|
||||||
|
observability,
|
||||||
|
privacy,
|
||||||
|
search,
|
||||||
|
users,
|
||||||
|
vehicles,
|
||||||
|
workflows,
|
||||||
|
)
|
||||||
|
from app.api.routers.auth import bootstrap_initial_admin
|
||||||
|
from app.core.config import PRODUCT_NAME, get_settings
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.core.errors import AppError, error_body
|
||||||
|
from app.core.observability import (
|
||||||
|
DATABASE_READY,
|
||||||
|
configure_logging,
|
||||||
|
correlation_id_context,
|
||||||
|
correlation_id_for,
|
||||||
|
request_finished,
|
||||||
|
request_started,
|
||||||
|
)
|
||||||
|
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings.log_level)
|
||||||
|
request_logger = logging.getLogger("mobilityops.request")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
with SessionLocal() as db:
|
||||||
|
bootstrap_initial_admin(db)
|
||||||
|
start_background_dispatcher()
|
||||||
|
yield
|
||||||
|
stop_background_dispatcher()
|
||||||
|
|
||||||
|
|
||||||
|
production = settings.mobilityops_env.lower() == "production"
|
||||||
|
app = FastAPI(
|
||||||
|
title=f"{PRODUCT_NAME} API",
|
||||||
|
version="0.1.0",
|
||||||
|
description=(
|
||||||
|
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
|
||||||
|
"MobilityOps remains the technical repository and service identifier."
|
||||||
|
),
|
||||||
|
servers=[{"url": "http://localhost:8128"}],
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url=None if production else "/docs",
|
||||||
|
redoc_url=None if production else "/redoc",
|
||||||
|
openapi_url=None if production else "/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=settings.app_secret,
|
||||||
|
session_cookie="mobilityops_oidc_state",
|
||||||
|
max_age=600,
|
||||||
|
same_site="lax",
|
||||||
|
https_only=settings.session_cookie_secure,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_observability(request: Request, call_next):
|
||||||
|
correlation_id = correlation_id_for(request)
|
||||||
|
request.state.correlation_id = correlation_id
|
||||||
|
token = correlation_id_context.set(correlation_id)
|
||||||
|
started_at = request_started()
|
||||||
|
status_code = 500
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
status_code = response.status_code
|
||||||
|
response.headers["X-Correlation-Id"] = correlation_id
|
||||||
|
return response
|
||||||
|
finally:
|
||||||
|
duration = request_finished(request, status_code, started_at)
|
||||||
|
request_logger.info(
|
||||||
|
"request_completed",
|
||||||
|
extra={
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"status_code": status_code,
|
||||||
|
"duration_ms": round(duration * 1000, 2),
|
||||||
|
"client_ip": request.client.host if request.client else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
correlation_id_context.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=error_body(
|
||||||
|
exc.code,
|
||||||
|
exc.message,
|
||||||
|
request.state.correlation_id,
|
||||||
|
exc.details,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(HTTPException)
|
||||||
|
def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=error_body(
|
||||||
|
code=str(exc.status_code),
|
||||||
|
message=str(exc.detail),
|
||||||
|
correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())),
|
||||||
|
details={},
|
||||||
|
),
|
||||||
|
headers=exc.headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok", "service": "mobilityops-api"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/live")
|
||||||
|
def liveness() -> dict[str, str]:
|
||||||
|
"""Process liveness only; external dependencies deliberately do not affect it."""
|
||||||
|
return {"status": "ok", "service": "mobilityops-api"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/ready")
|
||||||
|
def readiness() -> JSONResponse:
|
||||||
|
"""Traffic readiness: the API is useful only while its canonical database responds."""
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("SELECT 1"))
|
||||||
|
except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503
|
||||||
|
DATABASE_READY.set(0)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
|
||||||
|
)
|
||||||
|
DATABASE_READY.set(1)
|
||||||
|
return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/system/status")
|
||||||
|
def system_status() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"service": "mobilityops-api",
|
||||||
|
"environment": settings.mobilityops_env,
|
||||||
|
"demo_mode": settings.mobilityops_demo_mode,
|
||||||
|
"knowledge_provider": settings.knowledge_provider,
|
||||||
|
"oidc_enabled": auth.oidc_status().enabled,
|
||||||
|
"oidc_provider_name": auth.oidc_status().provider_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(demo.router)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(vehicles.router)
|
||||||
|
app.include_router(bookings.router)
|
||||||
|
app.include_router(customers.router)
|
||||||
|
app.include_router(audit.router)
|
||||||
|
app.include_router(data_quality.router)
|
||||||
|
app.include_router(workflows.router)
|
||||||
|
app.include_router(integrations.router)
|
||||||
|
app.include_router(knowledge.router)
|
||||||
|
app.include_router(mcp_integrations.router)
|
||||||
|
app.include_router(search.router)
|
||||||
|
app.include_router(integration_status.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(observability.router)
|
||||||
|
app.include_router(privacy.router)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from app.core.db import Base
|
||||||
|
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.idempotency import IdempotencyRecord
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.maintenance import MaintenanceRecord
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.models.revoked_session import RevokedSession
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"AuditEvent",
|
||||||
|
"Booking",
|
||||||
|
"Customer",
|
||||||
|
"DataQualityIssue",
|
||||||
|
"IdempotencyRecord",
|
||||||
|
"Inspection",
|
||||||
|
"MaintenanceRecord",
|
||||||
|
"OutboxEvent",
|
||||||
|
"RevokedSession",
|
||||||
|
"User",
|
||||||
|
"Vehicle",
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, Index, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
ACTOR_TYPES = ("user", "service", "system")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEvent(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "audit_events"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("actor_type IN ('user','service','system')", name="ck_audit_actor_type"),
|
||||||
|
Index("ix_audit_action_occurred", "action", "occurred_at"),
|
||||||
|
Index("ix_audit_entity", "entity_type", "entity_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
actor_label: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
before_json: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
after_json: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
BOOKING_STATUSES = ("reserved", "active", "returned", "cancelled", "blocked")
|
||||||
|
|
||||||
|
|
||||||
|
class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "bookings"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('reserved','active','returned','cancelled','blocked')",
|
||||||
|
name="ck_bookings_status",
|
||||||
|
),
|
||||||
|
CheckConstraint("ends_at > starts_at", name="ck_bookings_time_window"),
|
||||||
|
CheckConstraint(
|
||||||
|
"start_odometer_km IS NULL OR start_odometer_km >= 0",
|
||||||
|
name="ck_bookings_start_odometer",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"end_odometer_km IS NULL OR end_odometer_km >= 0",
|
||||||
|
name="ck_bookings_end_odometer",
|
||||||
|
),
|
||||||
|
Index("ix_bookings_vehicle_status_window", "vehicle_id", "status", "starts_at", "ends_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
customer_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
|
||||||
|
)
|
||||||
|
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
start_odometer_km: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
end_odometer_km: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "customers"
|
||||||
|
|
||||||
|
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
first_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
last_name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(200))
|
||||||
|
phone: Mapped[str | None] = mapped_column(String(40))
|
||||||
|
postal_code: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
city: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
date_of_birth: Mapped[date | None] = mapped_column(Date)
|
||||||
|
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("customers.id")
|
||||||
|
)
|
||||||
|
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
RULE_TYPES = (
|
||||||
|
"possible_duplicate_customer",
|
||||||
|
"missing_required_field",
|
||||||
|
"odometer_regression",
|
||||||
|
"booking_overlap",
|
||||||
|
"vehicle_status_conflict",
|
||||||
|
)
|
||||||
|
SEVERITIES = ("low", "medium", "high")
|
||||||
|
ISSUE_STATUSES = ("open", "deferred", "resolved", "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "data_quality_issues"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"rule_type IN ('possible_duplicate_customer','missing_required_field',"
|
||||||
|
"'odometer_regression','booking_overlap','vehicle_status_conflict')",
|
||||||
|
name="ck_data_quality_rule_type",
|
||||||
|
),
|
||||||
|
CheckConstraint("severity IN ('low','medium','high')", name="ck_data_quality_severity"),
|
||||||
|
CheckConstraint(
|
||||||
|
"status IN ('open','deferred','resolved','rejected')",
|
||||||
|
name="ck_data_quality_status",
|
||||||
|
),
|
||||||
|
Index("ix_data_quality_work_queue", "status", "due_at", "severity"),
|
||||||
|
Index(
|
||||||
|
"uq_data_quality_one_open_condition",
|
||||||
|
"rule_type",
|
||||||
|
"entity_type",
|
||||||
|
"entity_id",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("status = 'open'"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
rule_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
severity: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="open")
|
||||||
|
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
assigned_to_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
assigned_to_user: Mapped["User | None"] = relationship(lazy="selectin")
|
||||||
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
resolved_by: Mapped[str | None] = mapped_column(String(120))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "idempotency_records"
|
||||||
|
|
||||||
|
idempotency_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||||
|
booking_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
|
||||||
|
)
|
||||||
|
# SHA-256 of the canonical request body. Replaying a key with a *different* body is
|
||||||
|
# a client bug and must be rejected instead of silently answered with the old result.
|
||||||
|
request_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
response_status: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
response_body: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
INSPECTION_TYPES = ("checkout", "return")
|
||||||
|
|
||||||
|
|
||||||
|
class Inspection(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "inspections"
|
||||||
|
|
||||||
|
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
booking_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
|
||||||
|
)
|
||||||
|
type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
fuel_level_percent: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
cleanliness_ok: Mapped[bool] = mapped_column(Boolean, nullable=False)
|
||||||
|
damage_reported: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
technical_warning: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
completed_by: Mapped[str | None] = mapped_column(String(120))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceRecord(UUIDPrimaryKeyMixin, Base):
|
||||||
|
__tablename__ = "maintenance_records"
|
||||||
|
|
||||||
|
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
|
||||||
|
)
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
category: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
|
summary: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, DateTime, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin
|
||||||
|
|
||||||
|
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
|
||||||
|
|
||||||
|
# The one delivery failure the demo seed deliberately plants (BK-H-0020, see
|
||||||
|
# seed/workflow_runs.csv). It exists to show retry and audit working, so it must never
|
||||||
|
# be read as an integration-health problem: it is a scripted prop, not evidence that
|
||||||
|
# n8n is unhealthy. A dedicated error code -- rather than the generic
|
||||||
|
# "connectionError" a real timeout produces -- is what lets every reader tell the two
|
||||||
|
# apart without guessing from the message text.
|
||||||
|
#
|
||||||
|
# It is deliberately a `last_error_code` value and not a new column: the code is
|
||||||
|
# already persisted, already surfaced to the UI, and already localizable, so no schema
|
||||||
|
# change or migration is needed. A genuine later failure of this same event overwrites
|
||||||
|
# the code with the real one, which is exactly right -- from that moment it *is* a real
|
||||||
|
# failure.
|
||||||
|
DEMO_SCENARIO_ERROR_CODE = "demoScenarioTimeout"
|
||||||
|
|
||||||
|
|
||||||
|
def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
|
||||||
|
"""True for the prepared demo failure, false for every real one."""
|
||||||
|
return event.delivery_status == "failed" and event.last_error_code == DEMO_SCENARIO_ERROR_CODE
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxEvent(TimestampMixin, Base):
|
||||||
|
__tablename__ = "outbox_events"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"delivery_status IN ('pending','delivering','succeeded','failed')",
|
||||||
|
name="ck_outbox_delivery_status",
|
||||||
|
),
|
||||||
|
CheckConstraint("attempts >= 0", name="ck_outbox_attempts"),
|
||||||
|
Index("ix_outbox_delivery_next_attempt", "delivery_status", "next_attempt_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
event_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
|
aggregate_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
aggregate_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||||
|
payload_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
|
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
delivery_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Stable, localizable classification of last_error -- the frontend renders a
|
||||||
|
# localized summary from this code as the primary text and shows last_error itself
|
||||||
|
# only under "Technical details" (section 10 of docs/fleet-ops-correction/
|
||||||
|
# current-gap-audit.md). Kept alongside the raw message for backward compatibility.
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(60))
|
||||||
|
external_run_id: Mapped[str | None] = mapped_column(String(120))
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "revoked_sessions"
|
||||||
|
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from sqlalchemy import Boolean, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
ROLES = ("operations_manager", "rental_employee")
|
||||||
|
|
||||||
|
|
||||||
|
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("identity_provider", "external_subject", name="uq_user_external_identity"),
|
||||||
|
)
|
||||||
|
|
||||||
|
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
identity_provider: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||||
|
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from sqlalchemy import Boolean, CheckConstraint, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
OPERATIONAL_STATUSES = ("available", "rented", "cleaning", "maintenance", "blocked")
|
||||||
|
|
||||||
|
|
||||||
|
class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||||
|
__tablename__ = "vehicles"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"operational_status IN ('available','rented','cleaning','maintenance','blocked')",
|
||||||
|
name="ck_vehicles_operational_status",
|
||||||
|
),
|
||||||
|
CheckConstraint("model_year BETWEEN 1900 AND 2100", name="ck_vehicles_model_year"),
|
||||||
|
CheckConstraint("odometer_km >= 0", name="ck_vehicles_odometer"),
|
||||||
|
CheckConstraint("next_service_km >= 0", name="ck_vehicles_next_service"),
|
||||||
|
CheckConstraint("version >= 1", name="ck_vehicles_version"),
|
||||||
|
)
|
||||||
|
|
||||||
|
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||||
|
make: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
model: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||||
|
model_year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
registration_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
|
||||||
|
location: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||||
|
operational_status: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
next_service_km: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Annotated, Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, StringConstraints
|
||||||
|
|
||||||
|
Role = Literal["operations_manager", "rental_employee"]
|
||||||
|
NonBlankOperationalReason = Annotated[
|
||||||
|
str,
|
||||||
|
StringConstraints(strip_whitespace=True, min_length=3, max_length=500),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DemoLoginRequest(BaseModel):
|
||||||
|
role: Role
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordLoginRequest(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
password: str = Field(min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
email: str | None
|
||||||
|
display_name: str
|
||||||
|
role: Role
|
||||||
|
active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CreateUserRequest(BaseModel):
|
||||||
|
email: str = Field(min_length=3, max_length=320)
|
||||||
|
display_name: str = Field(min_length=2, max_length=120)
|
||||||
|
role: Role
|
||||||
|
password: str = Field(min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserRequest(BaseModel):
|
||||||
|
display_name: str | None = Field(default=None, min_length=2, max_length=120)
|
||||||
|
role: Role | None = None
|
||||||
|
active: bool | None = None
|
||||||
|
password: str | None = Field(default=None, min_length=8, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentUser(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
display_name: str
|
||||||
|
role: Role
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
make: str
|
||||||
|
model: str
|
||||||
|
model_year: int
|
||||||
|
registration_number: str
|
||||||
|
location: str
|
||||||
|
operational_status: str
|
||||||
|
odometer_km: int
|
||||||
|
next_service_km: int
|
||||||
|
active: bool
|
||||||
|
attention: bool = False
|
||||||
|
attention_reason: str | None = None
|
||||||
|
service_remaining_km: int
|
||||||
|
next_booking_ref: str | None = None
|
||||||
|
next_booking_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehiclePageOut(BaseModel):
|
||||||
|
items: list[VehicleOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class BookingSummaryOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
customer_ref: str
|
||||||
|
vehicle_ref: str
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class BookingOut(BookingSummaryOut):
|
||||||
|
start_odometer_km: int | None
|
||||||
|
end_odometer_km: int | None
|
||||||
|
requirements_complete: bool
|
||||||
|
customer_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateBookingRequest(BaseModel):
|
||||||
|
customer_ref: str = Field(min_length=3, max_length=20)
|
||||||
|
vehicle_ref: str = Field(min_length=3, max_length=20)
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteBookingRequirementsRequest(BaseModel):
|
||||||
|
confirmation: NonBlankOperationalReason
|
||||||
|
|
||||||
|
|
||||||
|
class RescheduleBookingRequest(BaseModel):
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime
|
||||||
|
reason: NonBlankOperationalReason
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerOptionOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
display_name: str
|
||||||
|
email: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AvailableVehicleOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
make: str
|
||||||
|
model: str
|
||||||
|
registration_number: str
|
||||||
|
location: str
|
||||||
|
operational_status: str
|
||||||
|
|
||||||
|
|
||||||
|
class CancelBookingRequest(BaseModel):
|
||||||
|
reason: NonBlankOperationalReason
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutBookingRequest(BaseModel):
|
||||||
|
start_odometer_km: Annotated[int, Field(ge=0)]
|
||||||
|
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
|
||||||
|
cleanliness_ok: bool
|
||||||
|
damage_reported: bool = False
|
||||||
|
technical_warning: bool = False
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutBookingResult(BaseModel):
|
||||||
|
booking_ref: str
|
||||||
|
vehicle_ref: str
|
||||||
|
inspection_ref: str
|
||||||
|
booking_status: str
|
||||||
|
resulting_vehicle_status: str
|
||||||
|
activated: bool
|
||||||
|
attention_reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class BookingPageOut(BaseModel):
|
||||||
|
items: list[BookingOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterReturnRequest(BaseModel):
|
||||||
|
end_odometer_km: Annotated[int, Field(ge=0)]
|
||||||
|
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
|
||||||
|
cleanliness_ok: bool
|
||||||
|
damage_reported: bool
|
||||||
|
technical_warning: bool
|
||||||
|
notes: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class NextBookingRisk(BaseModel):
|
||||||
|
booking_ref: str
|
||||||
|
starts_at: datetime
|
||||||
|
at_risk: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterReturnResult(BaseModel):
|
||||||
|
booking_ref: str
|
||||||
|
vehicle_ref: str
|
||||||
|
inspection_ref: str
|
||||||
|
resulting_vehicle_status: str
|
||||||
|
odometer_regression: bool
|
||||||
|
quality_issue_ref: str | None
|
||||||
|
workflow_event_id: str
|
||||||
|
correlation_id: str
|
||||||
|
next_booking_risk: NextBookingRisk | None
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnPreviewResult(BaseModel):
|
||||||
|
booking_ref: str
|
||||||
|
vehicle_ref: str
|
||||||
|
canonical_odometer_km: int
|
||||||
|
submitted_odometer_km: int
|
||||||
|
odometer_regression: bool
|
||||||
|
resulting_odometer_km: int
|
||||||
|
resulting_vehicle_status: str
|
||||||
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int] = {}
|
||||||
|
would_create_quality_issue: bool
|
||||||
|
attention_reasons: list[str]
|
||||||
|
next_booking_risk: NextBookingRisk | None
|
||||||
|
|
||||||
|
|
||||||
|
class InspectionOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
booking_ref: str
|
||||||
|
type: str
|
||||||
|
fuel_level_percent: int
|
||||||
|
cleanliness_ok: bool
|
||||||
|
damage_reported: bool
|
||||||
|
technical_warning: bool
|
||||||
|
odometer_km: int
|
||||||
|
completed_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MaintenanceOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
occurred_at: datetime
|
||||||
|
odometer_km: int
|
||||||
|
category: str
|
||||||
|
summary: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreateMaintenanceRequest(BaseModel):
|
||||||
|
occurred_at: datetime
|
||||||
|
odometer_km: Annotated[int, Field(ge=0)]
|
||||||
|
category: Literal["periodic_service", "repair", "inspection", "tyres", "other"]
|
||||||
|
summary: str = Field(min_length=3, max_length=2000)
|
||||||
|
next_service_km: Annotated[int | None, Field(default=None, ge=0)]
|
||||||
|
mark_maintenance: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseVehicleRequest(BaseModel):
|
||||||
|
reason: NonBlankOperationalReason
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityIssueOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
rule_type: str
|
||||||
|
entity_type: str
|
||||||
|
entity_ref: str
|
||||||
|
severity: str
|
||||||
|
status: str
|
||||||
|
evidence: dict[str, Any]
|
||||||
|
detected_at: datetime
|
||||||
|
due_at: datetime | None = None
|
||||||
|
assigned_to_ref: str | None = None
|
||||||
|
assigned_to_name: str | None = None
|
||||||
|
overdue: bool = False
|
||||||
|
resolved_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityIssuePageOut(BaseModel):
|
||||||
|
items: list[DataQualityIssueOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
|
|
||||||
|
|
||||||
|
class DataQualityIssueDetailOut(DataQualityIssueOut):
|
||||||
|
entity_snapshot: dict[str, Any] | None = None
|
||||||
|
related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDataQualityWorkRequest(BaseModel):
|
||||||
|
issue_refs: list[str] = Field(min_length=1, max_length=25)
|
||||||
|
assigned_to_ref: str | None = Field(default=None, min_length=3, max_length=20)
|
||||||
|
clear_assignment: bool = False
|
||||||
|
due_at: datetime | None = None
|
||||||
|
clear_due_at: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class BulkDataQualityWorkResult(BaseModel):
|
||||||
|
updated: list[DataQualityIssueOut]
|
||||||
|
|
||||||
|
|
||||||
|
class MergeCustomersRequest(BaseModel):
|
||||||
|
survivor_ref: str
|
||||||
|
field_overrides: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MergeCustomersResult(BaseModel):
|
||||||
|
issue_ref: str
|
||||||
|
survivor_ref: str
|
||||||
|
loser_ref: str
|
||||||
|
rewired_bookings: int
|
||||||
|
|
||||||
|
|
||||||
|
class ScanResultOut(BaseModel):
|
||||||
|
created: dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportIn(BaseModel):
|
||||||
|
workflow_id: str = Field(max_length=120)
|
||||||
|
workflow_name: str = Field(max_length=200)
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
failed_at: datetime
|
||||||
|
error_category: Literal[
|
||||||
|
"timeout", "authError", "connectionError", "httpError", "validationError", "unknown"
|
||||||
|
]
|
||||||
|
error_summary: str = Field(max_length=500)
|
||||||
|
trigger_context: str | None = Field(default=None, max_length=200)
|
||||||
|
correlation_id: uuid.UUID | None = None
|
||||||
|
attempt: int = Field(default=1, ge=1, le=1000)
|
||||||
|
retry_action: str | None = Field(default=None, max_length=200)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowErrorReportResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureDocumentOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureListOut(BaseModel):
|
||||||
|
documents: list[ProcedureDocumentOut]
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultIn(BaseModel):
|
||||||
|
execution_id: str = Field(max_length=120)
|
||||||
|
synced: int = Field(ge=0)
|
||||||
|
failed: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProcedureSyncResultResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProvideFieldsRequest(BaseModel):
|
||||||
|
fields: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveOdometerRegressionRequest(BaseModel):
|
||||||
|
decision: Literal["retain_canonical", "correct_reading"]
|
||||||
|
booking_ref: str | None = None
|
||||||
|
corrected_odometer_km: Annotated[int, Field(ge=0)] | None = None
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveOverlapRequest(BaseModel):
|
||||||
|
booking_ref: str
|
||||||
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleStatusFactsOut(BaseModel):
|
||||||
|
active_booking_refs: list[str]
|
||||||
|
overlapping_booking_pairs: list[list[str]]
|
||||||
|
service_threshold_reached: bool
|
||||||
|
odometer_km: int
|
||||||
|
next_service_km: int
|
||||||
|
open_booking_overlap_issue_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StatusRecommendationOut(BaseModel):
|
||||||
|
current_status: str
|
||||||
|
recommended_status: str | None
|
||||||
|
recommendation_code: str
|
||||||
|
safe_to_apply: bool
|
||||||
|
manual_review_required: bool
|
||||||
|
facts: VehicleStatusFactsOut
|
||||||
|
blocking_reasons: list[str]
|
||||||
|
recommendation_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyRecommendedStatusRequest(BaseModel):
|
||||||
|
recommendation_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyRecommendedStatusResult(BaseModel):
|
||||||
|
issue: DataQualityIssueOut
|
||||||
|
applied_status: str
|
||||||
|
reason_code: str
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResultItem(BaseModel):
|
||||||
|
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||||
|
label: str
|
||||||
|
detail_code: str
|
||||||
|
detail_params: dict[str, str] = {}
|
||||||
|
link: str
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
query: str
|
||||||
|
results: list[SearchResultItem]
|
||||||
|
|
||||||
|
|
||||||
|
class OidcStatusOut(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
provider_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PrivacyRetentionOut(BaseModel):
|
||||||
|
minimum_booking_retention_days: int
|
||||||
|
audit_retention_days: int
|
||||||
|
customers_total: int
|
||||||
|
customers_anonymized: int
|
||||||
|
customers_eligible: int
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAnonymizeRequest(BaseModel):
|
||||||
|
confirmation: str = Field(min_length=1, max_length=20)
|
||||||
|
reason: str = Field(min_length=8, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerAnonymizeResult(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
anonymized_at: datetime
|
||||||
|
status: Literal["anonymized", "already_anonymized"]
|
||||||
|
|
||||||
|
|
||||||
|
class N8nWorkflowEvidence(BaseModel):
|
||||||
|
name: str
|
||||||
|
built: bool
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
state: Literal["no_evidence", "healthy", "stale", "failed"] = "no_evidence"
|
||||||
|
last_status: Literal["succeeded", "failed"] | None = None
|
||||||
|
last_execution_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnCallbackIn(BaseModel):
|
||||||
|
"""Body of the n8n return follow-up callback.
|
||||||
|
|
||||||
|
n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are ignored;
|
||||||
|
only the fields we persist are validated and bounded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
event_id: uuid.UUID
|
||||||
|
correlation_id: uuid.UUID
|
||||||
|
follow_up: str | None = Field(default=None, max_length=200)
|
||||||
|
summary: str | None = Field(default=None, max_length=2000)
|
||||||
|
|
||||||
|
|
||||||
|
class N8nHeartbeatIn(BaseModel):
|
||||||
|
workflow_id: str = Field(min_length=1, max_length=120)
|
||||||
|
workflow_name: str = Field(min_length=1, max_length=200)
|
||||||
|
execution_id: str = Field(min_length=1, max_length=120)
|
||||||
|
status: Literal["succeeded", "failed"]
|
||||||
|
|
||||||
|
|
||||||
|
class N8nHeartbeatResult(BaseModel):
|
||||||
|
status: Literal["registered", "already_registered"]
|
||||||
|
execution_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class N8nErrorHandlerStatus(BaseModel):
|
||||||
|
total_failures_registered: int
|
||||||
|
latest_failure_at: datetime | None
|
||||||
|
latest_failure_workflow: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class N8nIntegrationStatus(BaseModel):
|
||||||
|
configured: bool
|
||||||
|
dispatch_enabled: bool
|
||||||
|
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||||
|
pending: int
|
||||||
|
#: Every failed delivery, staged and real together -- the number a viewer sees in
|
||||||
|
#: the run list.
|
||||||
|
failed: int
|
||||||
|
#: Failures that were not planted by the demo seed. This is the only failure count
|
||||||
|
#: that may influence `state`.
|
||||||
|
unexpected_failed: int = 0
|
||||||
|
#: Prepared demo failures (see `app.models.outbox.DEMO_SCENARIO_ERROR_CODE`).
|
||||||
|
#: Present so the UI can label them instead of implying the automation is broken.
|
||||||
|
demo_scenario_failed: int = 0
|
||||||
|
delivering: int
|
||||||
|
succeeded: int
|
||||||
|
latest_success_at: datetime | None
|
||||||
|
#: Most recent *real* failure; a staged one never sets this.
|
||||||
|
latest_failure_at: datetime | None
|
||||||
|
latest_demo_scenario_at: datetime | None = None
|
||||||
|
expected_workflow_count: int
|
||||||
|
known_workflow_count: int
|
||||||
|
workflows: list[N8nWorkflowEvidence]
|
||||||
|
error_handler: N8nErrorHandlerStatus
|
||||||
|
|
||||||
|
|
||||||
|
class McpHubIntegrationStatus(BaseModel):
|
||||||
|
registration_enabled: bool
|
||||||
|
state: Literal["not_configured", "no_evidence", "operational"]
|
||||||
|
total_calls: int
|
||||||
|
last_tool: str | None = None
|
||||||
|
last_client: str | None = None
|
||||||
|
last_called_at: datetime | None = None
|
||||||
|
hub_reachable: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationStatusOut(BaseModel):
|
||||||
|
n8n: N8nIntegrationStatus
|
||||||
|
mcp_hub: McpHubIntegrationStatus
|
||||||
|
|
||||||
|
|
||||||
|
class DemoScenarioOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
estimated_minutes: int
|
||||||
|
required_roles: list[Role]
|
||||||
|
start_path: str
|
||||||
|
ready: bool
|
||||||
|
blocked_reason_code: str | None = None
|
||||||
|
blocked_reason_params: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class DemoIntegrationSummaryOut(BaseModel):
|
||||||
|
key: Literal["n8n", "ragcore", "mcp_hub"]
|
||||||
|
status_code: str
|
||||||
|
detail_code: str
|
||||||
|
detail_params: dict[str, str | int] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class DemoManifestOut(BaseModel):
|
||||||
|
demo_mode: bool
|
||||||
|
organization_name: str
|
||||||
|
timezone: str
|
||||||
|
synthetic_data: bool
|
||||||
|
allow_reset: bool
|
||||||
|
last_reset_at: datetime | None
|
||||||
|
anchor_date: str | None
|
||||||
|
guide_available: bool
|
||||||
|
required_roles: list[Role]
|
||||||
|
scenarios: list[DemoScenarioOut]
|
||||||
|
integrations: list[DemoIntegrationSummaryOut]
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleDetailOut(VehicleOut):
|
||||||
|
bookings: list[BookingSummaryOut] = Field(default_factory=list)
|
||||||
|
inspections: list[InspectionOut] = Field(default_factory=list)
|
||||||
|
maintenance: list[MaintenanceOut] = Field(default_factory=list)
|
||||||
|
quality_issues: list[DataQualityIssueOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardMetrics(BaseModel):
|
||||||
|
available: int
|
||||||
|
rented: int
|
||||||
|
cleaning: int
|
||||||
|
maintenance: int
|
||||||
|
blocked: int
|
||||||
|
open_quality_issues: int
|
||||||
|
pending_or_failed_workflows: int
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceSignalOut(BaseModel):
|
||||||
|
code: str
|
||||||
|
params: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class AttentionItem(BaseModel):
|
||||||
|
kind: Literal["quality_issue", "vehicle"]
|
||||||
|
severity: str
|
||||||
|
rule_type: str
|
||||||
|
evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
|
||||||
|
link_type: Literal["vehicle", "booking", "customer"]
|
||||||
|
link_ref: str
|
||||||
|
issue_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TodayItem(BaseModel):
|
||||||
|
kind: Literal["departure", "return"]
|
||||||
|
booking_ref: str
|
||||||
|
vehicle_ref: str
|
||||||
|
scheduled_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationRunOut(BaseModel):
|
||||||
|
event_id: str
|
||||||
|
event_type: str
|
||||||
|
aggregate_ref: str
|
||||||
|
status: str
|
||||||
|
attempts: int
|
||||||
|
last_error: str | None
|
||||||
|
last_error_code: str | None
|
||||||
|
#: True for the deliberately seeded demo failure. The UI uses this to label the run
|
||||||
|
#: as a prepared scenario and to offer the demo retry, instead of presenting it as
|
||||||
|
#: an unexplained production error.
|
||||||
|
is_demo_scenario: bool = False
|
||||||
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardOut(BaseModel):
|
||||||
|
metrics: DashboardMetrics
|
||||||
|
attention_items: list[AttentionItem]
|
||||||
|
today: list[TodayItem]
|
||||||
|
recent_automation: list[AutomationRunOut]
|
||||||
|
|
||||||
|
|
||||||
|
class OperationsSummaryOut(BaseModel):
|
||||||
|
tenant: str
|
||||||
|
metrics: DashboardMetrics
|
||||||
|
|
||||||
|
|
||||||
|
class AttentionVehicleOut(BaseModel):
|
||||||
|
vehicle_ref: str
|
||||||
|
severity: Literal["low", "medium", "high"]
|
||||||
|
rule_type: str
|
||||||
|
summary: str
|
||||||
|
detected_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class McpVehicleDetailOut(BaseModel):
|
||||||
|
public_ref: str
|
||||||
|
make: str
|
||||||
|
model: str
|
||||||
|
model_year: int
|
||||||
|
location: str
|
||||||
|
operational_status: str
|
||||||
|
odometer_km: int
|
||||||
|
next_service_km: int
|
||||||
|
open_quality_issue_count: int
|
||||||
|
current_booking_ref: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class McpKnowledgeSearchRequest(BaseModel):
|
||||||
|
question: str = Field(min_length=3, max_length=1000)
|
||||||
|
max_sources: int = Field(default=4, ge=1, le=8)
|
||||||
|
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
actor_type: str
|
||||||
|
actor_label: str
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str | None
|
||||||
|
entity_ref: str | None = None
|
||||||
|
entity_link: str | None = None
|
||||||
|
correlation_id: str
|
||||||
|
occurred_at: datetime
|
||||||
|
before: dict[str, Any] | None = None
|
||||||
|
after: dict[str, Any] | None = None
|
||||||
|
metadata: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventPageOut(BaseModel):
|
||||||
|
items: list[AuditEventOut]
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
total: int
|
||||||
|
total_pages: int
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import delete, insert, update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import begin_exclusive_demo_reset, end_exclusive_demo_reset
|
||||||
|
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.idempotency import IdempotencyRecord
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.maintenance import MaintenanceRecord
|
||||||
|
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
DEMO_USERS = [
|
||||||
|
{
|
||||||
|
"public_ref": "USR-OPS",
|
||||||
|
"display_name": "Amelie De Ridder",
|
||||||
|
"role": "operations_manager",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"public_ref": "USR-EMP",
|
||||||
|
"display_name": "Karim Boujaddaine",
|
||||||
|
"role": "rental_employee",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# seed/generate_seed.py authored the committed CSVs relative to this fixed date
|
||||||
|
# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every
|
||||||
|
# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps
|
||||||
|
# right now" scenarios stay true to the actual reset moment instead of decaying as real
|
||||||
|
# time passes between resets -- a fixed anchor with no shift goes stale within days.
|
||||||
|
SEED_AUTHORED_ANCHOR = date(2026, 8, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_anchor_shift(today: date) -> timedelta:
|
||||||
|
return today - SEED_AUTHORED_ANCHOR
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value: str) -> datetime:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: str) -> bool:
|
||||||
|
return value.strip().lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_int(value: str) -> int | None:
|
||||||
|
value = value.strip()
|
||||||
|
return int(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeedResult:
|
||||||
|
counts: dict[str, int]
|
||||||
|
anchor_date: date
|
||||||
|
seeded_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_dir() -> Path:
|
||||||
|
return Path(settings.seed_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_csv(name: str) -> list[dict[str, str]]:
|
||||||
|
path = _seed_dir() / name
|
||||||
|
with path.open(newline="", encoding="utf-8") as handle:
|
||||||
|
return list(csv.DictReader(handle))
|
||||||
|
|
||||||
|
|
||||||
|
_PERSISTENT_TELEMETRY_ACTIONS = (
|
||||||
|
"mcp_tool_request",
|
||||||
|
"n8n_return_followup_recorded",
|
||||||
|
"n8n_workflow_failure_registered",
|
||||||
|
"n8n_procedures_synced",
|
||||||
|
"n8n_workflow_heartbeat",
|
||||||
|
"knowledge_question_asked",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
|
||||||
|
# RevokedSession is intentionally NOT cleared: it has no FK to users and wiping it
|
||||||
|
# would silently re-validate cookies that were logged out before the reset.
|
||||||
|
for model in (
|
||||||
|
OutboxEvent,
|
||||||
|
IdempotencyRecord,
|
||||||
|
DataQualityIssue,
|
||||||
|
Inspection,
|
||||||
|
MaintenanceRecord,
|
||||||
|
Booking,
|
||||||
|
Vehicle,
|
||||||
|
Customer,
|
||||||
|
User,
|
||||||
|
):
|
||||||
|
db.execute(delete(model))
|
||||||
|
if preserve_integration_telemetry:
|
||||||
|
db.execute(
|
||||||
|
delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
db.execute(delete(AuditEvent))
|
||||||
|
|
||||||
|
|
||||||
|
def load_seed(db: Session) -> SeedResult:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
shift = _seed_anchor_shift(today)
|
||||||
|
|
||||||
|
user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS]
|
||||||
|
db.execute(insert(User), user_rows)
|
||||||
|
counts["users"] = len(user_rows)
|
||||||
|
|
||||||
|
customer_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
|
customer_rows = []
|
||||||
|
customer_row_by_ref: dict[str, dict] = {}
|
||||||
|
for row in _read_csv("customers.csv"):
|
||||||
|
cid = uuid.uuid4()
|
||||||
|
customer_id_by_ref[row["public_ref"]] = cid
|
||||||
|
customer_row = {
|
||||||
|
"id": cid,
|
||||||
|
"public_ref": row["public_ref"],
|
||||||
|
"first_name": row["first_name"],
|
||||||
|
"last_name": row["last_name"],
|
||||||
|
"email": row["email"] or None,
|
||||||
|
"phone": row["phone"] or None,
|
||||||
|
"postal_code": row["postal_code"] or None,
|
||||||
|
"city": row["city"] or None,
|
||||||
|
}
|
||||||
|
customer_rows.append(customer_row)
|
||||||
|
customer_row_by_ref[row["public_ref"]] = customer_row
|
||||||
|
db.execute(insert(Customer), customer_rows)
|
||||||
|
counts["customers"] = len(customer_rows)
|
||||||
|
# Second pass for merged_into (self-referencing FK) since target must exist first.
|
||||||
|
for row in _read_csv("customers.csv"):
|
||||||
|
merged_ref = row.get("merged_into") or ""
|
||||||
|
if merged_ref:
|
||||||
|
db.execute(
|
||||||
|
update(Customer)
|
||||||
|
.where(Customer.id == customer_id_by_ref[row["public_ref"]])
|
||||||
|
.values(merged_into_customer_id=customer_id_by_ref[merged_ref])
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
|
vehicle_rows = []
|
||||||
|
vehicle_row_by_ref: dict[str, dict] = {}
|
||||||
|
for row in _read_csv("vehicles.csv"):
|
||||||
|
vid = uuid.uuid4()
|
||||||
|
vehicle_id_by_ref[row["public_ref"]] = vid
|
||||||
|
vehicle_row = {
|
||||||
|
"id": vid,
|
||||||
|
"public_ref": row["public_ref"],
|
||||||
|
"make": row["make"],
|
||||||
|
"model": row["model"],
|
||||||
|
"model_year": int(row["model_year"]),
|
||||||
|
"registration_number": row["registration_number"],
|
||||||
|
"location": row["location"],
|
||||||
|
"operational_status": row["operational_status"],
|
||||||
|
"odometer_km": int(row["odometer_km"]),
|
||||||
|
"next_service_km": int(row["next_service_km"]),
|
||||||
|
"active": _parse_bool(row["active"]),
|
||||||
|
"version": 1,
|
||||||
|
}
|
||||||
|
vehicle_rows.append(vehicle_row)
|
||||||
|
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
|
||||||
|
db.execute(insert(Vehicle), vehicle_rows)
|
||||||
|
counts["vehicles"] = len(vehicle_rows)
|
||||||
|
|
||||||
|
booking_id_by_ref: dict[str, uuid.UUID] = {}
|
||||||
|
booking_rows = []
|
||||||
|
booking_row_by_ref: dict[str, dict] = {}
|
||||||
|
for row in _read_csv("bookings.csv"):
|
||||||
|
bid = uuid.uuid4()
|
||||||
|
booking_id_by_ref[row["public_ref"]] = bid
|
||||||
|
booking_row = {
|
||||||
|
"id": bid,
|
||||||
|
"public_ref": row["public_ref"],
|
||||||
|
"customer_id": customer_id_by_ref[row["customer_ref"]],
|
||||||
|
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||||
|
"starts_at": _parse_dt(row["starts_at"]) + shift,
|
||||||
|
"ends_at": _parse_dt(row["ends_at"]) + shift,
|
||||||
|
"status": row["status"],
|
||||||
|
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
|
||||||
|
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
|
||||||
|
"requirements_complete": _parse_bool(row["requirements_complete"]),
|
||||||
|
}
|
||||||
|
booking_rows.append(booking_row)
|
||||||
|
booking_row_by_ref[row["public_ref"]] = booking_row
|
||||||
|
db.execute(insert(Booking), booking_rows)
|
||||||
|
counts["bookings"] = len(booking_rows)
|
||||||
|
|
||||||
|
inspection_rows = []
|
||||||
|
inspection_source_rows = _read_csv("inspections.csv")
|
||||||
|
return_inspection_row_by_booking_ref = {
|
||||||
|
row["booking_ref"]: row for row in inspection_source_rows if row["type"] == "return"
|
||||||
|
}
|
||||||
|
for row in inspection_source_rows:
|
||||||
|
inspection_rows.append(
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"public_ref": row["public_ref"],
|
||||||
|
"booking_id": booking_id_by_ref[row["booking_ref"]],
|
||||||
|
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||||
|
"type": row["type"],
|
||||||
|
"fuel_level_percent": int(row["fuel_level_percent"]),
|
||||||
|
"cleanliness_ok": _parse_bool(row["cleanliness_ok"]),
|
||||||
|
"damage_reported": _parse_bool(row["damage_reported"]),
|
||||||
|
"technical_warning": _parse_bool(row["technical_warning"]),
|
||||||
|
"odometer_km": int(row["odometer_km"]),
|
||||||
|
"completed_at": _parse_dt(row["completed_at"]) + shift,
|
||||||
|
"completed_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.execute(insert(Inspection), inspection_rows)
|
||||||
|
counts["inspections"] = len(inspection_rows)
|
||||||
|
|
||||||
|
maintenance_rows = []
|
||||||
|
for row in _read_csv("maintenance.csv"):
|
||||||
|
maintenance_rows.append(
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"public_ref": row["public_ref"],
|
||||||
|
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
|
||||||
|
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||||
|
"odometer_km": int(row["odometer_km"]),
|
||||||
|
"category": row["category"],
|
||||||
|
"summary": row["summary"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.execute(insert(MaintenanceRecord), maintenance_rows)
|
||||||
|
counts["maintenance"] = len(maintenance_rows)
|
||||||
|
|
||||||
|
def resolve_entity(entity_ref: str) -> tuple[str, uuid.UUID]:
|
||||||
|
if entity_ref.startswith("CUS-"):
|
||||||
|
return "customer", customer_id_by_ref[entity_ref]
|
||||||
|
return "vehicle", vehicle_id_by_ref[entity_ref]
|
||||||
|
|
||||||
|
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
|
||||||
|
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
|
||||||
|
# handful of seed-only rows below -- none of them carry an active rental or a
|
||||||
|
# real booking conflict (verified against the fixed seed dataset), only a
|
||||||
|
# genuinely-crossed service threshold or none at all, so those two fields are
|
||||||
|
# the only ones that vary per vehicle.
|
||||||
|
vehicle = vehicle_row_by_ref[vehicle_ref]
|
||||||
|
return {
|
||||||
|
"active_booking_refs": [],
|
||||||
|
"overlapping_booking_pairs": [],
|
||||||
|
"service_threshold_reached": service_threshold_reached,
|
||||||
|
"odometer_km": vehicle["odometer_km"],
|
||||||
|
"next_service_km": vehicle["next_service_km"],
|
||||||
|
"open_booking_overlap_issue_ref": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
|
||||||
|
later = return_inspection_row_by_booking_ref[later_ref]
|
||||||
|
earlier = return_inspection_row_by_booking_ref[earlier_ref]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": "odometer.regression",
|
||||||
|
"source_type": "return",
|
||||||
|
"params": {
|
||||||
|
"later_ref": later["public_ref"],
|
||||||
|
"later_km": int(later["odometer_km"]),
|
||||||
|
"earlier_ref": earlier["public_ref"],
|
||||||
|
"earlier_km": int(earlier["odometer_km"]),
|
||||||
|
"booking_ref": later_ref,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def _missing_field_signal(field: str) -> list[dict]:
|
||||||
|
return [{"code": "missing_field", "params": {"field": field}}]
|
||||||
|
|
||||||
|
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
|
||||||
|
# used to carry no structured signal at all -- just the placeholder summary
|
||||||
|
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
|
||||||
|
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
|
||||||
|
# pair of booking odometer readings engineered into seed/bookings.csv), using the
|
||||||
|
# exact same signal vocabulary the live scan (app.services.data_quality) already
|
||||||
|
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
|
||||||
|
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
|
||||||
|
"DQ-0005": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.service_threshold_reached",
|
||||||
|
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0006": _missing_field_signal("location"),
|
||||||
|
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
|
||||||
|
"DQ-0008": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.rental_ended",
|
||||||
|
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0009": _missing_field_signal("location"),
|
||||||
|
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
|
||||||
|
"DQ-0011": [
|
||||||
|
{
|
||||||
|
"code": "vehicle.service_threshold_reached",
|
||||||
|
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"DQ-0012": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0013": _missing_field_signal("location"),
|
||||||
|
"DQ-0014": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0015": _missing_field_signal("location"),
|
||||||
|
"DQ-0016": _missing_field_signal("location"),
|
||||||
|
"DQ-0017": _missing_field_signal("location"),
|
||||||
|
"DQ-0018": _missing_field_signal("registration_number"),
|
||||||
|
"DQ-0019": _missing_field_signal("location"),
|
||||||
|
"DQ-0020": _missing_field_signal("location"),
|
||||||
|
"DQ-0021": _missing_field_signal("location"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
|
||||||
|
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
|
||||||
|
# they carry real, accurate structured signals (not just a legacy English
|
||||||
|
# sentence) -- the frontend renders these as the primary, localized evidence;
|
||||||
|
# see docs/fleet-ops-correction/current-gap-audit.md §6.
|
||||||
|
if public_ref == "DQ-DEMO-DUPLICATE":
|
||||||
|
a = customer_row_by_ref[entity_ref]
|
||||||
|
b = customer_row_by_ref[related_refs[0]]
|
||||||
|
name_a = f"{a['first_name']} {a['last_name']}".strip().lower()
|
||||||
|
name_b = f"{b['first_name']} {b['last_name']}".strip().lower()
|
||||||
|
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
||||||
|
return [
|
||||||
|
{"code": "duplicate.exact_email"},
|
||||||
|
{"code": "duplicate.exact_phone"},
|
||||||
|
{"code": "duplicate.same_postal_code"},
|
||||||
|
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}},
|
||||||
|
]
|
||||||
|
if public_ref == "DQ-DEMO-OVERLAP":
|
||||||
|
return [{"code": "overlap.reserved_bookings", "params": {"refs": related_refs}}]
|
||||||
|
if public_ref == "DQ-DEMO-STATUS":
|
||||||
|
return [{"code": "vehicle.booking_conflict"}]
|
||||||
|
if public_ref == "DQ-DEMO-ATTENTION":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"code": "attention.upcoming_booking_missing_inspection",
|
||||||
|
"params": {"booking_ref": related_refs[0] if related_refs else ""},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
|
||||||
|
|
||||||
|
dq_rows = []
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
for row in _read_csv("data_quality_issues.csv"):
|
||||||
|
entity_type, entity_id = resolve_entity(row["entity_ref"])
|
||||||
|
related_ref = row.get("related_ref") or ""
|
||||||
|
related_refs = related_ref.split("|") if related_ref else []
|
||||||
|
signals = _seed_signals(row["public_ref"], row["entity_ref"], related_refs)
|
||||||
|
evidence_extra: dict[str, object] = {}
|
||||||
|
if row["rule_type"] == "odometer_regression" and signals:
|
||||||
|
params = signals[0].get("params", {})
|
||||||
|
later_ref = params.get("later_ref")
|
||||||
|
earlier_ref = params.get("earlier_ref")
|
||||||
|
booking_ref = params.get("booking_ref")
|
||||||
|
if (
|
||||||
|
isinstance(earlier_ref, str)
|
||||||
|
and isinstance(later_ref, str)
|
||||||
|
and isinstance(booking_ref, str)
|
||||||
|
):
|
||||||
|
# The authored CSV prose predates structured references. Preserve the
|
||||||
|
# two real source bookings so the detail page can show evidence, while
|
||||||
|
# only the later (regressing) reading is eligible for correction.
|
||||||
|
related_refs = [earlier_ref, booking_ref, later_ref]
|
||||||
|
evidence_extra = {
|
||||||
|
"source_type": signals[0].get("source_type", "return"),
|
||||||
|
"source_types": [signals[0].get("source_type", "return")],
|
||||||
|
"correctable_booking_refs": [booking_ref],
|
||||||
|
}
|
||||||
|
severity_due_delta = {
|
||||||
|
"high": timedelta(hours=4),
|
||||||
|
"medium": timedelta(days=1),
|
||||||
|
"low": timedelta(days=3),
|
||||||
|
}.get(row["severity"], timedelta(days=1))
|
||||||
|
dq_rows.append(
|
||||||
|
{
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"public_ref": row["public_ref"],
|
||||||
|
"rule_type": row["rule_type"],
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"severity": row["severity"],
|
||||||
|
"status": row["status"],
|
||||||
|
"evidence_json": {
|
||||||
|
"summary": row["evidence"],
|
||||||
|
"entity_ref": row["entity_ref"],
|
||||||
|
"related_refs": related_refs,
|
||||||
|
"signals": signals,
|
||||||
|
**evidence_extra,
|
||||||
|
},
|
||||||
|
"proposed_action_json": {},
|
||||||
|
"detected_at": now,
|
||||||
|
"due_at": now + severity_due_delta if row["status"] == "open" else None,
|
||||||
|
"resolved_at": now if row["status"] == "resolved" else None,
|
||||||
|
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.execute(insert(DataQualityIssue), dq_rows)
|
||||||
|
counts["data_quality_issues"] = len(dq_rows)
|
||||||
|
|
||||||
|
vehicle_ref_by_booking_ref = {
|
||||||
|
row["public_ref"]: row["vehicle_ref"] for row in _read_csv("bookings.csv")
|
||||||
|
}
|
||||||
|
|
||||||
|
outbox_rows = []
|
||||||
|
for row in _read_csv("workflow_runs.csv"):
|
||||||
|
booking_id = booking_id_by_ref.get(row["aggregate_ref"])
|
||||||
|
# Build the same schema-complete envelope the live return workflow (M2) produces,
|
||||||
|
# so a seeded/historical event is redeliverable (e.g. via manual retry) without the
|
||||||
|
# dispatcher crashing on a missing key. See PROJECT_STATE.md M4 notes.
|
||||||
|
outbox_rows.append(
|
||||||
|
{
|
||||||
|
"event_id": uuid.UUID(row["event_id"]),
|
||||||
|
"event_type": row["event_type"],
|
||||||
|
"aggregate_type": "booking",
|
||||||
|
"aggregate_id": booking_id or uuid.uuid4(),
|
||||||
|
"payload_json": {
|
||||||
|
"correlation_id": str(uuid.uuid4()),
|
||||||
|
"aggregate": {
|
||||||
|
"type": "booking",
|
||||||
|
"id": str(booking_id or uuid.uuid4()),
|
||||||
|
"public_ref": row["aggregate_ref"],
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"vehicle_ref": vehicle_ref_by_booking_ref.get(row["aggregate_ref"], ""),
|
||||||
|
"inspection_ref": "",
|
||||||
|
"resulting_vehicle_status": "cleaning",
|
||||||
|
"attention_reasons": [],
|
||||||
|
},
|
||||||
|
"aggregate_ref": row["aggregate_ref"],
|
||||||
|
},
|
||||||
|
"occurred_at": _parse_dt(row["occurred_at"]) + shift,
|
||||||
|
"delivery_status": row["status"],
|
||||||
|
"attempts": int(row["attempts"]),
|
||||||
|
"next_attempt_at": None,
|
||||||
|
"last_error": row["last_error"] or None,
|
||||||
|
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
||||||
|
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
||||||
|
# It is coded as a *prepared demo scenario*, not as a real
|
||||||
|
# connectionError, so integration health never degrades because of a
|
||||||
|
# prop and a viewer is told plainly that this failure is staged.
|
||||||
|
"last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None,
|
||||||
|
"external_run_id": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.execute(insert(OutboxEvent), outbox_rows)
|
||||||
|
counts["workflow_runs"] = len(outbox_rows)
|
||||||
|
|
||||||
|
seeded_at = datetime.now(UTC)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="system",
|
||||||
|
actor_label="seed loader",
|
||||||
|
action="demo_data_seeded",
|
||||||
|
entity_type="system",
|
||||||
|
metadata={
|
||||||
|
"anchor_date": today.isoformat(),
|
||||||
|
"seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(),
|
||||||
|
"counts": counts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_and_seed(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
preserve_integration_telemetry: bool = False,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> SeedResult:
|
||||||
|
from app.services.data_quality import run_scan
|
||||||
|
|
||||||
|
if not db.info.get("mobilityops_demo_reset_exclusive"):
|
||||||
|
begin_exclusive_demo_reset(db)
|
||||||
|
clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
|
||||||
|
result = load_seed(db)
|
||||||
|
scan = run_scan(db, commit=False)
|
||||||
|
result.counts["data_quality_issues"] += sum(scan.created.values())
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
end_exclusive_demo_reset(db)
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return result
|
||||||
Whitespace-only changes.
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
|
||||||
|
|
||||||
|
def record_audit_event(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
actor_type: str,
|
||||||
|
actor_label: str,
|
||||||
|
action: str,
|
||||||
|
entity_type: str,
|
||||||
|
actor_id: uuid.UUID | None = None,
|
||||||
|
entity_id: uuid.UUID | None = None,
|
||||||
|
correlation_id: uuid.UUID | None = None,
|
||||||
|
before: dict[str, Any] | None = None,
|
||||||
|
after: dict[str, Any] | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> AuditEvent:
|
||||||
|
event = AuditEvent(
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_id=actor_id,
|
||||||
|
actor_label=actor_label,
|
||||||
|
action=action,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
correlation_id=correlation_id or uuid.uuid4(),
|
||||||
|
before_json=before,
|
||||||
|
after_json=after,
|
||||||
|
metadata_json=metadata,
|
||||||
|
occurred_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
return event
|
||||||
@@ -0,0 +1,799 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
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 CurrentUser
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality_common import has_open_issue as _has_open_issue
|
||||||
|
from app.services.data_quality_common import issue_due_at
|
||||||
|
from app.services.data_quality_common import load_open_issue as _load_open_issue
|
||||||
|
from app.services.data_quality_common import new_scan_ref as _new_scan_ref
|
||||||
|
from app.services.data_quality_duplicate_scan import scan_duplicate_customers
|
||||||
|
from app.services.data_quality_odometer import (
|
||||||
|
open_odometer_regression_issue as open_odometer_regression_issue,
|
||||||
|
)
|
||||||
|
from app.services.data_quality_odometer import (
|
||||||
|
resolve_odometer_regression as resolve_odometer_regression,
|
||||||
|
)
|
||||||
|
from app.services.vehicle_status import (
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||||
|
VehicleStatusRecommendation,
|
||||||
|
compute_recommendation_token,
|
||||||
|
evaluate_vehicle_status,
|
||||||
|
gather_vehicle_status_facts,
|
||||||
|
)
|
||||||
|
|
||||||
|
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
||||||
|
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||||
|
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanResult:
|
||||||
|
created: dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def bump(self, rule_type: str) -> None:
|
||||||
|
self.created[rule_type] = self.created.get(rule_type, 0) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def _open_issue(
|
||||||
|
db: Session,
|
||||||
|
scan: ScanResult,
|
||||||
|
*,
|
||||||
|
rule_type: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
severity: str,
|
||||||
|
summary: str,
|
||||||
|
entity_ref: str,
|
||||||
|
related_refs: list[str],
|
||||||
|
signals: list[dict] | None = None,
|
||||||
|
evidence_extra: dict | None = None,
|
||||||
|
) -> None:
|
||||||
|
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
||||||
|
return
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Reintroduced evidence creates a new issue rather than silently reopening the old
|
||||||
|
# one, but it stays linked to whatever decision was made last time so an operator
|
||||||
|
# doesn't re-litigate from a blank slate.
|
||||||
|
previous = db.scalar(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(
|
||||||
|
DataQualityIssue.rule_type == rule_type,
|
||||||
|
DataQualityIssue.entity_type == entity_type,
|
||||||
|
DataQualityIssue.entity_id == entity_id,
|
||||||
|
DataQualityIssue.status != "open",
|
||||||
|
)
|
||||||
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
|
)
|
||||||
|
# `summary` is kept as a technical-fallback string (shown only under "Technical
|
||||||
|
# details"); `signals` is the stable, localizable structure the frontend renders as
|
||||||
|
# the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6.
|
||||||
|
evidence: dict = {
|
||||||
|
"summary": summary,
|
||||||
|
"entity_ref": entity_ref,
|
||||||
|
"related_refs": related_refs,
|
||||||
|
"signals": signals or [],
|
||||||
|
}
|
||||||
|
evidence.update(evidence_extra or {})
|
||||||
|
if previous is not None:
|
||||||
|
evidence["reopened_from"] = previous.public_ref
|
||||||
|
evidence["previous_decision"] = previous.status
|
||||||
|
|
||||||
|
issue = DataQualityIssue(
|
||||||
|
public_ref=_new_scan_ref("DQ-SCAN"),
|
||||||
|
rule_type=rule_type,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
severity=severity,
|
||||||
|
status="open",
|
||||||
|
evidence_json=evidence,
|
||||||
|
proposed_action_json={},
|
||||||
|
detected_at=now,
|
||||||
|
due_at=issue_due_at(now, severity),
|
||||||
|
)
|
||||||
|
db.add(issue)
|
||||||
|
db.flush()
|
||||||
|
scan.bump(rule_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
|
||||||
|
# Anonymised customers have had their contact data removed on purpose; flagging
|
||||||
|
# them as "missing required field" would only be resolvable by re-entering PII.
|
||||||
|
for customer in db.scalars(
|
||||||
|
select(Customer).where(
|
||||||
|
Customer.merged_into_customer_id.is_(None),
|
||||||
|
Customer.anonymized_at.is_(None),
|
||||||
|
)
|
||||||
|
).all():
|
||||||
|
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)]
|
||||||
|
if not customer.email and not customer.phone:
|
||||||
|
missing.append("email_or_phone")
|
||||||
|
if missing:
|
||||||
|
_open_issue(
|
||||||
|
db,
|
||||||
|
scan,
|
||||||
|
rule_type="missing_required_field",
|
||||||
|
entity_type="customer",
|
||||||
|
entity_id=customer.id,
|
||||||
|
severity="low",
|
||||||
|
summary=f"Missing: {', '.join(missing)}",
|
||||||
|
entity_ref=customer.public_ref,
|
||||||
|
related_refs=[],
|
||||||
|
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||||
|
)
|
||||||
|
|
||||||
|
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
|
||||||
|
missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(vehicle, f)]
|
||||||
|
if missing:
|
||||||
|
_open_issue(
|
||||||
|
db,
|
||||||
|
scan,
|
||||||
|
rule_type="missing_required_field",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
severity="low",
|
||||||
|
summary=f"Missing: {', '.join(missing)}",
|
||||||
|
entity_ref=vehicle.public_ref,
|
||||||
|
related_refs=[],
|
||||||
|
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
|
||||||
|
vehicles = db.scalars(select(Vehicle)).all()
|
||||||
|
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
|
||||||
|
for booking in db.scalars(
|
||||||
|
select(Booking).where(Booking.status.in_(["reserved", "active"]))
|
||||||
|
).all():
|
||||||
|
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
|
||||||
|
|
||||||
|
vehicle_by_id = {v.id: v for v in vehicles}
|
||||||
|
for vehicle_id, bookings in bookings_by_vehicle.items():
|
||||||
|
bookings.sort(key=lambda b: b.starts_at)
|
||||||
|
for i, first in enumerate(bookings):
|
||||||
|
for second in bookings[i + 1 :]:
|
||||||
|
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||||
|
vehicle = vehicle_by_id[vehicle_id]
|
||||||
|
_open_issue(
|
||||||
|
db,
|
||||||
|
scan,
|
||||||
|
rule_type="booking_overlap",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle_id,
|
||||||
|
severity="high",
|
||||||
|
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
|
||||||
|
entity_ref=vehicle.public_ref,
|
||||||
|
related_refs=[first.public_ref, second.public_ref],
|
||||||
|
signals=[
|
||||||
|
{
|
||||||
|
"code": "overlap.reserved_bookings",
|
||||||
|
"params": {"refs": [first.public_ref, second.public_ref]},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
||||||
|
# Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status)
|
||||||
|
# so detection and resolution can never structurally disagree -- see
|
||||||
|
# docs/fleet-ops-correction/vehicle-status-decision-table.md.
|
||||||
|
vehicles = db.scalars(select(Vehicle)).all()
|
||||||
|
for vehicle in vehicles:
|
||||||
|
facts = gather_vehicle_status_facts(db, vehicle)
|
||||||
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
|
if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
|
||||||
|
continue
|
||||||
|
|
||||||
|
signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}]
|
||||||
|
summary = (
|
||||||
|
f"Recommended status: {recommendation.recommended_status}"
|
||||||
|
if recommendation.recommended_status
|
||||||
|
else "Manual review required: active rental conflicts with a blocking condition"
|
||||||
|
)
|
||||||
|
_open_issue(
|
||||||
|
db,
|
||||||
|
scan,
|
||||||
|
rule_type="vehicle_status_conflict",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
severity="high",
|
||||||
|
summary=summary,
|
||||||
|
entity_ref=vehicle.public_ref,
|
||||||
|
related_refs=[
|
||||||
|
*facts.active_booking_refs,
|
||||||
|
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
|
||||||
|
],
|
||||||
|
signals=signals,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_odometer_regressions(
|
||||||
|
db: Session,
|
||||||
|
scan: ScanResult,
|
||||||
|
*,
|
||||||
|
actor_label: str | None,
|
||||||
|
actor_type: str,
|
||||||
|
) -> None:
|
||||||
|
# Compare the chronological history with itself rather than every historical reading
|
||||||
|
# to today's canonical value. That detects imported checkout/return/maintenance
|
||||||
|
# regressions without flagging every legitimate older reading.
|
||||||
|
# Runtime checkout/return/maintenance mutations take the vehicle lock before they
|
||||||
|
# inspect or append DQ-03 evidence. Taking the same lock here makes scan-vs-command
|
||||||
|
# check/merge atomic and prevents a partial-unique race for the open issue.
|
||||||
|
vehicles = {
|
||||||
|
v.id: v for v in db.scalars(select(Vehicle).order_by(Vehicle.id).with_for_update()).all()
|
||||||
|
}
|
||||||
|
readings_by_vehicle: dict[uuid.UUID, list[tuple[datetime, str, int, str, str | None]]] = {}
|
||||||
|
inspections = db.scalars(select(Inspection)).all()
|
||||||
|
return_inspection_booking_ids = {
|
||||||
|
inspection.booking_id for inspection in inspections if inspection.type == "return"
|
||||||
|
}
|
||||||
|
returned_bookings = db.scalars(
|
||||||
|
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
|
||||||
|
).all()
|
||||||
|
booking_ref_by_id = {booking.id: booking.public_ref for booking in returned_bookings}
|
||||||
|
for booking in returned_bookings:
|
||||||
|
# A real return inspection owns the actual reading timestamp. Using the booking's
|
||||||
|
# planned ends_at as a duplicate second reading can make an early return appear to
|
||||||
|
# go backwards after a newer real inspection. Keep booking data only as the legacy
|
||||||
|
# import fallback when no return inspection exists.
|
||||||
|
if booking.id in return_inspection_booking_ids:
|
||||||
|
continue
|
||||||
|
assert booking.end_odometer_km is not None
|
||||||
|
readings_by_vehicle.setdefault(booking.vehicle_id, []).append(
|
||||||
|
(
|
||||||
|
booking.ends_at,
|
||||||
|
booking.public_ref,
|
||||||
|
booking.end_odometer_km,
|
||||||
|
"booking",
|
||||||
|
booking.public_ref,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for inspection in inspections:
|
||||||
|
readings_by_vehicle.setdefault(inspection.vehicle_id, []).append(
|
||||||
|
(
|
||||||
|
inspection.completed_at,
|
||||||
|
inspection.public_ref,
|
||||||
|
inspection.odometer_km,
|
||||||
|
inspection.type,
|
||||||
|
(
|
||||||
|
booking_ref_by_id.get(inspection.booking_id)
|
||||||
|
if inspection.type == "return"
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for record in db.scalars(select(MaintenanceRecord)).all():
|
||||||
|
readings_by_vehicle.setdefault(record.vehicle_id, []).append(
|
||||||
|
(record.occurred_at, record.public_ref, record.odometer_km, "maintenance", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
for vehicle_id, readings in readings_by_vehicle.items():
|
||||||
|
readings.sort(key=lambda reading: (reading[0], reading[1]))
|
||||||
|
highest = readings[0] if readings else None
|
||||||
|
for later in readings[1:]:
|
||||||
|
assert highest is not None
|
||||||
|
if later[2] < highest[2]:
|
||||||
|
vehicle = vehicles[vehicle_id]
|
||||||
|
had_open_issue = _has_open_issue(
|
||||||
|
db,
|
||||||
|
"odometer_regression",
|
||||||
|
"vehicle",
|
||||||
|
vehicle_id,
|
||||||
|
)
|
||||||
|
issue = open_odometer_regression_issue(
|
||||||
|
db,
|
||||||
|
vehicle=vehicle,
|
||||||
|
reading_ref=later[1],
|
||||||
|
reading_km=later[2],
|
||||||
|
canonical_km=highest[2],
|
||||||
|
canonical_ref=highest[1],
|
||||||
|
source_type=later[3],
|
||||||
|
related_refs=list(
|
||||||
|
dict.fromkeys(
|
||||||
|
[highest[1], later[1], *([later[4]] if later[4] is not None else [])]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
correctable_booking_refs=[later[4]] if later[4] is not None else [],
|
||||||
|
public_ref=_new_scan_ref("DQ-SCAN"),
|
||||||
|
actor_label=actor_label,
|
||||||
|
actor_type=actor_type,
|
||||||
|
)
|
||||||
|
if issue is not None and not had_open_issue:
|
||||||
|
scan.bump("odometer_regression")
|
||||||
|
if later[2] > highest[2]:
|
||||||
|
highest = later
|
||||||
|
|
||||||
|
|
||||||
|
def run_scan(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
actor_label: str | None = None,
|
||||||
|
actor_type: str = "user",
|
||||||
|
commit: bool = True,
|
||||||
|
) -> ScanResult:
|
||||||
|
# The check-then-insert work below spans several rules. Serialise whole scans at the
|
||||||
|
# database boundary so API and n8n triggers cannot both observe an empty condition.
|
||||||
|
db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID)))
|
||||||
|
scan = ScanResult()
|
||||||
|
scan_duplicate_customers(db, scan, _open_issue)
|
||||||
|
_scan_missing_required_fields(db, scan)
|
||||||
|
_scan_odometer_regressions(
|
||||||
|
db,
|
||||||
|
scan,
|
||||||
|
actor_label=actor_label,
|
||||||
|
actor_type=actor_type,
|
||||||
|
)
|
||||||
|
_scan_booking_overlaps(db, scan)
|
||||||
|
_scan_vehicle_status_conflicts(db, scan)
|
||||||
|
if actor_label is not None:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_label=actor_label,
|
||||||
|
action="data_quality_scan_run",
|
||||||
|
entity_type="system",
|
||||||
|
metadata={"created": scan.created},
|
||||||
|
)
|
||||||
|
if commit:
|
||||||
|
db.commit()
|
||||||
|
else:
|
||||||
|
db.flush()
|
||||||
|
return scan
|
||||||
|
|
||||||
|
|
||||||
|
def defer_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
issue.status = "deferred"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_deferred",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "deferred"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def reject_issue(db: Session, public_ref: str, actor: CurrentUser) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
issue.status = "rejected"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_rejected",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "rejected"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def provide_missing_fields(
|
||||||
|
db: Session, public_ref: str, fields: dict[str, str], actor: CurrentUser
|
||||||
|
) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "missing_required_field":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_A_MISSING_FIELD_ISSUE",
|
||||||
|
"This issue is not a missing-required-field issue.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
entity: Customer | Vehicle | None
|
||||||
|
if issue.entity_type == "customer":
|
||||||
|
entity = db.get(Customer, issue.entity_id)
|
||||||
|
allowed = {*REQUIRED_CUSTOMER_FIELDS, "email", "phone"}
|
||||||
|
elif issue.entity_type == "vehicle":
|
||||||
|
entity = db.get(Vehicle, issue.entity_id)
|
||||||
|
allowed = set(REQUIRED_VEHICLE_FIELDS)
|
||||||
|
else:
|
||||||
|
raise AppError(
|
||||||
|
"UNSUPPORTED_ENTITY",
|
||||||
|
f"Cannot provide fields for entity type '{issue.entity_type}'.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if entity is None:
|
||||||
|
raise AppError(
|
||||||
|
"ENTITY_NOT_FOUND", "The underlying record could not be found.", status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
invalid = set(fields) - allowed
|
||||||
|
if invalid:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_FIELD",
|
||||||
|
f"Fields not permitted here: {', '.join(sorted(invalid))}.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
if not fields:
|
||||||
|
raise AppError(
|
||||||
|
"NO_FIELDS_PROVIDED", "At least one field must be provided.", status_code=422
|
||||||
|
)
|
||||||
|
|
||||||
|
before = {f: getattr(entity, f) for f in allowed}
|
||||||
|
for field_name, value in fields.items():
|
||||||
|
if not value.strip():
|
||||||
|
raise AppError("EMPTY_VALUE", f"Field '{field_name}' cannot be blank.", status_code=422)
|
||||||
|
setattr(entity, field_name, value.strip())
|
||||||
|
after = {f: getattr(entity, f) for f in allowed}
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_fields_provided",
|
||||||
|
entity_type=issue.entity_type,
|
||||||
|
entity_id=entity.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
metadata={"issue_ref": issue.public_ref},
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(entity, Customer):
|
||||||
|
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(entity, f)]
|
||||||
|
if not entity.email and not entity.phone:
|
||||||
|
missing.append("email_or_phone")
|
||||||
|
else:
|
||||||
|
missing = [f for f in REQUIRED_VEHICLE_FIELDS if not getattr(entity, f)]
|
||||||
|
|
||||||
|
if not missing:
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
issue.evidence_json = {**issue.evidence_json, "summary": f"Missing: {', '.join(missing)}"}
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_booking_overlap(
|
||||||
|
db: Session, public_ref: str, booking_ref: str, note: str | None, actor: CurrentUser
|
||||||
|
) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "booking_overlap":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_AN_OVERLAP_ISSUE", "This issue is not a booking_overlap issue.", status_code=409
|
||||||
|
)
|
||||||
|
related_refs = issue.evidence_json.get("related_refs", [])
|
||||||
|
if booking_ref not in related_refs:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
"booking_ref must be one of this issue's overlapping bookings.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
|
||||||
|
if booking is None:
|
||||||
|
raise AppError("BOOKING_NOT_FOUND", "The booking to block was not found.", status_code=404)
|
||||||
|
if booking.status not in ("reserved", "active"):
|
||||||
|
raise AppError(
|
||||||
|
"BOOKING_NOT_ACTIVE",
|
||||||
|
f"Booking is '{booking.status}'; only a reserved or active booking can be blocked.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
before = {"status": booking.status}
|
||||||
|
booking.status = "blocked"
|
||||||
|
|
||||||
|
# Verify the minimal safe resolution actually removed the conflict: no two
|
||||||
|
# reserved/active bookings for this vehicle should still overlap. The session has
|
||||||
|
# autoflush disabled, so exclude the just-blocked booking by id rather than relying
|
||||||
|
# on the in-memory status change being visible to this query.
|
||||||
|
remaining = db.scalars(
|
||||||
|
select(Booking).where(
|
||||||
|
Booking.vehicle_id == booking.vehicle_id,
|
||||||
|
Booking.status.in_(["reserved", "active"]),
|
||||||
|
Booking.public_ref.in_(related_refs),
|
||||||
|
Booking.id != booking.id,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
for i, first in enumerate(remaining):
|
||||||
|
for second in remaining[i + 1 :]:
|
||||||
|
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||||
|
raise AppError(
|
||||||
|
"OVERLAP_STILL_PRESENT",
|
||||||
|
"Blocking this booking did not remove the overlap; another commitment remains.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_booking_blocked",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after={"status": booking.status},
|
||||||
|
metadata={"issue_ref": issue.public_ref, "note": note},
|
||||||
|
)
|
||||||
|
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def _load_vehicle_status_conflict_issue(
|
||||||
|
db: Session, public_ref: str, *, lock: bool = True
|
||||||
|
) -> DataQualityIssue:
|
||||||
|
issue = _load_open_issue(db, public_ref, lock=lock)
|
||||||
|
if issue.rule_type != "vehicle_status_conflict":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_A_STATUS_CONFLICT_ISSUE",
|
||||||
|
"This issue is not a vehicle_status_conflict issue.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def preview_vehicle_status_recommendation(
|
||||||
|
db: Session, public_ref: str
|
||||||
|
) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]:
|
||||||
|
"""Non-mutating: computes and returns the recommendation only. Never resolves the
|
||||||
|
issue, never writes an audit event, never queues automation -- safe to call as often
|
||||||
|
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
|
||||||
|
issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False)
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
|
)
|
||||||
|
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
|
token = compute_recommendation_token(vehicle, facts)
|
||||||
|
return issue, vehicle, recommendation, token
|
||||||
|
|
||||||
|
|
||||||
|
def apply_recommended_status(
|
||||||
|
db: Session, public_ref: str, actor: CurrentUser, expected_token: str
|
||||||
|
) -> tuple[DataQualityIssue, str, str]:
|
||||||
|
issue = _load_vehicle_status_conflict_issue(db, public_ref)
|
||||||
|
# Lock the vehicle row for the remainder of this transaction so a concurrent apply
|
||||||
|
# (or return/checkout) can't race between our fact-gathering and the write below.
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
|
current_token = compute_recommendation_token(vehicle, facts)
|
||||||
|
|
||||||
|
if current_token != expected_token:
|
||||||
|
raise AppError(
|
||||||
|
"RECOMMENDATION_STALE",
|
||||||
|
"The underlying facts changed since this recommendation was shown; "
|
||||||
|
"review the recommendation again before applying it.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if recommendation.manual_review_required or not recommendation.safe_to_apply:
|
||||||
|
raise AppError(
|
||||||
|
"MANUAL_REVIEW_REQUIRED",
|
||||||
|
"This vehicle's state requires manual review; no automatic status change is safe.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if recommendation.recommended_status is None:
|
||||||
|
raise AppError(
|
||||||
|
"NO_CONFLICT_DETECTED",
|
||||||
|
"The current vehicle state no longer conflicts; nothing to apply.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
new_status = recommendation.recommended_status
|
||||||
|
reason_code = recommendation.recommendation_code
|
||||||
|
|
||||||
|
before = {"operational_status": vehicle.operational_status}
|
||||||
|
vehicle.operational_status = new_status
|
||||||
|
vehicle.version += 1
|
||||||
|
|
||||||
|
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
|
||||||
|
# applying this change actually leaves no conflict -- never trust the pre-computed
|
||||||
|
# recommendation alone for the post-condition.
|
||||||
|
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
post_check = evaluate_vehicle_status(vehicle, post_facts)
|
||||||
|
if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,):
|
||||||
|
raise AppError(
|
||||||
|
"CONFLICT_STILL_PRESENT",
|
||||||
|
"Applying the recommended status did not resolve the conflict.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_status_applied",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after={"operational_status": vehicle.operational_status},
|
||||||
|
metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
|
||||||
|
)
|
||||||
|
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue, new_status, reason_code
|
||||||
|
|
||||||
|
|
||||||
|
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
|
||||||
|
# Mirrors the column lengths in app/models/customer.py so an override can never fail with
|
||||||
|
# a database DataError (500) instead of a validation error.
|
||||||
|
_MERGEABLE_FIELD_MAX_LENGTH = {
|
||||||
|
"first_name": 80,
|
||||||
|
"last_name": 80,
|
||||||
|
"email": 200,
|
||||||
|
"phone": 40,
|
||||||
|
"postal_code": 20,
|
||||||
|
"city": 120,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def merge_customers(
|
||||||
|
db: Session,
|
||||||
|
public_ref: str,
|
||||||
|
survivor_ref: str,
|
||||||
|
field_overrides: dict[str, str] | None,
|
||||||
|
actor: CurrentUser,
|
||||||
|
) -> dict:
|
||||||
|
issue = _load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "possible_duplicate_customer":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_A_DUPLICATE_ISSUE",
|
||||||
|
"This issue is not a possible-duplicate-customer issue.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
entity_ref = issue.evidence_json.get("entity_ref")
|
||||||
|
related_refs = issue.evidence_json.get("related_refs", [])
|
||||||
|
candidate_refs = {entity_ref, *related_refs}
|
||||||
|
if survivor_ref not in candidate_refs:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_SURVIVOR",
|
||||||
|
"The survivor reference must be one of the two customers in this issue.",
|
||||||
|
status_code=422,
|
||||||
|
details={"candidates": sorted(candidate_refs)},
|
||||||
|
)
|
||||||
|
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
|
||||||
|
|
||||||
|
# Lock both rows in a deterministic order (by public_ref) so two concurrent merges
|
||||||
|
# touching the same customers serialise instead of deadlocking or double-merging.
|
||||||
|
survivor = None
|
||||||
|
loser = None
|
||||||
|
for ref in sorted((survivor_ref, loser_ref)):
|
||||||
|
customer = db.scalar(select(Customer).where(Customer.public_ref == ref).with_for_update())
|
||||||
|
if ref == survivor_ref:
|
||||||
|
survivor = customer
|
||||||
|
else:
|
||||||
|
loser = customer
|
||||||
|
if survivor is None or loser is None:
|
||||||
|
raise AppError(
|
||||||
|
"CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404
|
||||||
|
)
|
||||||
|
if survivor.merged_into_customer_id is not None or loser.merged_into_customer_id is not None:
|
||||||
|
raise AppError(
|
||||||
|
"CUSTOMER_ALREADY_MERGED",
|
||||||
|
"One of the customers has already been merged into another record.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
before = {
|
||||||
|
"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS},
|
||||||
|
"loser": {f: getattr(loser, f) for f in MERGEABLE_FIELDS},
|
||||||
|
}
|
||||||
|
|
||||||
|
for field_name, value in (field_overrides or {}).items():
|
||||||
|
if field_name not in MERGEABLE_FIELDS:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422
|
||||||
|
)
|
||||||
|
cleaned = value.strip() if isinstance(value, str) else value
|
||||||
|
max_length = _MERGEABLE_FIELD_MAX_LENGTH[field_name]
|
||||||
|
if not cleaned or len(cleaned) > max_length:
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_FIELD_OVERRIDE",
|
||||||
|
f"Field '{field_name}' must be 1 to {max_length} characters.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
setattr(survivor, field_name, cleaned)
|
||||||
|
|
||||||
|
rewired = db.execute(
|
||||||
|
update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id)
|
||||||
|
)
|
||||||
|
rewired_count: int = rewired.rowcount # type: ignore[attr-defined]
|
||||||
|
loser.merged_into_customer_id = survivor.id
|
||||||
|
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="customer_merged",
|
||||||
|
entity_type="customer",
|
||||||
|
entity_id=survivor.id,
|
||||||
|
before=before,
|
||||||
|
after={"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS}},
|
||||||
|
metadata={
|
||||||
|
"loser_ref": loser_ref,
|
||||||
|
"survivor_ref": survivor_ref,
|
||||||
|
"rewired_bookings": rewired_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"issue_ref": issue.public_ref,
|
||||||
|
"survivor_ref": survivor_ref,
|
||||||
|
"loser_ref": loser_ref,
|
||||||
|
"rewired_bookings": rewired_count,
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
|
||||||
|
|
||||||
|
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
|
||||||
|
"""Return the local operational SLA deadline for a newly detected issue."""
|
||||||
|
return detected_at + {
|
||||||
|
"high": timedelta(hours=4),
|
||||||
|
"medium": timedelta(days=1),
|
||||||
|
"low": timedelta(days=3),
|
||||||
|
}.get(severity, timedelta(days=1))
|
||||||
|
|
||||||
|
|
||||||
|
def has_open_issue(
|
||||||
|
db: Session,
|
||||||
|
rule_type: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
db.scalar(
|
||||||
|
select(DataQualityIssue.id).where(
|
||||||
|
DataQualityIssue.rule_type == rule_type,
|
||||||
|
DataQualityIssue.entity_type == entity_type,
|
||||||
|
DataQualityIssue.entity_id == entity_id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def new_scan_ref(prefix: str) -> str:
|
||||||
|
"""Generate a stable human-readable prefix with a concurrent-safe suffix."""
|
||||||
|
return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_open_issue(db: Session, public_ref: str, *, lock: bool = True) -> DataQualityIssue:
|
||||||
|
statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
|
||||||
|
if lock:
|
||||||
|
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||||||
|
issue = db.scalar(statement)
|
||||||
|
if issue is None:
|
||||||
|
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
|
||||||
|
if issue.status != "open":
|
||||||
|
raise AppError(
|
||||||
|
"ISSUE_NOT_OPEN",
|
||||||
|
f"Issue is '{issue.status}', not 'open'.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
return issue
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from typing import Protocol, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.customer import Customer
|
||||||
|
|
||||||
|
DUPLICATE_THRESHOLD = 70
|
||||||
|
|
||||||
|
|
||||||
|
class ScanAccumulator(Protocol):
|
||||||
|
def bump(self, rule_type: str) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
ScanTypeContra = TypeVar("ScanTypeContra", bound=ScanAccumulator, contravariant=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenIssue(Protocol[ScanTypeContra]):
|
||||||
|
def __call__(
|
||||||
|
self,
|
||||||
|
db: Session,
|
||||||
|
scan: ScanTypeContra,
|
||||||
|
*,
|
||||||
|
rule_type: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID,
|
||||||
|
severity: str,
|
||||||
|
summary: str,
|
||||||
|
entity_ref: str,
|
||||||
|
related_refs: list[str],
|
||||||
|
signals: list[dict] | None = None,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(value: str | None) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def scan_duplicate_customers[ScanType: ScanAccumulator](
|
||||||
|
db: Session, scan: ScanType, open_issue: OpenIssue[ScanType]
|
||||||
|
) -> None:
|
||||||
|
customers = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Customer).where(
|
||||||
|
Customer.merged_into_customer_id.is_(None),
|
||||||
|
Customer.anonymized_at.is_(None),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
customers.sort(key=lambda customer: customer.public_ref)
|
||||||
|
|
||||||
|
# The threshold cannot be reached without an exact email (60 points) or phone
|
||||||
|
# (50 points). Block on normalized identifiers so this remains linear for the
|
||||||
|
# overwhelmingly common case and only scores plausible pairs.
|
||||||
|
candidate_pairs: set[tuple[int, int]] = set()
|
||||||
|
for attribute in ("email", "phone"):
|
||||||
|
blocks: dict[str, list[int]] = {}
|
||||||
|
for index, customer in enumerate(customers):
|
||||||
|
key = _normalize(getattr(customer, attribute))
|
||||||
|
if key:
|
||||||
|
blocks.setdefault(key, []).append(index)
|
||||||
|
for indices in blocks.values():
|
||||||
|
for offset, left in enumerate(indices):
|
||||||
|
candidate_pairs.update((left, right) for right in indices[offset + 1 :])
|
||||||
|
|
||||||
|
for left, right in sorted(candidate_pairs):
|
||||||
|
a = customers[left]
|
||||||
|
b = customers[right]
|
||||||
|
score = 0
|
||||||
|
signals: list[dict] = []
|
||||||
|
summary_parts: list[str] = []
|
||||||
|
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
|
||||||
|
score += 60
|
||||||
|
signals.append({"code": "duplicate.exact_email"})
|
||||||
|
summary_parts.append("exact email")
|
||||||
|
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
|
||||||
|
score += 50
|
||||||
|
signals.append({"code": "duplicate.exact_phone"})
|
||||||
|
summary_parts.append("exact phone")
|
||||||
|
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
|
||||||
|
score += 10
|
||||||
|
signals.append({"code": "duplicate.same_postal_code"})
|
||||||
|
summary_parts.append("exact postal code")
|
||||||
|
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
|
||||||
|
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
|
||||||
|
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
||||||
|
if ratio >= 0.5:
|
||||||
|
score += round(ratio * 30)
|
||||||
|
signals.append({"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}})
|
||||||
|
summary_parts.append("similar name")
|
||||||
|
|
||||||
|
if score >= DUPLICATE_THRESHOLD:
|
||||||
|
open_issue(
|
||||||
|
db,
|
||||||
|
scan,
|
||||||
|
rule_type="possible_duplicate_customer",
|
||||||
|
entity_type="customer",
|
||||||
|
entity_id=a.id,
|
||||||
|
severity="high",
|
||||||
|
summary="; ".join(summary_parts) + f" (score {score})",
|
||||||
|
entity_ref=a.public_ref,
|
||||||
|
related_refs=[b.public_ref],
|
||||||
|
signals=signals,
|
||||||
|
)
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality_common import issue_due_at, load_open_issue, new_scan_ref
|
||||||
|
|
||||||
|
|
||||||
|
def _odometer_fingerprint(
|
||||||
|
*,
|
||||||
|
source_type: str,
|
||||||
|
later_ref: str,
|
||||||
|
later_km: int,
|
||||||
|
) -> dict[str, str | int]:
|
||||||
|
"""Stable identity for one reviewed regression, independent of issue IDs."""
|
||||||
|
return {
|
||||||
|
"source_type": source_type,
|
||||||
|
"later_ref": later_ref,
|
||||||
|
"later_km": later_km,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _was_odometer_regression_retained(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
fingerprint: dict[str, str | int],
|
||||||
|
) -> bool:
|
||||||
|
reviewed = db.scalars(
|
||||||
|
select(DataQualityIssue).where(
|
||||||
|
DataQualityIssue.rule_type == "odometer_regression",
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle_id,
|
||||||
|
DataQualityIssue.status == "resolved",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return any(
|
||||||
|
issue.evidence_json.get("resolution_decision") == "retain_canonical"
|
||||||
|
and fingerprint in issue.evidence_json.get("retained_odometer_fingerprints", [])
|
||||||
|
for issue in reviewed
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def open_odometer_regression_issue(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
vehicle: Vehicle,
|
||||||
|
reading_ref: str,
|
||||||
|
reading_km: int,
|
||||||
|
canonical_km: int,
|
||||||
|
source_type: str,
|
||||||
|
related_refs: list[str],
|
||||||
|
correctable_booking_refs: list[str] | None = None,
|
||||||
|
canonical_ref: str | None = None,
|
||||||
|
detected_at: datetime | None = None,
|
||||||
|
public_ref: str | None = None,
|
||||||
|
actor_label: str | None = None,
|
||||||
|
actor_type: str = "user",
|
||||||
|
correlation_id: uuid.UUID | None = None,
|
||||||
|
) -> DataQualityIssue | None:
|
||||||
|
"""Open one explainable DQ-03 issue while the caller holds the vehicle lock."""
|
||||||
|
if reading_km >= canonical_km:
|
||||||
|
return None
|
||||||
|
earlier_ref = canonical_ref or vehicle.public_ref
|
||||||
|
fingerprint = _odometer_fingerprint(
|
||||||
|
source_type=source_type,
|
||||||
|
later_ref=reading_ref,
|
||||||
|
later_km=reading_km,
|
||||||
|
)
|
||||||
|
# A manager's explicit "retain canonical" decision acknowledges this exact source
|
||||||
|
# fact. Reopening it on every scheduled scan would create churn; only changed or new
|
||||||
|
# evidence (and therefore a different fingerprint) is actionable again.
|
||||||
|
if _was_odometer_regression_retained(
|
||||||
|
db,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
fingerprint=fingerprint,
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
existing = db.scalar(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(
|
||||||
|
DataQualityIssue.rule_type == "odometer_regression",
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
)
|
||||||
|
.with_for_update()
|
||||||
|
.execution_options(populate_existing=True)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
before_related_refs = list(existing.evidence_json.get("related_refs", []))
|
||||||
|
before_correctable_refs = list(existing.evidence_json.get("correctable_booking_refs", []))
|
||||||
|
new_signal = {
|
||||||
|
"code": "odometer.regression",
|
||||||
|
"source_type": source_type,
|
||||||
|
"params": {
|
||||||
|
"later_ref": reading_ref,
|
||||||
|
"later_km": reading_km,
|
||||||
|
"earlier_ref": earlier_ref,
|
||||||
|
"earlier_km": canonical_km,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
existing_signals = list(existing.evidence_json.get("signals", []))
|
||||||
|
signal_was_new = not any(
|
||||||
|
isinstance(signal, dict)
|
||||||
|
and isinstance(signal.get("params"), dict)
|
||||||
|
and _odometer_fingerprint(
|
||||||
|
source_type=str(
|
||||||
|
signal.get("source_type", existing.evidence_json.get("source_type"))
|
||||||
|
),
|
||||||
|
later_ref=str(signal["params"].get("later_ref")),
|
||||||
|
later_km=signal["params"].get("later_km"),
|
||||||
|
)
|
||||||
|
== fingerprint
|
||||||
|
for signal in existing_signals
|
||||||
|
if isinstance(signal, dict)
|
||||||
|
and isinstance(signal.get("params"), dict)
|
||||||
|
and isinstance(signal["params"].get("later_km"), int)
|
||||||
|
)
|
||||||
|
if signal_was_new:
|
||||||
|
existing_signals.append(new_signal)
|
||||||
|
merged_related_refs = list(
|
||||||
|
dict.fromkeys([*before_related_refs, *(related_refs if signal_was_new else [])])
|
||||||
|
)
|
||||||
|
merged_correctable_refs = list(
|
||||||
|
dict.fromkeys([*before_correctable_refs, *(correctable_booking_refs or [])])
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not signal_was_new
|
||||||
|
and merged_related_refs == before_related_refs
|
||||||
|
and merged_correctable_refs == before_correctable_refs
|
||||||
|
):
|
||||||
|
return existing
|
||||||
|
source_types = list(existing.evidence_json.get("source_types", []))
|
||||||
|
previous_source_type = existing.evidence_json.get("source_type")
|
||||||
|
if (
|
||||||
|
isinstance(previous_source_type, str)
|
||||||
|
and previous_source_type != "multiple"
|
||||||
|
and previous_source_type not in source_types
|
||||||
|
):
|
||||||
|
source_types.append(previous_source_type)
|
||||||
|
if source_type not in source_types:
|
||||||
|
source_types.append(source_type)
|
||||||
|
existing.evidence_json = {
|
||||||
|
**existing.evidence_json,
|
||||||
|
"summary": (
|
||||||
|
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
|
||||||
|
f"the canonical {canonical_km} km for {vehicle.public_ref}."
|
||||||
|
),
|
||||||
|
"related_refs": merged_related_refs,
|
||||||
|
"correctable_booking_refs": merged_correctable_refs,
|
||||||
|
"source_type": source_type if len(source_types) == 1 else "multiple",
|
||||||
|
"source_types": source_types,
|
||||||
|
"signals": existing_signals,
|
||||||
|
}
|
||||||
|
if actor_label is not None and (
|
||||||
|
merged_related_refs != before_related_refs
|
||||||
|
or merged_correctable_refs != before_correctable_refs
|
||||||
|
or signal_was_new
|
||||||
|
):
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_label=actor_label,
|
||||||
|
action="data_quality_issue_evidence_updated",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=existing.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={
|
||||||
|
"related_refs": before_related_refs,
|
||||||
|
"correctable_booking_refs": before_correctable_refs,
|
||||||
|
},
|
||||||
|
after={
|
||||||
|
"related_refs": merged_related_refs,
|
||||||
|
"correctable_booking_refs": merged_correctable_refs,
|
||||||
|
},
|
||||||
|
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
now = detected_at or datetime.now(UTC)
|
||||||
|
previous = db.scalar(
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(
|
||||||
|
DataQualityIssue.rule_type == "odometer_regression",
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status != "open",
|
||||||
|
)
|
||||||
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
|
)
|
||||||
|
evidence = {
|
||||||
|
"summary": (
|
||||||
|
f"{source_type.title()} {reading_ref} recorded {reading_km} km, below "
|
||||||
|
f"the canonical {canonical_km} km for {vehicle.public_ref}."
|
||||||
|
),
|
||||||
|
"entity_ref": vehicle.public_ref,
|
||||||
|
"related_refs": related_refs,
|
||||||
|
"correctable_booking_refs": correctable_booking_refs or [],
|
||||||
|
"source_type": source_type,
|
||||||
|
"source_types": [source_type],
|
||||||
|
"signals": [
|
||||||
|
{
|
||||||
|
"code": "odometer.regression",
|
||||||
|
"source_type": source_type,
|
||||||
|
"params": {
|
||||||
|
"later_ref": reading_ref,
|
||||||
|
"later_km": reading_km,
|
||||||
|
"earlier_ref": earlier_ref,
|
||||||
|
"earlier_km": canonical_km,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if previous is not None:
|
||||||
|
evidence["reopened_from"] = previous.public_ref
|
||||||
|
evidence["previous_decision"] = previous.status
|
||||||
|
issue = DataQualityIssue(
|
||||||
|
public_ref=public_ref or new_scan_ref("DQ-ODO"),
|
||||||
|
rule_type="odometer_regression",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
severity="medium",
|
||||||
|
status="open",
|
||||||
|
evidence_json=evidence,
|
||||||
|
proposed_action_json={},
|
||||||
|
detected_at=now,
|
||||||
|
due_at=issue_due_at(now, "medium"),
|
||||||
|
)
|
||||||
|
db.add(issue)
|
||||||
|
db.flush()
|
||||||
|
if actor_label is not None:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type=actor_type,
|
||||||
|
actor_label=actor_label,
|
||||||
|
action="data_quality_issue_created",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
after={"status": "open", "rule_type": "odometer_regression"},
|
||||||
|
metadata={"vehicle_ref": vehicle.public_ref, "reading_ref": reading_ref},
|
||||||
|
)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_odometer_regression(
|
||||||
|
db: Session, public_ref: str, body: ResolveOdometerRegressionRequest, actor: CurrentUser
|
||||||
|
) -> DataQualityIssue:
|
||||||
|
# Read the routing data without a row lock first. The canonical mutation order is
|
||||||
|
# booking -> vehicle -> issue -> inspection everywhere, matching checkout/return.
|
||||||
|
# Locking the issue before the booking creates a resolver-vs-return deadlock.
|
||||||
|
issue_snapshot = load_open_issue(db, public_ref, lock=False)
|
||||||
|
if issue_snapshot.rule_type != "odometer_regression":
|
||||||
|
raise AppError(
|
||||||
|
"NOT_AN_ODOMETER_ISSUE",
|
||||||
|
"This issue is not an odometer_regression issue.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
def correctable_refs(issue: DataQualityIssue) -> list[str]:
|
||||||
|
if "correctable_booking_refs" in issue.evidence_json:
|
||||||
|
refs = issue.evidence_json.get("correctable_booking_refs", [])
|
||||||
|
else:
|
||||||
|
# Backward compatibility for issues created before source-aware evidence.
|
||||||
|
refs = [
|
||||||
|
ref
|
||||||
|
for ref in issue.evidence_json.get("related_refs", [])
|
||||||
|
if isinstance(ref, str) and ref.startswith("BK-")
|
||||||
|
]
|
||||||
|
return [ref for ref in refs if isinstance(ref, str)]
|
||||||
|
|
||||||
|
booking: Booking | None = None
|
||||||
|
if body.decision != "retain_canonical":
|
||||||
|
if body.booking_ref not in correctable_refs(issue_snapshot):
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
"booking_ref must be one of this issue's related bookings.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
if body.corrected_odometer_km is None:
|
||||||
|
raise AppError(
|
||||||
|
"CORRECTED_VALUE_REQUIRED",
|
||||||
|
"corrected_odometer_km is required when correcting a reading.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
booking = db.scalar(
|
||||||
|
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
|
||||||
|
)
|
||||||
|
if booking is None:
|
||||||
|
raise AppError(
|
||||||
|
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
|
||||||
|
)
|
||||||
|
vehicle = db.scalar(
|
||||||
|
select(Vehicle).where(Vehicle.id == issue_snapshot.entity_id).with_for_update()
|
||||||
|
)
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
|
)
|
||||||
|
|
||||||
|
# A return or scan may have appended evidence while we waited for the domain locks.
|
||||||
|
# Lock and refresh the issue only now, then revalidate every decision against that
|
||||||
|
# current evidence instead of resolving a stale snapshot.
|
||||||
|
issue = load_open_issue(db, public_ref)
|
||||||
|
if issue.rule_type != "odometer_regression" or issue.entity_id != vehicle.id:
|
||||||
|
raise AppError(
|
||||||
|
"ISSUE_CHANGED",
|
||||||
|
"The issue changed while the correction was being prepared. Review it again.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if booking is not None:
|
||||||
|
if booking.vehicle_id != vehicle.id or booking.public_ref not in correctable_refs(issue):
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_REFERENCE",
|
||||||
|
"booking_ref must be one of this issue's related bookings.",
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
|
||||||
|
if body.decision == "retain_canonical":
|
||||||
|
retained_fingerprints: list[dict[str, str | int]] = []
|
||||||
|
fallback_source_type = str(issue.evidence_json.get("source_type", "unknown"))
|
||||||
|
for signal in issue.evidence_json.get("signals", []):
|
||||||
|
if not isinstance(signal, dict) or signal.get("code") != "odometer.regression":
|
||||||
|
continue
|
||||||
|
params = signal.get("params")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
continue
|
||||||
|
source_type = signal.get("source_type", fallback_source_type)
|
||||||
|
later_ref = params.get("later_ref")
|
||||||
|
later_km = params.get("later_km")
|
||||||
|
earlier_ref = params.get("earlier_ref")
|
||||||
|
earlier_km = params.get("earlier_km")
|
||||||
|
if (
|
||||||
|
isinstance(source_type, str)
|
||||||
|
and isinstance(later_ref, str)
|
||||||
|
and isinstance(later_km, int)
|
||||||
|
and isinstance(earlier_ref, str)
|
||||||
|
and isinstance(earlier_km, int)
|
||||||
|
):
|
||||||
|
retained_fingerprints.append(
|
||||||
|
_odometer_fingerprint(
|
||||||
|
source_type=source_type,
|
||||||
|
later_ref=later_ref,
|
||||||
|
later_km=later_km,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
issue.evidence_json = {
|
||||||
|
**issue.evidence_json,
|
||||||
|
"resolution_decision": "retain_canonical",
|
||||||
|
"retained_odometer_fingerprints": retained_fingerprints,
|
||||||
|
}
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_odometer_retained",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
metadata={
|
||||||
|
"issue_ref": issue.public_ref,
|
||||||
|
"canonical_odometer_km": vehicle.odometer_km,
|
||||||
|
"retained_fingerprint_count": len(retained_fingerprints),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert booking is not None and body.corrected_odometer_km is not None
|
||||||
|
# Never silently lower the canonical odometer: a correction must be at or above
|
||||||
|
# the current canonical value, otherwise it would just create a new regression.
|
||||||
|
if body.corrected_odometer_km < vehicle.odometer_km:
|
||||||
|
raise AppError(
|
||||||
|
"CORRECTION_BELOW_CANONICAL",
|
||||||
|
(
|
||||||
|
f"Corrected value {body.corrected_odometer_km} km is still below the "
|
||||||
|
f"canonical {vehicle.odometer_km} km; it would not resolve the regression."
|
||||||
|
),
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A returned booking has two persisted representations of the same reading.
|
||||||
|
# Correct every return inspection for the selected booking, including historical
|
||||||
|
# seed rows whose old issue evidence did not yet cite the inspection explicitly.
|
||||||
|
related_inspections = db.scalars(
|
||||||
|
select(Inspection)
|
||||||
|
.where(Inspection.booking_id == booking.id, Inspection.type == "return")
|
||||||
|
.with_for_update()
|
||||||
|
).all()
|
||||||
|
before = {
|
||||||
|
"booking_end_odometer_km": booking.end_odometer_km,
|
||||||
|
"vehicle_odometer_km": vehicle.odometer_km,
|
||||||
|
"inspection_odometer_km": {
|
||||||
|
inspection.public_ref: inspection.odometer_km for inspection in related_inspections
|
||||||
|
},
|
||||||
|
}
|
||||||
|
booking.end_odometer_km = body.corrected_odometer_km
|
||||||
|
for inspection in related_inspections:
|
||||||
|
inspection.odometer_km = body.corrected_odometer_km
|
||||||
|
vehicle.odometer_km = body.corrected_odometer_km
|
||||||
|
vehicle.version += 1
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_odometer_corrected",
|
||||||
|
entity_type="vehicle",
|
||||||
|
entity_id=vehicle.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before=before,
|
||||||
|
after={
|
||||||
|
"booking_end_odometer_km": booking.end_odometer_km,
|
||||||
|
"vehicle_odometer_km": vehicle.odometer_km,
|
||||||
|
"inspection_odometer_km": {
|
||||||
|
inspection.public_ref: inspection.odometer_km
|
||||||
|
for inspection in related_inspections
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metadata={"issue_ref": issue.public_ref, "booking_ref": booking.public_ref},
|
||||||
|
)
|
||||||
|
|
||||||
|
has_remaining_evidence = False
|
||||||
|
if booking is not None:
|
||||||
|
target_refs = {booking.public_ref, *(item.public_ref for item in related_inspections)}
|
||||||
|
before_related = [
|
||||||
|
ref for ref in issue.evidence_json.get("related_refs", []) if isinstance(ref, str)
|
||||||
|
]
|
||||||
|
before_correctable = correctable_refs(issue)
|
||||||
|
before_signals = [
|
||||||
|
signal for signal in issue.evidence_json.get("signals", []) if isinstance(signal, dict)
|
||||||
|
]
|
||||||
|
remaining_signals = [
|
||||||
|
signal
|
||||||
|
for signal in before_signals
|
||||||
|
if not (
|
||||||
|
isinstance(signal.get("params"), dict)
|
||||||
|
and signal["params"].get("later_ref") in target_refs
|
||||||
|
)
|
||||||
|
]
|
||||||
|
remaining_related = [ref for ref in before_related if ref not in target_refs]
|
||||||
|
remaining_correctable = [ref for ref in before_correctable if ref != booking.public_ref]
|
||||||
|
has_remaining_evidence = bool(remaining_signals or remaining_correctable)
|
||||||
|
issue.evidence_json = {
|
||||||
|
**issue.evidence_json,
|
||||||
|
"summary": (
|
||||||
|
"Additional odometer regression evidence remains for review."
|
||||||
|
if has_remaining_evidence
|
||||||
|
else issue.evidence_json.get("summary", "Odometer reading corrected.")
|
||||||
|
),
|
||||||
|
"related_refs": remaining_related,
|
||||||
|
"correctable_booking_refs": remaining_correctable,
|
||||||
|
"signals": remaining_signals,
|
||||||
|
}
|
||||||
|
if has_remaining_evidence:
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_partially_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={
|
||||||
|
"related_refs": before_related,
|
||||||
|
"correctable_booking_refs": before_correctable,
|
||||||
|
"signal_count": len(before_signals),
|
||||||
|
},
|
||||||
|
after={
|
||||||
|
"related_refs": remaining_related,
|
||||||
|
"correctable_booking_refs": remaining_correctable,
|
||||||
|
"signal_count": len(remaining_signals),
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"decision": body.decision,
|
||||||
|
"booking_ref": booking.public_ref,
|
||||||
|
"note": body.note,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_remaining_evidence:
|
||||||
|
issue.status = "resolved"
|
||||||
|
issue.resolved_at = datetime.now(UTC)
|
||||||
|
issue.resolved_by = actor.display_name
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="data_quality_issue_resolved",
|
||||||
|
entity_type="data_quality_issue",
|
||||||
|
entity_id=issue.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "open"},
|
||||||
|
after={"status": "resolved"},
|
||||||
|
metadata={"decision": body.decision, "note": body.note},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return issue
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.outbox import OutboxEvent, is_demo_scenario_failure
|
||||||
|
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
|
||||||
|
from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
|
||||||
|
from app.services.knowledge import KnowledgeHealth, get_knowledge_provider
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
|
||||||
|
|
||||||
|
|
||||||
|
def _knowledge_scenario_ready(health: KnowledgeHealth) -> bool:
|
||||||
|
"""Require a reachable provider and independently verified indexed documents.
|
||||||
|
|
||||||
|
``source_document_count`` describes local Markdown files, while a sync report only
|
||||||
|
describes an upload attempt. Neither proves that the active provider can retrieve a
|
||||||
|
corpus, so an unavailable verification count remains honestly not ready.
|
||||||
|
"""
|
||||||
|
return bool(
|
||||||
|
health.available and health.document_count is not None and health.document_count > 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
|
||||||
|
marker = db.scalar(
|
||||||
|
select(AuditEvent)
|
||||||
|
.where(AuditEvent.action == "demo_data_seeded")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
)
|
||||||
|
if marker is None:
|
||||||
|
return None, None
|
||||||
|
metadata = marker.metadata_json or {}
|
||||||
|
return marker.occurred_at, metadata.get("anchor_date")
|
||||||
|
|
||||||
|
|
||||||
|
def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||||
|
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
|
||||||
|
duplicate_issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
|
||||||
|
)
|
||||||
|
overlap_issue = db.scalar(
|
||||||
|
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
|
||||||
|
)
|
||||||
|
failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID))
|
||||||
|
knowledge_health = get_knowledge_provider().health()
|
||||||
|
|
||||||
|
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
|
||||||
|
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
|
||||||
|
# languages. This service only emits stable identifiers and message codes -- never
|
||||||
|
# display prose -- per the message_code + params architecture used across the app.
|
||||||
|
return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
|
||||||
|
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
|
||||||
|
overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
|
||||||
|
automation_ready = bool(failed_run and is_demo_scenario_failure(failed_run))
|
||||||
|
knowledge_ready = _knowledge_scenario_ready(knowledge_health)
|
||||||
|
|
||||||
|
return [
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="return-anomaly",
|
||||||
|
estimated_minutes=3,
|
||||||
|
required_roles=["rental_employee", "operations_manager"],
|
||||||
|
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
|
||||||
|
ready=return_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if return_ready
|
||||||
|
else "bookingNotFound"
|
||||||
|
if booking is None
|
||||||
|
else "bookingAlreadyProcessed"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="duplicate-customer",
|
||||||
|
estimated_minutes=3,
|
||||||
|
required_roles=["operations_manager"],
|
||||||
|
start_path=(
|
||||||
|
f"/data-quality/{duplicate_issue.public_ref}"
|
||||||
|
if duplicate_issue
|
||||||
|
else "/data-quality"
|
||||||
|
),
|
||||||
|
ready=duplicate_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if duplicate_ready
|
||||||
|
else "duplicateIssueNotFound"
|
||||||
|
if duplicate_issue is None
|
||||||
|
else "issueAlreadyResolved"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="booking-overlap",
|
||||||
|
estimated_minutes=2,
|
||||||
|
required_roles=["operations_manager"],
|
||||||
|
start_path=(
|
||||||
|
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
|
||||||
|
),
|
||||||
|
ready=overlap_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if overlap_ready
|
||||||
|
else "overlapIssueNotFound"
|
||||||
|
if overlap_issue is None
|
||||||
|
else "issueAlreadyResolved"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="automation-retry",
|
||||||
|
estimated_minutes=2,
|
||||||
|
required_roles=["operations_manager"],
|
||||||
|
start_path="/automation",
|
||||||
|
ready=automation_ready,
|
||||||
|
blocked_reason_code=(
|
||||||
|
None
|
||||||
|
if automation_ready
|
||||||
|
else "failedEventNotFound"
|
||||||
|
if failed_run is None
|
||||||
|
else "eventAlreadyRecovered"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DemoScenarioOut(
|
||||||
|
id="knowledge-question",
|
||||||
|
estimated_minutes=2,
|
||||||
|
required_roles=["rental_employee", "operations_manager"],
|
||||||
|
start_path="/knowledge",
|
||||||
|
ready=knowledge_ready,
|
||||||
|
blocked_reason_code=None if knowledge_ready else "knowledgeUnavailable",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||||
|
n8n = derive_n8n_status(db)
|
||||||
|
knowledge_health = get_knowledge_provider().health()
|
||||||
|
mcp_hub = derive_mcp_hub_status(db)
|
||||||
|
|
||||||
|
return [
|
||||||
|
DemoIntegrationSummaryOut(
|
||||||
|
key="n8n",
|
||||||
|
status_code=n8n.state,
|
||||||
|
detail_code="n8nDetail",
|
||||||
|
detail_params={
|
||||||
|
"succeeded": n8n.succeeded,
|
||||||
|
"failed": n8n.failed,
|
||||||
|
"pending": n8n.pending,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DemoIntegrationSummaryOut(
|
||||||
|
key="ragcore",
|
||||||
|
status_code=(
|
||||||
|
"operational"
|
||||||
|
if knowledge_health.provider == "ragcore"
|
||||||
|
and _knowledge_scenario_ready(knowledge_health)
|
||||||
|
else "demoMode"
|
||||||
|
if knowledge_health.provider == "demo"
|
||||||
|
and _knowledge_scenario_ready(knowledge_health)
|
||||||
|
else "unavailable"
|
||||||
|
),
|
||||||
|
detail_code=(
|
||||||
|
"ragcoreDetail"
|
||||||
|
if knowledge_health.document_count is not None
|
||||||
|
else "ragcoreSourceDetail"
|
||||||
|
),
|
||||||
|
detail_params={
|
||||||
|
"count": knowledge_health.document_count or 0,
|
||||||
|
"source_count": knowledge_health.source_document_count,
|
||||||
|
"collection": knowledge_health.collection,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DemoIntegrationSummaryOut(
|
||||||
|
key="mcp_hub",
|
||||||
|
# `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
|
||||||
|
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
|
||||||
|
# to be called. Only real recorded `mcp_tool_request` calls make this
|
||||||
|
# "operational" -- same evidence rule the integration status page uses.
|
||||||
|
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
||||||
|
detail_code=(
|
||||||
|
"mcpDetailOperational" if mcp_hub.state == "operational" else "mcpDetailPrepared"
|
||||||
|
),
|
||||||
|
detail_params={},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_integrity_report(db: Session) -> dict:
|
||||||
|
"""Server-side scenario-integrity check run after every reset (section 15): confirms
|
||||||
|
each of the 5 named scenarios is actually present and ready, rather than trusting the
|
||||||
|
seed loader silently. Reuses the same readiness derivation the manifest/scenario
|
||||||
|
overview already use, so this can never drift from what a visitor actually sees."""
|
||||||
|
scenarios = _scenarios(db)
|
||||||
|
not_ready = [
|
||||||
|
{"id": s.id, "reason_code": s.blocked_reason_code} for s in scenarios if not s.ready
|
||||||
|
]
|
||||||
|
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
|
||||||
|
|
||||||
|
|
||||||
|
def build_demo_manifest(db: Session) -> DemoManifestOut:
|
||||||
|
last_reset_at, anchor_date = _last_reset(db)
|
||||||
|
return DemoManifestOut(
|
||||||
|
demo_mode=settings.mobilityops_demo_mode,
|
||||||
|
organization_name=settings.demo_organization_name,
|
||||||
|
timezone=settings.demo_timezone,
|
||||||
|
synthetic_data=True,
|
||||||
|
allow_reset=settings.demo_allow_reset,
|
||||||
|
last_reset_at=last_reset_at,
|
||||||
|
anchor_date=anchor_date,
|
||||||
|
guide_available=True,
|
||||||
|
required_roles=["operations_manager", "rental_employee"],
|
||||||
|
scenarios=_scenarios(db),
|
||||||
|
integrations=_integrations(db),
|
||||||
|
)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
|
||||||
|
logger = logging.getLogger("mobilityops.dispatcher")
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
_stop_event = threading.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def _backoff_seconds(attempts: int) -> int:
|
||||||
|
return min(2**attempts, 60)
|
||||||
|
|
||||||
|
|
||||||
|
def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
|
||||||
|
"""Recover events stuck in 'delivering' because the process that claimed them died
|
||||||
|
before recording an outcome. Only leases whose deadline has passed are touched, so an
|
||||||
|
in-flight delivery from a still-alive worker is never disturbed or double-processed;
|
||||||
|
`attempts` is preserved so the count reflects true history."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
rows = db.scalars(
|
||||||
|
select(OutboxEvent)
|
||||||
|
.where(
|
||||||
|
OutboxEvent.delivery_status == "delivering",
|
||||||
|
OutboxEvent.next_attempt_at.is_not(None),
|
||||||
|
OutboxEvent.next_attempt_at <= now,
|
||||||
|
)
|
||||||
|
.limit(batch_size)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
).all()
|
||||||
|
for row in rows:
|
||||||
|
row.delivery_status = "pending"
|
||||||
|
row.next_attempt_at = None
|
||||||
|
row.last_error = (
|
||||||
|
"Recovered from a stale 'delivering' lease "
|
||||||
|
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
||||||
|
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
||||||
|
)[:2000]
|
||||||
|
row.last_error_code = "staleLeaseRecovered"
|
||||||
|
db.commit()
|
||||||
|
return len(rows)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_due_events(batch_size: int = 5) -> list[uuid.UUID]:
|
||||||
|
"""Claim a batch of due events with a short-lived transaction (no network I/O held open).
|
||||||
|
Each claimed row gets a lease deadline (next_attempt_at) so a crash between this claim
|
||||||
|
and the outcome being recorded is recoverable by _reclaim_stale_deliveries."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
rows = db.scalars(
|
||||||
|
select(OutboxEvent)
|
||||||
|
.where(
|
||||||
|
OutboxEvent.delivery_status == "pending",
|
||||||
|
(OutboxEvent.next_attempt_at.is_(None)) | (OutboxEvent.next_attempt_at <= now),
|
||||||
|
)
|
||||||
|
.order_by(OutboxEvent.occurred_at.asc())
|
||||||
|
.limit(batch_size)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
).all()
|
||||||
|
claimed_ids = [row.event_id for row in rows]
|
||||||
|
lease_deadline = now + timedelta(seconds=settings.n8n_delivery_lease_seconds)
|
||||||
|
for row in rows:
|
||||||
|
row.delivery_status = "delivering"
|
||||||
|
row.next_attempt_at = lease_deadline
|
||||||
|
# The token is stored inside the internal payload (the wire envelope below
|
||||||
|
# explicitly selects only contract fields). It lets the outcome transaction
|
||||||
|
# prove that this is still the same lease after network I/O. A stale worker
|
||||||
|
# must never overwrite a later reclaim/retry or an idempotent callback.
|
||||||
|
row.payload_json = {
|
||||||
|
**row.payload_json,
|
||||||
|
"_delivery_claim_token": str(uuid.uuid4()),
|
||||||
|
}
|
||||||
|
db.commit()
|
||||||
|
return claimed_ids
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _deliver_one(event_id: uuid.UUID) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
event = db.get(OutboxEvent, event_id)
|
||||||
|
if event is None:
|
||||||
|
return
|
||||||
|
# Reconstruct the wire envelope from contracts/events.schema.json: only the fields
|
||||||
|
# the schema declares (additionalProperties: false), sourced from real columns where
|
||||||
|
# possible. `payload_json` also carries an internal `aggregate_ref` convenience field
|
||||||
|
# for our own dashboard/audit reads, which must not be forwarded to n8n.
|
||||||
|
try:
|
||||||
|
wire_event = {
|
||||||
|
"event_id": str(event.event_id),
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"occurred_at": event.occurred_at.isoformat(),
|
||||||
|
"correlation_id": event.payload_json["correlation_id"],
|
||||||
|
"aggregate": event.payload_json["aggregate"],
|
||||||
|
"data": event.payload_json["data"],
|
||||||
|
}
|
||||||
|
payload_error: str | None = None
|
||||||
|
except KeyError as exc:
|
||||||
|
# A malformed payload must still resolve the claimed "delivering" row to a
|
||||||
|
# terminal-or-retryable state below, rather than leaving it stuck forever.
|
||||||
|
wire_event = None
|
||||||
|
payload_error = f"Malformed outbox payload, missing key {exc}"
|
||||||
|
attempts = event.attempts
|
||||||
|
claim_token = event.payload_json.get("_delivery_claim_token")
|
||||||
|
if event.delivery_status != "delivering" or not isinstance(claim_token, str):
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
error_code: str | None
|
||||||
|
if wire_event is None:
|
||||||
|
success, error, body = False, payload_error, None
|
||||||
|
error_code = "malformedPayload"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
response = httpx.post(
|
||||||
|
settings.n8n_webhook_url,
|
||||||
|
json=wire_event,
|
||||||
|
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
|
||||||
|
timeout=settings.n8n_http_timeout_seconds,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = None
|
||||||
|
if isinstance(body, dict):
|
||||||
|
acknowledged = body.get("ok") is True
|
||||||
|
response_event_id = body.get("event_id")
|
||||||
|
event_id_matches = response_event_id == str(event_id)
|
||||||
|
result = body.get("result")
|
||||||
|
execution_id = result.get("execution_id") if isinstance(result, dict) else None
|
||||||
|
execution_id_valid = isinstance(execution_id, str) and bool(execution_id.strip())
|
||||||
|
success = acknowledged and event_id_matches and execution_id_valid
|
||||||
|
if success:
|
||||||
|
error = None
|
||||||
|
error_code = None
|
||||||
|
elif not acknowledged:
|
||||||
|
error = (
|
||||||
|
"n8n response did not explicitly acknowledge the event with ok=true: "
|
||||||
|
f"{body}"
|
||||||
|
)
|
||||||
|
error_code = (
|
||||||
|
"remoteReportedFailure" if body.get("ok") is False else "malformedResponse"
|
||||||
|
)
|
||||||
|
elif not event_id_matches:
|
||||||
|
error = (
|
||||||
|
"n8n acknowledged a different event ID "
|
||||||
|
f"(expected {event_id}, received {response_event_id!r})"
|
||||||
|
)
|
||||||
|
error_code = "mismatchedEventId"
|
||||||
|
else:
|
||||||
|
error = "n8n response omitted a valid result.execution_id"
|
||||||
|
error_code = "malformedResponse"
|
||||||
|
else:
|
||||||
|
# A 2xx status with a non-object (or unparsable) body means the workflow
|
||||||
|
# itself errored before its "Respond to Webhook" node ran -- n8n's default
|
||||||
|
# error response still carries a 2xx-looking status here. Treat it as a
|
||||||
|
# failure so the event is retried rather than lost or wrongly marked
|
||||||
|
# succeeded.
|
||||||
|
success = False
|
||||||
|
error = (
|
||||||
|
f"Unexpected non-JSON-object response from n8n (status {response.status_code})"
|
||||||
|
)
|
||||||
|
error_code = "malformedResponse"
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
success = False
|
||||||
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
error_code = "connectionError"
|
||||||
|
body = None
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
event = db.scalar(
|
||||||
|
select(OutboxEvent).where(OutboxEvent.event_id == event_id).with_for_update()
|
||||||
|
)
|
||||||
|
if event is None:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
event.delivery_status != "delivering"
|
||||||
|
or event.attempts != attempts
|
||||||
|
or event.payload_json.get("_delivery_claim_token") != claim_token
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring stale delivery outcome for event %s because lease ownership changed",
|
||||||
|
event_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
event.attempts = attempts + 1
|
||||||
|
event.payload_json = {
|
||||||
|
key: value
|
||||||
|
for key, value in event.payload_json.items()
|
||||||
|
if key != "_delivery_claim_token"
|
||||||
|
}
|
||||||
|
if success:
|
||||||
|
event.delivery_status = "succeeded"
|
||||||
|
event.last_error = None
|
||||||
|
event.last_error_code = None
|
||||||
|
event.next_attempt_at = None
|
||||||
|
result = (body or {}).get("result")
|
||||||
|
execution_id = result.get("execution_id") if isinstance(result, dict) else None
|
||||||
|
event.external_run_id = execution_id if isinstance(execution_id, str) else None
|
||||||
|
else:
|
||||||
|
event.last_error = (error or "delivery failed")[:2000]
|
||||||
|
event.last_error_code = error_code or "unknownError"
|
||||||
|
if event.attempts >= settings.n8n_max_attempts:
|
||||||
|
event.delivery_status = "failed"
|
||||||
|
event.next_attempt_at = None
|
||||||
|
else:
|
||||||
|
event.delivery_status = "pending"
|
||||||
|
event.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||||
|
seconds=_backoff_seconds(event.attempts)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_dispatch_cycle() -> int:
|
||||||
|
"""Run one reclaim+claim+deliver cycle. Returns the number of events processed."""
|
||||||
|
_reclaim_stale_deliveries()
|
||||||
|
claimed = _claim_due_events()
|
||||||
|
for event_id in claimed:
|
||||||
|
_deliver_one(event_id)
|
||||||
|
return len(claimed)
|
||||||
|
|
||||||
|
|
||||||
|
def _loop() -> None:
|
||||||
|
while not _stop_event.is_set():
|
||||||
|
try:
|
||||||
|
run_dispatch_cycle()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
logger.exception("Outbox dispatch cycle failed")
|
||||||
|
_stop_event.wait(settings.n8n_dispatch_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_dispatcher() -> None:
|
||||||
|
if not settings.n8n_dispatch_enabled:
|
||||||
|
return
|
||||||
|
_stop_event.clear()
|
||||||
|
thread = threading.Thread(target=_loop, name="outbox-dispatcher", daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_background_dispatcher() -> None:
|
||||||
|
_stop_event.set()
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import Row, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
|
||||||
|
from app.schemas import (
|
||||||
|
McpHubIntegrationStatus,
|
||||||
|
N8nErrorHandlerStatus,
|
||||||
|
N8nIntegrationStatus,
|
||||||
|
N8nWorkflowEvidence,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
_hub_health_lock = threading.Lock()
|
||||||
|
_hub_health_cached_at = 0.0
|
||||||
|
_hub_health_cached_value: bool | None = None
|
||||||
|
|
||||||
|
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
|
||||||
|
# built (all with their full node set saved).
|
||||||
|
_CANONICAL_WORKFLOWS = (
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration",
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan",
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync",
|
||||||
|
"Fleet Ops — Workflow Error Handler",
|
||||||
|
)
|
||||||
|
_STALE_AFTER = {
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": timedelta(hours=2, minutes=30),
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": timedelta(hours=30),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_rows_per_workflow(db: Session, action: str) -> Sequence[Row[Any]]:
|
||||||
|
"""Return the most recent audit row per workflow name for one action.
|
||||||
|
|
||||||
|
Heartbeats arrive on every scheduled run, so loading *all* rows and picking the
|
||||||
|
latest in Python would grow linearly with deployment age. ``DISTINCT ON`` lets
|
||||||
|
PostgreSQL return exactly one (latest) row per workflow instead.
|
||||||
|
"""
|
||||||
|
workflow_name = AuditEvent.after_json["workflow_name"].astext
|
||||||
|
return db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
|
||||||
|
.where(AuditEvent.action == action)
|
||||||
|
.distinct(workflow_name)
|
||||||
|
.order_by(workflow_name, AuditEvent.occurred_at.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||||
|
counts: dict[str, int] = dict(
|
||||||
|
db.execute(
|
||||||
|
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||||
|
).all() # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
pending = counts.get("pending", 0)
|
||||||
|
delivering = counts.get("delivering", 0)
|
||||||
|
failed = counts.get("failed", 0)
|
||||||
|
succeeded = counts.get("succeeded", 0)
|
||||||
|
|
||||||
|
# Prepared demo failures are props, not health signals. They stay visible and
|
||||||
|
# counted -- hiding them would be its own kind of lie -- but they are counted
|
||||||
|
# *separately*, and only genuinely unexpected failures are allowed to move n8n off
|
||||||
|
# "operational". Without this split the demo seed's single staged failure pins the
|
||||||
|
# integration to "degraded" forever, which tells a viewer something untrue about
|
||||||
|
# the automation.
|
||||||
|
demo_scenario_failed = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(OutboxEvent)
|
||||||
|
.where(
|
||||||
|
OutboxEvent.delivery_status == "failed",
|
||||||
|
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
unexpected_failed = max(failed - demo_scenario_failed, 0)
|
||||||
|
|
||||||
|
latest_success_at = db.scalar(
|
||||||
|
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||||
|
)
|
||||||
|
# Health talks about real failures only, so the "latest failure" a health reader
|
||||||
|
# sees must exclude the staged one too.
|
||||||
|
latest_failure_at = db.scalar(
|
||||||
|
select(func.max(OutboxEvent.updated_at)).where(
|
||||||
|
OutboxEvent.delivery_status == "failed",
|
||||||
|
or_(
|
||||||
|
OutboxEvent.last_error_code.is_(None),
|
||||||
|
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
latest_demo_scenario_at = db.scalar(
|
||||||
|
select(func.max(OutboxEvent.updated_at)).where(
|
||||||
|
OutboxEvent.delivery_status == "failed",
|
||||||
|
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||||
|
if not settings.n8n_dispatch_enabled:
|
||||||
|
state = "disabled"
|
||||||
|
elif not settings.n8n_webhook_url:
|
||||||
|
# Persisted history does not make a currently unconfigured dispatcher green.
|
||||||
|
state = "unavailable"
|
||||||
|
elif unexpected_failed > 0 and succeeded == 0:
|
||||||
|
state = "unavailable"
|
||||||
|
elif unexpected_failed > 0:
|
||||||
|
state = "degraded"
|
||||||
|
# Queued/in-flight work proves only that MobilityOps has work for the dispatcher;
|
||||||
|
# it does not prove that n8n has ever accepted a delivery. A green state requires
|
||||||
|
# at least one persisted successful round trip.
|
||||||
|
elif succeeded > 0:
|
||||||
|
state = "operational"
|
||||||
|
else:
|
||||||
|
state = "no_evidence"
|
||||||
|
|
||||||
|
# Scheduled scan evidence: only service-triggered runs count as n8n evidence, not
|
||||||
|
# runs an operator triggered manually from the Data Quality page.
|
||||||
|
latest_scan_at = db.scalar(
|
||||||
|
select(func.max(AuditEvent.occurred_at)).where(
|
||||||
|
AuditEvent.action == "data_quality_scan_run",
|
||||||
|
AuditEvent.actor_type == "service",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# RAGcore Procedure Sync evidence: result reports posted by the workflow itself once
|
||||||
|
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
|
||||||
|
# procedures_sync_result), the same "the workflow's own callback is the evidence"
|
||||||
|
# pattern the scheduled scan and error handler already use below.
|
||||||
|
latest_procedure_sync_row = db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
|
||||||
|
.where(AuditEvent.action == "n8n_procedures_synced")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
latest_procedure_sync_at = (
|
||||||
|
latest_procedure_sync_row[0] if latest_procedure_sync_row is not None else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||||
|
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired
|
||||||
|
# up and firing correctly.
|
||||||
|
total_failures_registered = (
|
||||||
|
db.scalar(
|
||||||
|
select(func.count(AuditEvent.id)).where(
|
||||||
|
AuditEvent.action == "n8n_workflow_failure_registered"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
latest_failure_row = db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.after_json)
|
||||||
|
.where(AuditEvent.action == "n8n_workflow_failure_registered")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None
|
||||||
|
latest_handler_failure_workflow = (
|
||||||
|
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_evidence_by_workflow = {
|
||||||
|
"Fleet Ops — Vehicle Return Orchestration": latest_success_at,
|
||||||
|
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
|
||||||
|
"Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at,
|
||||||
|
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
|
||||||
|
}
|
||||||
|
heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {}
|
||||||
|
heartbeat_rows = _latest_rows_per_workflow(db, "n8n_workflow_heartbeat")
|
||||||
|
for occurred_at, after, metadata in heartbeat_rows:
|
||||||
|
workflow_name = (after or {}).get("workflow_name")
|
||||||
|
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in heartbeat_by_workflow:
|
||||||
|
heartbeat_by_workflow[workflow_name] = (
|
||||||
|
occurred_at,
|
||||||
|
(after or {}).get("status", "succeeded"),
|
||||||
|
(metadata or {}).get("execution_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
failure_by_workflow: dict[str, tuple[datetime, str | None]] = {}
|
||||||
|
failure_rows = _latest_rows_per_workflow(db, "n8n_workflow_failure_registered")
|
||||||
|
for occurred_at, after, metadata in failure_rows:
|
||||||
|
workflow_name = (after or {}).get("workflow_name")
|
||||||
|
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in failure_by_workflow:
|
||||||
|
failure_by_workflow[workflow_name] = (
|
||||||
|
occurred_at,
|
||||||
|
(metadata or {}).get("execution_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
workflows: list[N8nWorkflowEvidence] = []
|
||||||
|
for name in _CANONICAL_WORKFLOWS:
|
||||||
|
legacy_seen = legacy_evidence_by_workflow[name]
|
||||||
|
heartbeat = heartbeat_by_workflow.get(name)
|
||||||
|
failure_signal = failure_by_workflow.get(name)
|
||||||
|
seen_at = heartbeat[0] if heartbeat else legacy_seen
|
||||||
|
last_status: Literal["succeeded", "failed"] | None = (
|
||||||
|
"succeeded" if seen_at is not None else None
|
||||||
|
)
|
||||||
|
execution_id = heartbeat[2] if heartbeat else None
|
||||||
|
if heartbeat and heartbeat[1] == "failed":
|
||||||
|
last_status = "failed"
|
||||||
|
if failure_signal and (seen_at is None or failure_signal[0] > seen_at):
|
||||||
|
seen_at = failure_signal[0]
|
||||||
|
last_status = "failed"
|
||||||
|
execution_id = failure_signal[1]
|
||||||
|
if name == "Fleet Ops — RAGcore Procedure Sync" and latest_procedure_sync_row:
|
||||||
|
sync_at, sync_result, sync_metadata = latest_procedure_sync_row
|
||||||
|
synced = (sync_result or {}).get("synced", 0)
|
||||||
|
failed_syncs = (sync_result or {}).get("failed", 0)
|
||||||
|
# The result callback is authoritative for corpus delivery. A generic
|
||||||
|
# succeeded heartbeat cannot turn a zero/partial upload green.
|
||||||
|
sync_result_failed = (
|
||||||
|
not isinstance(synced, int)
|
||||||
|
or not isinstance(failed_syncs, int)
|
||||||
|
or synced <= 0
|
||||||
|
or failed_syncs > 0
|
||||||
|
)
|
||||||
|
if sync_result_failed:
|
||||||
|
last_status = "failed"
|
||||||
|
if seen_at is None or sync_at > seen_at:
|
||||||
|
seen_at = sync_at
|
||||||
|
if sync_result_failed:
|
||||||
|
execution_id = (sync_metadata or {}).get("execution_id")
|
||||||
|
workflow_state: Literal["no_evidence", "healthy", "stale", "failed"]
|
||||||
|
if seen_at is None:
|
||||||
|
workflow_state = "no_evidence"
|
||||||
|
elif last_status == "failed":
|
||||||
|
workflow_state = "failed"
|
||||||
|
elif name in _STALE_AFTER and now - seen_at > _STALE_AFTER[name]:
|
||||||
|
workflow_state = "stale"
|
||||||
|
else:
|
||||||
|
workflow_state = "healthy"
|
||||||
|
workflows.append(
|
||||||
|
N8nWorkflowEvidence(
|
||||||
|
name=name,
|
||||||
|
built=True,
|
||||||
|
last_seen_at=seen_at,
|
||||||
|
state=workflow_state,
|
||||||
|
last_status=last_status,
|
||||||
|
last_execution_id=execution_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if state in {"operational", "no_evidence"} and any(
|
||||||
|
workflow.state in {"failed", "stale"} for workflow in workflows
|
||||||
|
):
|
||||||
|
state = "degraded"
|
||||||
|
|
||||||
|
return N8nIntegrationStatus(
|
||||||
|
configured=bool(settings.n8n_webhook_url),
|
||||||
|
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||||
|
state=state,
|
||||||
|
pending=pending,
|
||||||
|
delivering=delivering,
|
||||||
|
failed=failed,
|
||||||
|
unexpected_failed=unexpected_failed,
|
||||||
|
demo_scenario_failed=demo_scenario_failed,
|
||||||
|
succeeded=succeeded,
|
||||||
|
latest_success_at=latest_success_at,
|
||||||
|
latest_failure_at=latest_failure_at,
|
||||||
|
latest_demo_scenario_at=latest_demo_scenario_at,
|
||||||
|
expected_workflow_count=len(_CANONICAL_WORKFLOWS),
|
||||||
|
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
|
||||||
|
workflows=workflows,
|
||||||
|
error_handler=N8nErrorHandlerStatus(
|
||||||
|
total_failures_registered=total_failures_registered,
|
||||||
|
latest_failure_at=latest_handler_failure_at,
|
||||||
|
latest_failure_workflow=latest_handler_failure_workflow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||||
|
"""Evidence-based MCP Hub status: real tool-call audit history, not just the
|
||||||
|
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
|
||||||
|
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
|
||||||
|
total_calls = (
|
||||||
|
db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request"))
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
latest_call_row = db.execute(
|
||||||
|
select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json)
|
||||||
|
.where(AuditEvent.action == "mcp_tool_request")
|
||||||
|
.order_by(AuditEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
last_called_at = latest_call_row[0] if latest_call_row else None
|
||||||
|
last_client = latest_call_row[1] if latest_call_row else None
|
||||||
|
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
|
||||||
|
|
||||||
|
hub_reachable = _check_hub_reachable()
|
||||||
|
|
||||||
|
state: Literal["not_configured", "no_evidence", "operational"]
|
||||||
|
if not settings.mcp_hub_registration_enabled:
|
||||||
|
state = "not_configured"
|
||||||
|
elif total_calls > 0 and hub_reachable is not False:
|
||||||
|
state = "operational"
|
||||||
|
else:
|
||||||
|
# Historical tool calls remain useful telemetry, but cannot support a current
|
||||||
|
# operational claim when the configured Hub health endpoint is unreachable.
|
||||||
|
# ``hub_reachable`` stays available separately so consumers can distinguish
|
||||||
|
# this from a provider that simply has no call evidence yet.
|
||||||
|
state = "no_evidence"
|
||||||
|
|
||||||
|
return McpHubIntegrationStatus(
|
||||||
|
registration_enabled=settings.mcp_hub_registration_enabled,
|
||||||
|
state=state,
|
||||||
|
total_calls=total_calls,
|
||||||
|
last_tool=last_tool,
|
||||||
|
last_client=last_client,
|
||||||
|
last_called_at=last_called_at,
|
||||||
|
hub_reachable=hub_reachable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_hub_reachable() -> bool | None:
|
||||||
|
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
|
||||||
|
its side, so this is the only thing Fleet Ops itself can honestly check).
|
||||||
|
`None` means not configured / not checked, never a guess."""
|
||||||
|
if not settings.mcp_hub_base_url:
|
||||||
|
return None
|
||||||
|
global _hub_health_cached_at, _hub_health_cached_value
|
||||||
|
now = time.monotonic()
|
||||||
|
with _hub_health_lock:
|
||||||
|
if now - _hub_health_cached_at < settings.mcp_hub_health_cache_seconds:
|
||||||
|
return _hub_health_cached_value
|
||||||
|
try:
|
||||||
|
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
|
||||||
|
_hub_health_cached_value = response.status_code == 200
|
||||||
|
except httpx.HTTPError:
|
||||||
|
_hub_health_cached_value = False
|
||||||
|
_hub_health_cached_at = time.monotonic()
|
||||||
|
return _hub_health_cached_value
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal, Protocol
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
EvidenceState = Literal["grounded", "insufficient", "unavailable"]
|
||||||
|
KnowledgeStatisticsState = Literal["verified", "sync_reported", "not_reported"]
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCard(BaseModel):
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
section: str
|
||||||
|
excerpt: str
|
||||||
|
|
||||||
|
|
||||||
|
class GroundedAnswer(BaseModel):
|
||||||
|
answer: str
|
||||||
|
evidence_state: EvidenceState
|
||||||
|
sources: list[SourceCard]
|
||||||
|
provider: str
|
||||||
|
correlation_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeHealth(BaseModel):
|
||||||
|
provider: str
|
||||||
|
available: bool
|
||||||
|
detail: str
|
||||||
|
tenant: str
|
||||||
|
workspace: str
|
||||||
|
collection: str
|
||||||
|
# A provider may be healthy without exposing a corpus-size endpoint. `None` means
|
||||||
|
# unknown, never "zero procedures".
|
||||||
|
document_count: int | None
|
||||||
|
source_document_count: int
|
||||||
|
reported_synced_document_count: int | None
|
||||||
|
reported_failed_document_count: int | None
|
||||||
|
last_sync_at: datetime | None
|
||||||
|
statistics_state: KnowledgeStatisticsState
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeProvider(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
def health(self, language: str = "en-GB") -> KnowledgeHealth: ...
|
||||||
|
|
||||||
|
def ask(
|
||||||
|
self, question: str, correlation_id: str, language: str = "en-GB"
|
||||||
|
) -> GroundedAnswer: ...
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_knowledge_provider() -> KnowledgeProvider:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.knowledge_provider == "ragcore":
|
||||||
|
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
|
||||||
|
|
||||||
|
return RAGcoreKnowledgeProvider()
|
||||||
|
from app.services.knowledge.demo import DemoKnowledgeProvider
|
||||||
|
|
||||||
|
return DemoKnowledgeProvider()
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
from app.services.knowledge.procedures import parse_frontmatter
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
|
DEFAULT_LANGUAGE = "en-GB"
|
||||||
|
|
||||||
|
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
|
||||||
|
"en-GB": {
|
||||||
|
"a",
|
||||||
|
"an",
|
||||||
|
"the",
|
||||||
|
"is",
|
||||||
|
"are",
|
||||||
|
"was",
|
||||||
|
"were",
|
||||||
|
"be",
|
||||||
|
"been",
|
||||||
|
"being",
|
||||||
|
"to",
|
||||||
|
"of",
|
||||||
|
"in",
|
||||||
|
"on",
|
||||||
|
"at",
|
||||||
|
"for",
|
||||||
|
"and",
|
||||||
|
"or",
|
||||||
|
"but",
|
||||||
|
"if",
|
||||||
|
"then",
|
||||||
|
"do",
|
||||||
|
"does",
|
||||||
|
"did",
|
||||||
|
"must",
|
||||||
|
"may",
|
||||||
|
"can",
|
||||||
|
"could",
|
||||||
|
"should",
|
||||||
|
"would",
|
||||||
|
"i",
|
||||||
|
"you",
|
||||||
|
"it",
|
||||||
|
"we",
|
||||||
|
"they",
|
||||||
|
"my",
|
||||||
|
"your",
|
||||||
|
"what",
|
||||||
|
"when",
|
||||||
|
"how",
|
||||||
|
"with",
|
||||||
|
"without",
|
||||||
|
"this",
|
||||||
|
"that",
|
||||||
|
"these",
|
||||||
|
"those",
|
||||||
|
"not",
|
||||||
|
"no",
|
||||||
|
},
|
||||||
|
"nl-BE": {
|
||||||
|
"een",
|
||||||
|
"de",
|
||||||
|
"het",
|
||||||
|
"is",
|
||||||
|
"zijn",
|
||||||
|
"was",
|
||||||
|
"waren",
|
||||||
|
"worden",
|
||||||
|
"wordt",
|
||||||
|
"van",
|
||||||
|
"in",
|
||||||
|
"op",
|
||||||
|
"voor",
|
||||||
|
"en",
|
||||||
|
"of",
|
||||||
|
"maar",
|
||||||
|
"als",
|
||||||
|
"dan",
|
||||||
|
"moet",
|
||||||
|
"mag",
|
||||||
|
"kan",
|
||||||
|
"kunnen",
|
||||||
|
"zou",
|
||||||
|
"zouden",
|
||||||
|
"ik",
|
||||||
|
"jij",
|
||||||
|
"u",
|
||||||
|
"we",
|
||||||
|
"wij",
|
||||||
|
"zij",
|
||||||
|
"mijn",
|
||||||
|
"jouw",
|
||||||
|
"wat",
|
||||||
|
"wanneer",
|
||||||
|
"hoe",
|
||||||
|
"met",
|
||||||
|
"zonder",
|
||||||
|
"dit",
|
||||||
|
"dat",
|
||||||
|
"deze",
|
||||||
|
"die",
|
||||||
|
"niet",
|
||||||
|
"geen",
|
||||||
|
},
|
||||||
|
"fr-BE": {
|
||||||
|
"un",
|
||||||
|
"une",
|
||||||
|
"le",
|
||||||
|
"la",
|
||||||
|
"les",
|
||||||
|
"des",
|
||||||
|
"est",
|
||||||
|
"sont",
|
||||||
|
"était",
|
||||||
|
"être",
|
||||||
|
"de",
|
||||||
|
"du",
|
||||||
|
"en",
|
||||||
|
"sur",
|
||||||
|
"pour",
|
||||||
|
"et",
|
||||||
|
"ou",
|
||||||
|
"mais",
|
||||||
|
"si",
|
||||||
|
"alors",
|
||||||
|
"doit",
|
||||||
|
"peut",
|
||||||
|
"peuvent",
|
||||||
|
"pourrait",
|
||||||
|
"devrait",
|
||||||
|
"je",
|
||||||
|
"tu",
|
||||||
|
"vous",
|
||||||
|
"il",
|
||||||
|
"elle",
|
||||||
|
"nous",
|
||||||
|
"ils",
|
||||||
|
"mon",
|
||||||
|
"votre",
|
||||||
|
"quoi",
|
||||||
|
"quand",
|
||||||
|
"comment",
|
||||||
|
"avec",
|
||||||
|
"sans",
|
||||||
|
"ce",
|
||||||
|
"cette",
|
||||||
|
"ces",
|
||||||
|
"cela",
|
||||||
|
"pas",
|
||||||
|
"non",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Includes the Latin-1 accented-letter range (à-ö, ø-ÿ) so French/Dutch words with
|
||||||
|
# diacritics (véhicule, réservation, geëscaleerd) tokenize as one word instead of
|
||||||
|
# splitting apart at the accented character -- a plain [a-z0-9]+ pattern silently
|
||||||
|
# drops every accent and fragments the word either side of it.
|
||||||
|
_WORD_RE = re.compile(r"[a-zà-öø-ÿ0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def _stem(word: str) -> str:
|
||||||
|
# Deterministic, intentionally crude suffix stripping — good enough to match "returns"
|
||||||
|
# with "return" or "damaged" with "damage" without pulling in a stemming dependency.
|
||||||
|
for suffix in ("ing", "edly", "ed", "es", "s"):
|
||||||
|
if len(word) > len(suffix) + 2 and word.endswith(suffix):
|
||||||
|
return word[: -len(suffix)]
|
||||||
|
return word
|
||||||
|
|
||||||
|
|
||||||
|
def _tokenize(text: str, language: str) -> set[str]:
|
||||||
|
stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE])
|
||||||
|
words = _WORD_RE.findall(text.lower())
|
||||||
|
return {_stem(w) for w in words if w not in stopwords and len(w) > 2}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Document:
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
title_tokens: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScoredSection:
|
||||||
|
document: Document
|
||||||
|
heading: str
|
||||||
|
text: str
|
||||||
|
heading_tokens: set[str]
|
||||||
|
body_tokens: set[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_sections(body: str) -> list[tuple[str, str]]:
|
||||||
|
sections: list[tuple[str, str]] = []
|
||||||
|
current_heading = "Overview"
|
||||||
|
current_lines: list[str] = []
|
||||||
|
for line in body.splitlines():
|
||||||
|
if line.startswith("## "):
|
||||||
|
if current_lines:
|
||||||
|
sections.append((current_heading, "\n".join(current_lines).strip()))
|
||||||
|
current_heading = line[3:].strip()
|
||||||
|
current_lines = []
|
||||||
|
elif line.startswith("# "):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
current_lines.append(line)
|
||||||
|
if current_lines:
|
||||||
|
sections.append((current_heading, "\n".join(current_lines).strip()))
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
|
||||||
|
sections: list[ScoredSection] = []
|
||||||
|
for path in sorted(procedures_dir.glob("*.md")):
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
meta, body = parse_frontmatter(raw)
|
||||||
|
title = meta.get("title", path.stem)
|
||||||
|
doc = Document(
|
||||||
|
document_id=meta.get("document_id", path.stem),
|
||||||
|
title=title,
|
||||||
|
version=meta.get("version", "1.0"),
|
||||||
|
title_tokens=_tokenize(title, language),
|
||||||
|
)
|
||||||
|
for heading, text in _split_sections(body):
|
||||||
|
sections.append(
|
||||||
|
ScoredSection(
|
||||||
|
document=doc,
|
||||||
|
heading=heading,
|
||||||
|
text=text,
|
||||||
|
heading_tokens=_tokenize(heading, language),
|
||||||
|
body_tokens=_tokenize(text, language),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
_NO_MATCH_TEXT = {
|
||||||
|
"en-GB": "No matching procedure was found for this question.",
|
||||||
|
"nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.",
|
||||||
|
"fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.",
|
||||||
|
}
|
||||||
|
_LOW_CONFIDENCE_TEXT = {
|
||||||
|
"en-GB": (
|
||||||
|
"The available procedures do not clearly answer this question. "
|
||||||
|
"The closest matches are included below for review."
|
||||||
|
),
|
||||||
|
"nl-BE": (
|
||||||
|
"De beschikbare procedures beantwoorden deze vraag niet duidelijk. "
|
||||||
|
"De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling."
|
||||||
|
),
|
||||||
|
"fr-BE": (
|
||||||
|
"Les procédures disponibles ne répondent pas clairement à cette question. "
|
||||||
|
"Les correspondances les plus proches sont indiquées ci-dessous pour examen."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
_LEAD_ANSWER_TEMPLATE = {
|
||||||
|
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
|
||||||
|
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
|
||||||
|
"fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DemoKnowledgeProvider:
|
||||||
|
"""Deterministic extractive retrieval over the local procedure Markdown files.
|
||||||
|
|
||||||
|
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
|
||||||
|
(downweighting terms common across the whole corpus, like "vehicle", in favor of
|
||||||
|
distinctive ones, like "damage") and returns real excerpts, never invented text.
|
||||||
|
|
||||||
|
Each supported UI language has its own translated procedure corpus under
|
||||||
|
knowledge/procedures/<language>/ -- retrieval searches only within the requested
|
||||||
|
language's corpus so citations always link to a same-language document.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "demo"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
self._settings = settings
|
||||||
|
base_dir = Path(settings.knowledge_dir)
|
||||||
|
self._sections_by_language: dict[str, list[ScoredSection]] = {}
|
||||||
|
self._idf_by_language: dict[str, dict[str, float]] = {}
|
||||||
|
self._document_count_by_language: dict[str, int] = {}
|
||||||
|
for language in SUPPORTED_LANGUAGES:
|
||||||
|
lang_dir = base_dir / language
|
||||||
|
sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else []
|
||||||
|
self._sections_by_language[language] = sections
|
||||||
|
self._idf_by_language[language] = self._build_idf(sections)
|
||||||
|
self._document_count_by_language[language] = len(
|
||||||
|
{s.document.document_id for s in sections}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
|
||||||
|
n = len(sections) or 1
|
||||||
|
doc_freq: dict[str, int] = {}
|
||||||
|
for section in sections:
|
||||||
|
doc = section.document
|
||||||
|
all_tokens = doc.title_tokens | section.heading_tokens | section.body_tokens
|
||||||
|
for token in all_tokens:
|
||||||
|
doc_freq[token] = doc_freq.get(token, 0) + 1
|
||||||
|
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
|
||||||
|
|
||||||
|
def _normalize_language(self, language: str | None) -> str:
|
||||||
|
if language in SUPPORTED_LANGUAGES:
|
||||||
|
return language
|
||||||
|
return DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth:
|
||||||
|
language = self._normalize_language(language)
|
||||||
|
return KnowledgeHealth(
|
||||||
|
provider=self.name,
|
||||||
|
available=True,
|
||||||
|
detail="Deterministic keyword-matching demo provider; no external service.",
|
||||||
|
tenant=self._settings.ragcore_tenant,
|
||||||
|
workspace=self._settings.ragcore_workspace,
|
||||||
|
collection=self._settings.ragcore_collection,
|
||||||
|
document_count=self._document_count_by_language[language],
|
||||||
|
source_document_count=self._document_count_by_language[language],
|
||||||
|
reported_synced_document_count=None,
|
||||||
|
reported_failed_document_count=None,
|
||||||
|
last_sync_at=None,
|
||||||
|
statistics_state="verified",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _score(
|
||||||
|
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
|
||||||
|
) -> float:
|
||||||
|
# The section body is the strongest relevance signal -- it's the actual
|
||||||
|
# substance a heading or title can only hint at -- so a body match is weighted
|
||||||
|
# *above* heading/title matches, not below them. The previous 3x/2x/1x
|
||||||
|
# (heading/title/body) ordering let a single generic word in a heading (e.g.
|
||||||
|
# "vehicle", present in nearly every section) or a document's own title
|
||||||
|
# outrank a section whose body genuinely covers multiple, more distinctive
|
||||||
|
# query terms -- confirmed to misrank the brief's exact validation question in
|
||||||
|
# every one of the three languages (see docs/fleet-ops-correction/
|
||||||
|
# current-gap-audit.md and i18n-inventory.md): nl-BE picked a checkout section
|
||||||
|
# over the damage procedure, en-GB and fr-BE picked the return procedure over
|
||||||
|
# the damage procedure, purely from heading/title overlap on common words.
|
||||||
|
score = 0.0
|
||||||
|
for token in query_tokens:
|
||||||
|
token_idf = idf.get(token, 0.0)
|
||||||
|
if token_idf == 0.0:
|
||||||
|
continue
|
||||||
|
if token in section.body_tokens:
|
||||||
|
score += 3 * token_idf
|
||||||
|
elif token in section.heading_tokens:
|
||||||
|
score += 2 * token_idf
|
||||||
|
elif token in section.document.title_tokens:
|
||||||
|
score += 1.5 * token_idf
|
||||||
|
return score
|
||||||
|
|
||||||
|
def ask(
|
||||||
|
self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
|
||||||
|
) -> GroundedAnswer:
|
||||||
|
language = self._normalize_language(language)
|
||||||
|
sections = self._sections_by_language[language]
|
||||||
|
idf = self._idf_by_language[language]
|
||||||
|
query_tokens = _tokenize(question, language)
|
||||||
|
scored = [(self._score(query_tokens, section, idf), section) for section in sections]
|
||||||
|
scored = [(score, section) for score, section in scored if score > 0]
|
||||||
|
scored.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
top = scored[:3]
|
||||||
|
|
||||||
|
if not top:
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer=_NO_MATCH_TEXT[language],
|
||||||
|
evidence_state="insufficient",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
sources = [
|
||||||
|
SourceCard(
|
||||||
|
document_id=section.document.document_id,
|
||||||
|
title=section.document.title,
|
||||||
|
version=section.document.version,
|
||||||
|
section=section.heading,
|
||||||
|
excerpt=(section.text[:400] + "…") if len(section.text) > 400 else section.text,
|
||||||
|
)
|
||||||
|
for _, section in top
|
||||||
|
]
|
||||||
|
|
||||||
|
if top[0][0] < 3:
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer=_LOW_CONFIDENCE_TEXT[language],
|
||||||
|
evidence_state="insufficient",
|
||||||
|
sources=sources,
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
lead_section = top[0][1]
|
||||||
|
answer = _LEAD_ANSWER_TEMPLATE[language].format(
|
||||||
|
title=lead_section.document.title,
|
||||||
|
version=lead_section.document.version,
|
||||||
|
heading=lead_section.heading,
|
||||||
|
excerpt=lead_section.text.splitlines()[0][:300],
|
||||||
|
)
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer=answer,
|
||||||
|
evidence_state="grounded",
|
||||||
|
sources=sources,
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
|
||||||
|
|
||||||
|
# Stable across runs (and across which language ships first) so a document's RAGcore
|
||||||
|
# source_id never changes just because the sync ran on a different day or in a
|
||||||
|
# different order -- required for RAGcore's upload idempotency to work per document.
|
||||||
|
_SOURCE_ID_NAMESPACE = uuid.uuid5(
|
||||||
|
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
||||||
|
if not raw.startswith("---"):
|
||||||
|
return {}, raw
|
||||||
|
end = raw.find("\n---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return {}, raw
|
||||||
|
block = raw[3:end].strip()
|
||||||
|
body = raw[end + 4 :].lstrip("\n")
|
||||||
|
meta: dict[str, str] = {}
|
||||||
|
for line in block.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
meta[key.strip()] = value.strip().strip('"')
|
||||||
|
return meta, body
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProcedureDocument:
|
||||||
|
source_id: str
|
||||||
|
language: str
|
||||||
|
document_id: str
|
||||||
|
title: str
|
||||||
|
version: str
|
||||||
|
content: str
|
||||||
|
content_hash: str
|
||||||
|
|
||||||
|
|
||||||
|
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
|
||||||
|
"""Read every procedure Markdown file Fleet Ops ships, across every supported
|
||||||
|
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
|
||||||
|
fields (title, version) come from the same files the demo knowledge provider
|
||||||
|
already reads -- see parse_frontmatter -- so the two never drift apart."""
|
||||||
|
|
||||||
|
documents: list[ProcedureDocument] = []
|
||||||
|
for language in SUPPORTED_LANGUAGES:
|
||||||
|
language_dir = knowledge_dir / language
|
||||||
|
if not language_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for path in sorted(language_dir.glob("*.md")):
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
meta, body = parse_frontmatter(raw)
|
||||||
|
document_id = meta.get("document_id", path.stem)
|
||||||
|
content = body.strip()
|
||||||
|
documents.append(
|
||||||
|
ProcedureDocument(
|
||||||
|
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
|
||||||
|
language=language,
|
||||||
|
document_id=document_id,
|
||||||
|
title=meta.get("title", path.stem),
|
||||||
|
version=meta.get("version", "1.0"),
|
||||||
|
content=content,
|
||||||
|
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return documents
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.observability import KNOWLEDGE_PROVIDER_REQUESTS, KNOWLEDGE_RETRIEVAL_SCORE
|
||||||
|
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||||
|
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
|
||||||
|
|
||||||
|
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||||
|
_ANSWERABILITY_STATES = _GROUNDED_ANSWERABILITY | {
|
||||||
|
"not_answerable",
|
||||||
|
"conflicting_evidence",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
|
||||||
|
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
|
||||||
|
# fallback when RAGcore's own /v1/answers (generation + citation validation) is
|
||||||
|
# unavailable but its retrieval (/v1/search) still returns real, relevant, cited
|
||||||
|
# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's
|
||||||
|
# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it
|
||||||
|
# is deliberately left out of this sentence (it still appears on the source card itself).
|
||||||
|
_LEAD_ANSWER_TEMPLATE = {
|
||||||
|
"en-GB": 'Per "{title}": {excerpt}',
|
||||||
|
"nl-BE": 'Volgens "{title}": {excerpt}',
|
||||||
|
"fr-BE": "Selon « {title} » : {excerpt}",
|
||||||
|
}
|
||||||
|
_DEFAULT_LANGUAGE = "en-GB"
|
||||||
|
_MAX_SOURCE_CARDS = 3
|
||||||
|
_INDEX_LOOKUP_TIMEOUT_SECONDS = 2.0
|
||||||
|
_INDEX_VERIFICATION_TTL_SECONDS = 300.0
|
||||||
|
_INDEX_VERIFICATION_WORKERS = 6
|
||||||
|
|
||||||
|
_DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
|
||||||
|
"damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"),
|
||||||
|
"vehicle": ("vehicle", "car", "voertuig", "wagen", "véhicule", "voiture"),
|
||||||
|
"return": ("return", "returned", "retour", "terugbrengen", "restitution"),
|
||||||
|
"fuel": ("fuel", "brandstof", "carburant"),
|
||||||
|
"booking": ("booking", "reservation", "boeking", "réservation"),
|
||||||
|
"odometer": ("odometer", "mileage", "kilometer", "kilométrage", "compteur"),
|
||||||
|
"customer": ("customer", "client", "klant"),
|
||||||
|
"cleaning": ("cleaning", "reiniging", "poetsen", "nettoyage"),
|
||||||
|
"maintenance": ("maintenance", "service", "onderhoud", "entretien"),
|
||||||
|
"conflict": ("conflict", "overlap", "overlapping", "conflit", "chevauchement"),
|
||||||
|
"checkout": ("checkout", "departure", "vertrek", "départ"),
|
||||||
|
"availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"),
|
||||||
|
"technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"),
|
||||||
|
}
|
||||||
|
_ANSWERABLE_INTENT_CONCEPTS = frozenset(_DOMAIN_CONCEPTS) - {"vehicle", "customer"}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_evidence_text(value: str) -> str:
|
||||||
|
return " ".join(re.findall(r"\w+", value.casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def _question_concepts(question: str) -> set[str]:
|
||||||
|
normalized = question.casefold()
|
||||||
|
return {
|
||||||
|
concept
|
||||||
|
for concept, terms in _DOMAIN_CONCEPTS.items()
|
||||||
|
if any(term in normalized for term in terms)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]:
|
||||||
|
"""Collapse duplicate chunks and re-uploaded document versions.
|
||||||
|
|
||||||
|
RAGcore document/version UUIDs change across uploads, so they are not useful
|
||||||
|
deduplication keys. Human-visible citation identity is the normalized title,
|
||||||
|
section. Chunks from the same unsectioned document collapse into one card; distinct
|
||||||
|
named sections remain independently citable.
|
||||||
|
"""
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
unique: list[SourceCard] = []
|
||||||
|
for source in sources:
|
||||||
|
key = (
|
||||||
|
source.title.strip().casefold(),
|
||||||
|
source.section.strip().casefold(),
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
unique.append(source)
|
||||||
|
if len(unique) == _MAX_SOURCE_CARDS:
|
||||||
|
break
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) -> list[SourceCard]:
|
||||||
|
if "damage" not in concepts:
|
||||||
|
return sources
|
||||||
|
return sorted(
|
||||||
|
sources,
|
||||||
|
key=lambda source: (
|
||||||
|
0 if "damage" in f"{source.document_id} {source.title}".casefold() else 1
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _retrieval_score(result: object) -> float | None:
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
return None
|
||||||
|
scores = result.get("scores")
|
||||||
|
if not isinstance(scores, dict):
|
||||||
|
return None
|
||||||
|
for name in ("rerank", "fused"):
|
||||||
|
value = scores.get(name)
|
||||||
|
if isinstance(value, int | float) and not isinstance(value, bool):
|
||||||
|
return float(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RAGcoreKnowledgeProvider:
|
||||||
|
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
|
||||||
|
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
|
||||||
|
owned separately, MobilityOps only ever talks to its documented HTTP API).
|
||||||
|
|
||||||
|
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
|
||||||
|
session-cookie auth is for its own browser admin UI only). Any connection error,
|
||||||
|
timeout, non-2xx response, or malformed body degrades to `evidence_state:
|
||||||
|
"unavailable"` rather than raising -- this is the adapter that actually exercises the
|
||||||
|
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
|
||||||
|
never fabricates an answer, never affects the rest of the app.
|
||||||
|
|
||||||
|
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
|
||||||
|
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
|
||||||
|
classifying the question as insufficiently answerable), `ask()` falls back to
|
||||||
|
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
|
||||||
|
stage with no generation step to fail. The fallback answer is always an extractive
|
||||||
|
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
|
||||||
|
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
|
||||||
|
|
||||||
|
Retrieval is scoped to the stable RAGcore ``source_id`` values owned by Fleet Ops for
|
||||||
|
the requested UI language. Returned internal document/version UUIDs are deliberately
|
||||||
|
not treated as Fleet Ops identifiers: every citation must instead prove the complete
|
||||||
|
managed-source chain (source id, URI, locator, checksum and extractive local text).
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "ragcore"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._settings = get_settings()
|
||||||
|
self._managed_documents_by_source_id: dict[str, ProcedureDocument] = {}
|
||||||
|
for document in iter_procedure_documents(Path(self._settings.knowledge_dir)):
|
||||||
|
self._managed_documents_by_source_id[document.source_id] = document
|
||||||
|
self._verification_cache: dict[str, tuple[float, int]] = {}
|
||||||
|
self._verification_lock = Lock()
|
||||||
|
self._answers_circuit_lock = Lock()
|
||||||
|
self._answers_circuit_open_until = 0.0
|
||||||
|
|
||||||
|
def _managed_source(self, citation: object, language: str) -> SourceCard | None:
|
||||||
|
if not isinstance(citation, dict):
|
||||||
|
return None
|
||||||
|
source_id = citation.get("source_id")
|
||||||
|
if not isinstance(source_id, str):
|
||||||
|
return None
|
||||||
|
document = self._managed_documents_by_source_id.get(str(source_id))
|
||||||
|
if document is None or document.language != language:
|
||||||
|
return None
|
||||||
|
if citation.get("locator") != f"{document.document_id}.md":
|
||||||
|
return None
|
||||||
|
if citation.get("source_uri") != f"ragcore://source/{document.source_id}":
|
||||||
|
return None
|
||||||
|
for field_name in ("id", "document_id", "document_version_id"):
|
||||||
|
value = citation.get(field_name)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = uuid.UUID(value)
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
if parsed.int == 0:
|
||||||
|
return None
|
||||||
|
if not isinstance(citation.get("title"), str):
|
||||||
|
return None
|
||||||
|
section = citation.get("section")
|
||||||
|
if section is not None and not isinstance(section, str):
|
||||||
|
return None
|
||||||
|
excerpt = citation.get("excerpt")
|
||||||
|
if not isinstance(excerpt, str) or not excerpt.strip():
|
||||||
|
return None
|
||||||
|
expected_hash = hashlib.sha256(excerpt.encode("utf-8")).hexdigest()
|
||||||
|
if citation.get("excerpt_sha256") != expected_hash:
|
||||||
|
return None
|
||||||
|
# RAGcore owns chunking, but the cited excerpt must still be extractive evidence
|
||||||
|
# from the authoritative local procedure. Token normalization tolerates Markdown
|
||||||
|
# punctuation/whitespace while rejecting provider text that was never uploaded.
|
||||||
|
normalized_excerpt = _normalize_evidence_text(excerpt)
|
||||||
|
if not normalized_excerpt or normalized_excerpt not in _normalize_evidence_text(
|
||||||
|
document.content
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return SourceCard(
|
||||||
|
document_id=document.document_id,
|
||||||
|
title=document.title,
|
||||||
|
version=document.version,
|
||||||
|
section=section or "",
|
||||||
|
excerpt=excerpt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _managed_source_ids(self, language: str) -> list[str]:
|
||||||
|
return [
|
||||||
|
document.source_id
|
||||||
|
for document in self._managed_documents_by_source_id.values()
|
||||||
|
if document.language == language
|
||||||
|
]
|
||||||
|
|
||||||
|
def _answers_circuit_is_open(self) -> bool:
|
||||||
|
with self._answers_circuit_lock:
|
||||||
|
return monotonic() < self._answers_circuit_open_until
|
||||||
|
|
||||||
|
def _open_answers_circuit(self) -> None:
|
||||||
|
with self._answers_circuit_lock:
|
||||||
|
self._answers_circuit_open_until = monotonic() + max(
|
||||||
|
0.0, self._settings.ragcore_answers_circuit_breaker_seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
def _close_answers_circuit(self) -> None:
|
||||||
|
with self._answers_circuit_lock:
|
||||||
|
self._answers_circuit_open_until = 0.0
|
||||||
|
|
||||||
|
def _client(self) -> httpx.Client:
|
||||||
|
headers = {}
|
||||||
|
if self._settings.ragcore_api_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self._settings.ragcore_api_token}"
|
||||||
|
return httpx.Client(
|
||||||
|
base_url=self._settings.ragcore_base_url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=self._settings.ragcore_http_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||||
|
documents = [
|
||||||
|
document
|
||||||
|
for document in iter_procedure_documents(Path(self._settings.knowledge_dir))
|
||||||
|
if document.language == language
|
||||||
|
]
|
||||||
|
verified_document_count: int | None = None
|
||||||
|
try:
|
||||||
|
with self._client() as client:
|
||||||
|
response = client.get("/health/ready")
|
||||||
|
body = response.json()
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise ValueError("health response is not a JSON object")
|
||||||
|
available = response.status_code == 200 and body.get("status") == "ok"
|
||||||
|
detail = (
|
||||||
|
"RAGcore reachable and ready."
|
||||||
|
if available
|
||||||
|
else f"RAGcore degraded: {body.get('status', 'unknown')}"
|
||||||
|
)
|
||||||
|
if available:
|
||||||
|
verified_document_count = self._verify_indexed_documents(
|
||||||
|
client, language, documents
|
||||||
|
)
|
||||||
|
if verified_document_count is None:
|
||||||
|
detail += " Index verification is temporarily unavailable."
|
||||||
|
else:
|
||||||
|
detail += (
|
||||||
|
f" {verified_document_count}/{len(documents)} managed sources have "
|
||||||
|
"an exact active published document in the configured space."
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, ValueError) as exc:
|
||||||
|
available = False
|
||||||
|
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
|
||||||
|
return KnowledgeHealth(
|
||||||
|
provider=self.name,
|
||||||
|
available=available,
|
||||||
|
detail=detail,
|
||||||
|
tenant=self._settings.ragcore_tenant,
|
||||||
|
workspace=self._settings.ragcore_workspace,
|
||||||
|
collection=self._settings.ragcore_collection,
|
||||||
|
# RAGcore deliberately has no browse/count endpoint. Fleet Ops instead
|
||||||
|
# verifies each managed source through its exact identity lookup and only
|
||||||
|
# counts an active document with a published active version. RAGcore's
|
||||||
|
# content_sha256 describes its canonical parsed artifact, not the uploaded
|
||||||
|
# source bytes, so comparing it with Fleet Ops's source hash would be false.
|
||||||
|
document_count=verified_document_count,
|
||||||
|
source_document_count=len(documents),
|
||||||
|
reported_synced_document_count=None,
|
||||||
|
reported_failed_document_count=None,
|
||||||
|
last_sync_at=None,
|
||||||
|
statistics_state=(
|
||||||
|
"verified" if verified_document_count is not None else "not_reported"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _verify_indexed_documents(
|
||||||
|
self, client: httpx.Client, language: str, documents: list[ProcedureDocument]
|
||||||
|
) -> int | None:
|
||||||
|
if not self._settings.ragcore_space_id or not documents:
|
||||||
|
return None
|
||||||
|
|
||||||
|
now = monotonic()
|
||||||
|
with self._verification_lock:
|
||||||
|
cached = self._verification_cache.get(language)
|
||||||
|
if cached is not None and now - cached[0] < _INDEX_VERIFICATION_TTL_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
def is_verified(document: ProcedureDocument) -> bool:
|
||||||
|
response = client.get(
|
||||||
|
"/v1/documents",
|
||||||
|
params={
|
||||||
|
"source_id": document.source_id,
|
||||||
|
"external_id": f"{document.document_id}.md",
|
||||||
|
},
|
||||||
|
timeout=_INDEX_LOOKUP_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise RuntimeError("RAGcore document verification failed")
|
||||||
|
body = response.json()
|
||||||
|
items = body.get("items") if isinstance(body, dict) else None
|
||||||
|
if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
|
||||||
|
return False
|
||||||
|
item = items[0]
|
||||||
|
active_version = item.get("active_version")
|
||||||
|
return bool(
|
||||||
|
item.get("space_id") == self._settings.ragcore_space_id
|
||||||
|
and item.get("source_id") == document.source_id
|
||||||
|
and item.get("external_id") == f"{document.document_id}.md"
|
||||||
|
and item.get("status") == "active"
|
||||||
|
and isinstance(active_version, dict)
|
||||||
|
and active_version.get("status") == "published"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(
|
||||||
|
max_workers=min(_INDEX_VERIFICATION_WORKERS, len(documents))
|
||||||
|
) as executor:
|
||||||
|
verified_count = sum(executor.map(is_verified, documents))
|
||||||
|
except (httpx.HTTPError, RuntimeError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
with self._verification_lock:
|
||||||
|
self._verification_cache[language] = (monotonic(), verified_count)
|
||||||
|
return verified_count
|
||||||
|
|
||||||
|
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
|
||||||
|
unavailable = GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="unavailable",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
if not self._settings.ragcore_space_id:
|
||||||
|
return unavailable
|
||||||
|
|
||||||
|
if self._answers_circuit_is_open():
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
|
||||||
|
else:
|
||||||
|
answered = self._ask_via_answers(question, correlation_id, language)
|
||||||
|
if answered is not None:
|
||||||
|
return answered
|
||||||
|
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
|
||||||
|
# real retrieval rather than degrading straight to "unavailable". This never
|
||||||
|
# fabricates an answer to the question: it only ever shows an actually-cited
|
||||||
|
# excerpt RAGcore's own search already found, using the same extractive
|
||||||
|
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
|
||||||
|
# generation step.
|
||||||
|
return self._ask_via_search_fallback(question, correlation_id, language)
|
||||||
|
|
||||||
|
def _ask_via_answers(
|
||||||
|
self, question: str, correlation_id: str, language: str
|
||||||
|
) -> GroundedAnswer | None:
|
||||||
|
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
|
||||||
|
so the caller can fall back to search -- as opposed to a real 200 response
|
||||||
|
classifying the question as insufficiently answerable, which is a genuine,
|
||||||
|
final result, not a reason to fall back."""
|
||||||
|
try:
|
||||||
|
with self._client() as client:
|
||||||
|
response = client.post(
|
||||||
|
"/v1/answers",
|
||||||
|
json={
|
||||||
|
"query": question,
|
||||||
|
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||||
|
"filters": {"source_ids": self._managed_source_ids(language)},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc()
|
||||||
|
self._open_answers_circuit()
|
||||||
|
return None
|
||||||
|
body = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc()
|
||||||
|
self._open_answers_circuit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise TypeError("answer response must be an object")
|
||||||
|
# Validate the provider's complete AnswerResponse contract before trusting
|
||||||
|
# generated prose. These IDs and claim bindings are the evidence that RAGcore
|
||||||
|
# ran its deterministic claim/citation validator; one unrelated but otherwise
|
||||||
|
# valid citation must never make arbitrary answer text appear grounded.
|
||||||
|
uuid.UUID(str(body["answer_id"]))
|
||||||
|
uuid.UUID(str(body["retrieval_run_id"]))
|
||||||
|
raw_citations = body.get("citations", [])
|
||||||
|
if not isinstance(raw_citations, list):
|
||||||
|
raise TypeError("citations must be a list")
|
||||||
|
citation_ids: set[uuid.UUID] = set()
|
||||||
|
for citation in raw_citations:
|
||||||
|
if not isinstance(citation, dict):
|
||||||
|
raise TypeError("citation must be an object")
|
||||||
|
citation_ids.add(uuid.UUID(str(citation["id"])))
|
||||||
|
raw_claims = body["claims"]
|
||||||
|
if not isinstance(raw_claims, list):
|
||||||
|
raise TypeError("claims must be a list")
|
||||||
|
claims_are_bound = bool(raw_claims)
|
||||||
|
answer_text = body.get("answer")
|
||||||
|
for claim in raw_claims:
|
||||||
|
if not isinstance(claim, dict):
|
||||||
|
raise TypeError("claim must be an object")
|
||||||
|
claim_text = claim.get("text")
|
||||||
|
claim_citation_ids = claim.get("citation_ids")
|
||||||
|
if (
|
||||||
|
not isinstance(claim_text, str)
|
||||||
|
or not claim_text.strip()
|
||||||
|
or not isinstance(answer_text, str)
|
||||||
|
or claim_text.strip() not in answer_text
|
||||||
|
or not isinstance(claim_citation_ids, list)
|
||||||
|
or not claim_citation_ids
|
||||||
|
):
|
||||||
|
claims_are_bound = False
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
bound_ids = {uuid.UUID(str(item)) for item in claim_citation_ids}
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
claims_are_bound = False
|
||||||
|
continue
|
||||||
|
if not bound_ids.issubset(citation_ids):
|
||||||
|
claims_are_bound = False
|
||||||
|
mapped_sources = [
|
||||||
|
source
|
||||||
|
for citation in raw_citations
|
||||||
|
if (source := self._managed_source(citation, language)) is not None
|
||||||
|
]
|
||||||
|
citations_are_managed = len(mapped_sources) == len(raw_citations)
|
||||||
|
sources = _deduplicate_sources(mapped_sources)
|
||||||
|
answerability = body.get("answerability", "not_answerable")
|
||||||
|
if answerability not in _ANSWERABILITY_STATES:
|
||||||
|
raise TypeError("answerability is invalid")
|
||||||
|
if not isinstance(answer_text, str):
|
||||||
|
raise TypeError("answer must be a string")
|
||||||
|
is_grounded = (
|
||||||
|
answerability in _GROUNDED_ANSWERABILITY
|
||||||
|
and bool(sources)
|
||||||
|
and citations_are_managed
|
||||||
|
and claims_are_bound
|
||||||
|
and bool(answer_text.strip())
|
||||||
|
)
|
||||||
|
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
|
||||||
|
self._close_answers_circuit()
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer=answer_text if evidence_state == "grounded" else "",
|
||||||
|
evidence_state=evidence_state,
|
||||||
|
sources=sources if evidence_state == "grounded" else [],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
except (TypeError, KeyError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc()
|
||||||
|
self._open_answers_circuit()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ask_via_search_fallback(
|
||||||
|
self, question: str, correlation_id: str, language: str
|
||||||
|
) -> GroundedAnswer:
|
||||||
|
unavailable = GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="unavailable",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with self._client() as client:
|
||||||
|
response = client.post(
|
||||||
|
"/v1/search",
|
||||||
|
json={
|
||||||
|
"query": question,
|
||||||
|
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||||
|
"filters": {"source_ids": self._managed_source_ids(language)},
|
||||||
|
"max_results": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc()
|
||||||
|
return unavailable
|
||||||
|
body = response.json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc()
|
||||||
|
return unavailable
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise TypeError("search response must be an object")
|
||||||
|
results = body.get("results", [])
|
||||||
|
if not isinstance(results, list):
|
||||||
|
raise TypeError("results must be a list")
|
||||||
|
sources = [
|
||||||
|
source
|
||||||
|
for result in results
|
||||||
|
if isinstance(result, dict)
|
||||||
|
and (source := self._managed_source(result.get("citation"), language)) is not None
|
||||||
|
]
|
||||||
|
except (TypeError, KeyError, ValueError):
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
|
||||||
|
return unavailable
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
continue
|
||||||
|
score = _retrieval_score(result)
|
||||||
|
if score is not None:
|
||||||
|
KNOWLEDGE_RETRIEVAL_SCORE.observe(score)
|
||||||
|
|
||||||
|
all_sources = _deduplicate_sources(sources)
|
||||||
|
concepts = _question_concepts(question)
|
||||||
|
qualified_sources = [
|
||||||
|
source
|
||||||
|
for result in results
|
||||||
|
if isinstance(result, dict)
|
||||||
|
and (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
|
||||||
|
and (source := self._managed_source(result.get("citation"), language)) is not None
|
||||||
|
]
|
||||||
|
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
|
||||||
|
if not all_sources:
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="insufficient",
|
||||||
|
sources=[],
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
required_concepts = concepts & _ANSWERABLE_INTENT_CONCEPTS
|
||||||
|
evidence_text = " ".join(
|
||||||
|
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
|
||||||
|
for source in sources
|
||||||
|
).casefold()
|
||||||
|
covered_concepts = {
|
||||||
|
concept
|
||||||
|
for concept in required_concepts
|
||||||
|
if any(term in evidence_text for term in _DOMAIN_CONCEPTS[concept])
|
||||||
|
}
|
||||||
|
if not required_concepts or not sources or covered_concepts != required_concepts:
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer="",
|
||||||
|
evidence_state="insufficient",
|
||||||
|
sources=all_sources,
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
|
||||||
|
lead = sources[0]
|
||||||
|
answer = template.format(title=lead.title, excerpt=lead.excerpt)
|
||||||
|
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc()
|
||||||
|
return GroundedAnswer(
|
||||||
|
answer=answer,
|
||||||
|
evidence_state="grounded",
|
||||||
|
sources=sources,
|
||||||
|
provider=self.name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import DashboardMetrics
|
||||||
|
|
||||||
|
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_metrics(db: Session) -> DashboardMetrics:
|
||||||
|
"""Shared operations-summary computation used by both the dashboard and the MCP
|
||||||
|
provider API, so the two never drift out of sync with two copies of the same query."""
|
||||||
|
status_counts: dict[str, int] = dict(
|
||||||
|
db.execute(
|
||||||
|
select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status)
|
||||||
|
).all() # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
open_issues = db.scalar(
|
||||||
|
select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open")
|
||||||
|
)
|
||||||
|
pending_or_failed = db.scalar(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(OutboxEvent)
|
||||||
|
.where(OutboxEvent.delivery_status.in_(["pending", "failed"]))
|
||||||
|
)
|
||||||
|
return DashboardMetrics(
|
||||||
|
available=status_counts.get("available", 0),
|
||||||
|
rented=status_counts.get("rented", 0),
|
||||||
|
cleaning=status_counts.get("cleaning", 0),
|
||||||
|
maintenance=status_counts.get("maintenance", 0),
|
||||||
|
blocked=status_counts.get("blocked", 0),
|
||||||
|
open_quality_issues=open_issues or 0,
|
||||||
|
pending_or_failed_workflows=pending_or_failed or 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_attention_vehicles(
|
||||||
|
db: Session, minimum_severity: str = "medium", on_or_before: date | None = None, limit: int = 20
|
||||||
|
) -> list[dict]:
|
||||||
|
max_rank = SEVERITY_ORDER.get(minimum_severity, 1)
|
||||||
|
stmt = (
|
||||||
|
select(DataQualityIssue)
|
||||||
|
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.status == "open")
|
||||||
|
.order_by(DataQualityIssue.detected_at.asc())
|
||||||
|
)
|
||||||
|
if on_or_before is not None:
|
||||||
|
stmt = stmt.where(func.date(DataQualityIssue.detected_at) <= on_or_before)
|
||||||
|
issues = db.scalars(stmt).all()
|
||||||
|
filtered = [i for i in issues if SEVERITY_ORDER.get(i.severity, 3) <= max_rank]
|
||||||
|
filtered.sort(key=lambda i: SEVERITY_ORDER.get(i.severity, 3))
|
||||||
|
|
||||||
|
vehicle_ids = {i.entity_id for i in filtered}
|
||||||
|
vehicles_by_id = {
|
||||||
|
v.id: v for v in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for issue in filtered[:limit]:
|
||||||
|
vehicle = vehicles_by_id.get(issue.entity_id)
|
||||||
|
if vehicle is None:
|
||||||
|
continue
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"vehicle_ref": vehicle.public_ref,
|
||||||
|
"severity": issue.severity,
|
||||||
|
"rule_type": issue.rule_type,
|
||||||
|
"summary": issue.evidence_json.get("summary", ""),
|
||||||
|
"detected_at": issue.detected_at,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.errors import AppError
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.idempotency import IdempotencyRecord
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas import CurrentUser, RegisterReturnRequest
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.data_quality import open_odometer_regression_issue
|
||||||
|
|
||||||
|
|
||||||
|
def _new_inspection_ref() -> str:
|
||||||
|
"""Generate a collision-resistant public reference without reading mutable counts.
|
||||||
|
|
||||||
|
Return commands for different bookings can commit concurrently. A count-based
|
||||||
|
reference made those independent transactions race for the same unique value.
|
||||||
|
"""
|
||||||
|
return f"INSP-{uuid.uuid4().hex[:10].upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_vehicle_status_with_reason(
|
||||||
|
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
||||||
|
) -> tuple[str, str, dict[str, str | int]]:
|
||||||
|
# Stable, localizable codes + params -- the backend never emits prose here. The
|
||||||
|
# frontend renders review.reasonCodes.<code> in the selected locale; the mirrored
|
||||||
|
# raw-English fallback strings live only in status_reason (shown under "Technical
|
||||||
|
# details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md.
|
||||||
|
if body.damage_reported and body.technical_warning:
|
||||||
|
return (
|
||||||
|
"blocked",
|
||||||
|
"returnBlockedDamageAndTechnical",
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
if body.damage_reported:
|
||||||
|
return "blocked", "returnBlockedDamage", {}
|
||||||
|
if body.technical_warning:
|
||||||
|
return "blocked", "returnBlockedTechnicalWarning", {}
|
||||||
|
if new_odometer >= vehicle.next_service_km:
|
||||||
|
return (
|
||||||
|
"maintenance",
|
||||||
|
"returnServiceThresholdReached",
|
||||||
|
{"threshold_km": vehicle.next_service_km},
|
||||||
|
)
|
||||||
|
return "cleaning", "returnRoutedToCleaning", {}
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = {
|
||||||
|
"returnBlockedDamageAndTechnical": (
|
||||||
|
"Damage and a technical warning were both reported on return."
|
||||||
|
),
|
||||||
|
"returnBlockedDamage": "Damage was reported on return.",
|
||||||
|
"returnBlockedTechnicalWarning": "A technical warning was reported on return.",
|
||||||
|
"returnServiceThresholdReached": "Odometer reached the service threshold.",
|
||||||
|
"returnRoutedToCleaning": (
|
||||||
|
"No damage, technical warning or service threshold; routed to cleaning."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReturnEvaluation:
|
||||||
|
canonical_odometer_km: int
|
||||||
|
submitted_odometer_km: int
|
||||||
|
odometer_regression: bool
|
||||||
|
resulting_odometer_km: int
|
||||||
|
resulting_vehicle_status: str
|
||||||
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int]
|
||||||
|
would_create_quality_issue: bool
|
||||||
|
attention_reasons: list[str]
|
||||||
|
next_booking_risk: dict | None
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_return(
|
||||||
|
db: Session, booking: Booking, vehicle: Vehicle, body: RegisterReturnRequest, *, now: datetime
|
||||||
|
) -> ReturnEvaluation:
|
||||||
|
"""Pure evaluation of what a return would do. No writes; safe to call from a
|
||||||
|
non-mutating preview endpoint. `register_vehicle_return` uses the same function so
|
||||||
|
preview and commit can never drift apart."""
|
||||||
|
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
||||||
|
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
||||||
|
resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
|
||||||
|
body, vehicle, resulting_odometer_km
|
||||||
|
)
|
||||||
|
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
|
||||||
|
|
||||||
|
attention_reasons = []
|
||||||
|
if body.damage_reported:
|
||||||
|
attention_reasons.append("damage_reported")
|
||||||
|
if body.technical_warning:
|
||||||
|
attention_reasons.append("technical_warning")
|
||||||
|
if odometer_regression:
|
||||||
|
attention_reasons.append("odometer_regression")
|
||||||
|
|
||||||
|
next_booking = db.scalar(
|
||||||
|
select(Booking)
|
||||||
|
.where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status == "reserved",
|
||||||
|
Booking.starts_at > now,
|
||||||
|
)
|
||||||
|
.order_by(Booking.starts_at.asc())
|
||||||
|
)
|
||||||
|
next_booking_risk = None
|
||||||
|
if next_booking is not None:
|
||||||
|
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
|
||||||
|
next_booking_risk = {
|
||||||
|
"booking_ref": next_booking.public_ref,
|
||||||
|
"starts_at": next_booking.starts_at.isoformat(),
|
||||||
|
"at_risk": resulting_status != "cleaning" or hours_until < 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReturnEvaluation(
|
||||||
|
canonical_odometer_km=vehicle.odometer_km,
|
||||||
|
submitted_odometer_km=body.end_odometer_km,
|
||||||
|
odometer_regression=odometer_regression,
|
||||||
|
resulting_odometer_km=resulting_odometer_km,
|
||||||
|
resulting_vehicle_status=resulting_status,
|
||||||
|
status_reason=status_reason,
|
||||||
|
status_reason_code=status_reason_code,
|
||||||
|
status_reason_params=status_reason_params,
|
||||||
|
would_create_quality_issue=odometer_regression,
|
||||||
|
attention_reasons=attention_reasons,
|
||||||
|
next_booking_risk=next_booking_risk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_active_booking_and_vehicle(
|
||||||
|
db: Session, booking_ref: str, *, lock: bool
|
||||||
|
) -> tuple[Booking, Vehicle]:
|
||||||
|
stmt = select(Booking).where(Booking.public_ref == booking_ref)
|
||||||
|
if lock:
|
||||||
|
stmt = stmt.with_for_update()
|
||||||
|
booking = db.scalar(stmt)
|
||||||
|
if booking is None:
|
||||||
|
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
|
||||||
|
|
||||||
|
vehicle_stmt = select(Vehicle).where(Vehicle.id == booking.vehicle_id)
|
||||||
|
if lock:
|
||||||
|
vehicle_stmt = vehicle_stmt.with_for_update()
|
||||||
|
vehicle = db.scalar(vehicle_stmt)
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this booking could not be found.", status_code=404
|
||||||
|
)
|
||||||
|
return booking, vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def preview_vehicle_return(
|
||||||
|
db: Session, booking_ref: str, body: RegisterReturnRequest
|
||||||
|
) -> tuple[Booking, Vehicle, ReturnEvaluation]:
|
||||||
|
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=False)
|
||||||
|
if booking.status != "active":
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_STATE",
|
||||||
|
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
evaluation = evaluate_return(db, booking, vehicle, body, now=datetime.now(UTC))
|
||||||
|
return booking, vehicle, evaluation
|
||||||
|
|
||||||
|
|
||||||
|
def request_fingerprint(body: RegisterReturnRequest) -> str:
|
||||||
|
canonical = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_or_reject(
|
||||||
|
db: Session,
|
||||||
|
existing: IdempotencyRecord,
|
||||||
|
booking_ref: str,
|
||||||
|
fingerprint: str,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
booking = db.get(Booking, existing.booking_id)
|
||||||
|
if booking is None or booking.public_ref != booking_ref:
|
||||||
|
raise AppError(
|
||||||
|
"IDEMPOTENCY_KEY_REUSED",
|
||||||
|
"This idempotency key was already used for a different booking.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
if existing.request_fingerprint is not None and existing.request_fingerprint != fingerprint:
|
||||||
|
raise AppError(
|
||||||
|
"IDEMPOTENCY_KEY_REUSED",
|
||||||
|
"This idempotency key was already used with a different request body.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
return existing.response_status, existing.response_body
|
||||||
|
|
||||||
|
|
||||||
|
def register_vehicle_return(
|
||||||
|
db: Session,
|
||||||
|
booking_ref: str,
|
||||||
|
body: RegisterReturnRequest,
|
||||||
|
idempotency_key: str,
|
||||||
|
actor: CurrentUser,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
fingerprint = request_fingerprint(body)
|
||||||
|
existing = db.scalar(
|
||||||
|
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||||
|
|
||||||
|
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
||||||
|
|
||||||
|
# Re-check after acquiring the row lock: a concurrent identical-key request may have
|
||||||
|
# just committed while we were waiting.
|
||||||
|
existing = db.scalar(
|
||||||
|
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||||
|
|
||||||
|
if booking.status != "active":
|
||||||
|
raise AppError(
|
||||||
|
"INVALID_BOOKING_STATE",
|
||||||
|
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
|
||||||
|
status_code=409,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
correlation_id = uuid.uuid4()
|
||||||
|
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
|
||||||
|
|
||||||
|
inspection = Inspection(
|
||||||
|
public_ref=_new_inspection_ref(),
|
||||||
|
booking_id=booking.id,
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
type="return",
|
||||||
|
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.end_odometer_km,
|
||||||
|
completed_at=now,
|
||||||
|
completed_by=actor.display_name,
|
||||||
|
)
|
||||||
|
db.add(inspection)
|
||||||
|
|
||||||
|
before_vehicle = {
|
||||||
|
"operational_status": vehicle.operational_status,
|
||||||
|
"odometer_km": vehicle.odometer_km,
|
||||||
|
}
|
||||||
|
|
||||||
|
booking.status = "returned"
|
||||||
|
booking.end_odometer_km = body.end_odometer_km
|
||||||
|
|
||||||
|
quality_issue_ref: str | None = None
|
||||||
|
if evaluation.odometer_regression:
|
||||||
|
issue = open_odometer_regression_issue(
|
||||||
|
db,
|
||||||
|
vehicle=vehicle,
|
||||||
|
reading_ref=inspection.public_ref,
|
||||||
|
reading_km=body.end_odometer_km,
|
||||||
|
canonical_km=evaluation.canonical_odometer_km,
|
||||||
|
source_type="return",
|
||||||
|
related_refs=[booking.public_ref, inspection.public_ref],
|
||||||
|
correctable_booking_refs=[booking.public_ref],
|
||||||
|
detected_at=now,
|
||||||
|
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
)
|
||||||
|
quality_issue_ref = issue.public_ref if issue is not None else None
|
||||||
|
|
||||||
|
resulting_status = evaluation.resulting_vehicle_status
|
||||||
|
vehicle.odometer_km = evaluation.resulting_odometer_km
|
||||||
|
vehicle.operational_status = resulting_status
|
||||||
|
vehicle.version += 1
|
||||||
|
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.display_name,
|
||||||
|
action="return_registered",
|
||||||
|
entity_type="booking",
|
||||||
|
entity_id=booking.id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
before={"status": "active"},
|
||||||
|
after={"status": "returned", "end_odometer_km": body.end_odometer_km},
|
||||||
|
metadata={"idempotency_key": idempotency_key},
|
||||||
|
)
|
||||||
|
record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label=actor.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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
event = OutboxEvent(
|
||||||
|
event_id=uuid.uuid4(),
|
||||||
|
event_type="vehicle.returned.v1",
|
||||||
|
aggregate_type="booking",
|
||||||
|
aggregate_id=booking.id,
|
||||||
|
payload_json={
|
||||||
|
"event_type": "vehicle.returned.v1",
|
||||||
|
"correlation_id": str(correlation_id),
|
||||||
|
"aggregate": {
|
||||||
|
"type": "booking",
|
||||||
|
"id": str(booking.id),
|
||||||
|
"public_ref": booking.public_ref,
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"vehicle_ref": vehicle.public_ref,
|
||||||
|
"inspection_ref": inspection.public_ref,
|
||||||
|
"resulting_vehicle_status": resulting_status,
|
||||||
|
"attention_reasons": evaluation.attention_reasons,
|
||||||
|
},
|
||||||
|
"aggregate_ref": booking.public_ref,
|
||||||
|
},
|
||||||
|
occurred_at=now,
|
||||||
|
delivery_status="pending",
|
||||||
|
attempts=0,
|
||||||
|
)
|
||||||
|
db.add(event)
|
||||||
|
|
||||||
|
response_body = {
|
||||||
|
"booking_ref": booking.public_ref,
|
||||||
|
"vehicle_ref": vehicle.public_ref,
|
||||||
|
"inspection_ref": inspection.public_ref,
|
||||||
|
"resulting_vehicle_status": resulting_status,
|
||||||
|
"odometer_regression": evaluation.odometer_regression,
|
||||||
|
"quality_issue_ref": quality_issue_ref,
|
||||||
|
"workflow_event_id": str(event.event_id),
|
||||||
|
"correlation_id": str(correlation_id),
|
||||||
|
"next_booking_risk": evaluation.next_booking_risk,
|
||||||
|
}
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
IdempotencyRecord(
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
booking_id=booking.id,
|
||||||
|
request_fingerprint=fingerprint,
|
||||||
|
response_status=201,
|
||||||
|
response_body=response_body,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
existing = db.scalar(
|
||||||
|
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return 201, response_body
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import SessionPayload, session_token_hash
|
||||||
|
from app.models.revoked_session import RevokedSession
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def is_session_revoked(db: Session, token: str) -> bool:
|
||||||
|
token_hash = session_token_hash(token)
|
||||||
|
revoked_id = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
|
||||||
|
return revoked_id is not None
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
|
||||||
|
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
|
||||||
|
token_hash = session_token_hash(token)
|
||||||
|
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
|
||||||
|
if existing is not None:
|
||||||
|
return
|
||||||
|
db.add(
|
||||||
|
RevokedSession(
|
||||||
|
token_hash=token_hash,
|
||||||
|
expires_at=datetime.fromtimestamp(
|
||||||
|
payload.issued_at + settings.session_ttl_seconds, UTC
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
"""The single authoritative vehicle-status evaluator.
|
||||||
|
|
||||||
|
Used by the data-quality scanner (detection), the status-recommendation preview
|
||||||
|
endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time
|
||||||
|
recommendation can never structurally disagree (see docs/fleet-ops-correction/
|
||||||
|
vehicle-status-decision-table.md for the full decision table and rationale).
|
||||||
|
|
||||||
|
The evaluator only ever reasons from real, freshly-queried domain facts (an actually
|
||||||
|
active rental, a real service-threshold breach, a real overlapping-booking conflict) --
|
||||||
|
never from a proxy like "does some other high-severity issue happen to be open". It is
|
||||||
|
therefore also order-independent: resolving, deferring or rejecting an unrelated issue on
|
||||||
|
the same vehicle never changes what this function returns, because it never looks at
|
||||||
|
issue history, only at the vehicle's/bookings' current state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
# Every code below is a stable, localizable identifier -- see
|
||||||
|
# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the
|
||||||
|
# human-language mapping in all three supported locales. The backend never emits prose.
|
||||||
|
RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental"
|
||||||
|
RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached"
|
||||||
|
RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict"
|
||||||
|
RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended"
|
||||||
|
RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required"
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VehicleStatusFacts:
|
||||||
|
active_booking_refs: list[str] = field(default_factory=list)
|
||||||
|
overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
service_threshold_reached: bool = False
|
||||||
|
odometer_km: int = 0
|
||||||
|
next_service_km: int = 0
|
||||||
|
open_booking_overlap_issue_ref: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_active_rental(self) -> bool:
|
||||||
|
return len(self.active_booking_refs) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_booking_conflict(self) -> bool:
|
||||||
|
return (
|
||||||
|
len(self.overlapping_booking_pairs) > 0
|
||||||
|
or self.open_booking_overlap_issue_ref is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"active_booking_refs": self.active_booking_refs,
|
||||||
|
"overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs],
|
||||||
|
"service_threshold_reached": self.service_threshold_reached,
|
||||||
|
"odometer_km": self.odometer_km,
|
||||||
|
"next_service_km": self.next_service_km,
|
||||||
|
"open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VehicleStatusRecommendation:
|
||||||
|
current_status: str
|
||||||
|
recommended_status: str | None
|
||||||
|
recommendation_code: str
|
||||||
|
safe_to_apply: bool
|
||||||
|
manual_review_required: bool
|
||||||
|
facts: VehicleStatusFacts
|
||||||
|
blocking_reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]:
|
||||||
|
ordered = sorted(bookings, key=lambda b: b.starts_at)
|
||||||
|
pairs: list[tuple[Booking, Booking]] = []
|
||||||
|
for i, first in enumerate(ordered):
|
||||||
|
for second in ordered[i + 1 :]:
|
||||||
|
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||||
|
pairs.append((first, second))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def gather_vehicle_status_facts(
|
||||||
|
db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None
|
||||||
|
) -> VehicleStatusFacts:
|
||||||
|
"""Real, freshly-queried facts only -- see module docstring. Never cached, never
|
||||||
|
derived from another issue's mere existence (only a *specific* booking_overlap
|
||||||
|
issue's presence is used, as a cross-reference to that issue's own public_ref)."""
|
||||||
|
reserved_or_active = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Booking).where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status.in_(["reserved", "active"]),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"]
|
||||||
|
overlap_pairs = [
|
||||||
|
(a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active)
|
||||||
|
]
|
||||||
|
|
||||||
|
overlap_issue_query = select(DataQualityIssue.public_ref).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.rule_type == "booking_overlap",
|
||||||
|
)
|
||||||
|
if exclude_issue_id is not None:
|
||||||
|
overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id)
|
||||||
|
open_overlap_ref = db.scalar(overlap_issue_query)
|
||||||
|
|
||||||
|
return VehicleStatusFacts(
|
||||||
|
active_booking_refs=active_refs,
|
||||||
|
overlapping_booking_pairs=overlap_pairs,
|
||||||
|
service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km,
|
||||||
|
odometer_km=vehicle.odometer_km,
|
||||||
|
next_service_km=vehicle.next_service_km,
|
||||||
|
open_booking_overlap_issue_ref=open_overlap_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str:
|
||||||
|
"""A short digest of exactly the facts the recommendation was based on, plus the
|
||||||
|
vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh
|
||||||
|
facts and rejects the request if it doesn't match the token the client last saw --
|
||||||
|
the frontend must never assume a previously-shown preview is still valid without the
|
||||||
|
server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F)."""
|
||||||
|
payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()}
|
||||||
|
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
|
return digest[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_vehicle_status(
|
||||||
|
vehicle: Vehicle, facts: VehicleStatusFacts
|
||||||
|
) -> VehicleStatusRecommendation:
|
||||||
|
"""Pure decision logic over already-gathered facts -- see
|
||||||
|
docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything,
|
||||||
|
never queries the database itself (call gather_vehicle_status_facts first), so it is
|
||||||
|
trivial to unit-test every branch in isolation."""
|
||||||
|
current = vehicle.operational_status
|
||||||
|
blocking_reasons: list[str] = []
|
||||||
|
if facts.service_threshold_reached:
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||||
|
if facts.has_booking_conflict:
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT)
|
||||||
|
if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons:
|
||||||
|
# Already being in maintenance is itself a real blocking fact -- an active
|
||||||
|
# booking never overrides it. This is exactly the forbidden shortcut this
|
||||||
|
# evaluator must never take (maintenance + active booking -> auto "rented").
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||||
|
|
||||||
|
def result(
|
||||||
|
recommended: str | None, code: str, *, safe: bool, manual: bool
|
||||||
|
) -> VehicleStatusRecommendation:
|
||||||
|
return VehicleStatusRecommendation(
|
||||||
|
current_status=current,
|
||||||
|
recommended_status=recommended,
|
||||||
|
recommendation_code=code,
|
||||||
|
safe_to_apply=safe,
|
||||||
|
manual_review_required=manual,
|
||||||
|
facts=facts,
|
||||||
|
blocking_reasons=blocking_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
if facts.has_active_rental and not blocking_reasons:
|
||||||
|
if current == "rented":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result("rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False)
|
||||||
|
|
||||||
|
if facts.has_active_rental and blocking_reasons:
|
||||||
|
# Explicitly forbidden shortcut this evaluator must never take: an active
|
||||||
|
# booking is not proof the vehicle should be "rented" when a real blocking
|
||||||
|
# condition also exists (e.g. maintenance-due, or a genuine booking conflict).
|
||||||
|
# This is a real contradiction in the underlying facts, not something safe to
|
||||||
|
# resolve automatically.
|
||||||
|
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||||
|
|
||||||
|
if facts.service_threshold_reached:
|
||||||
|
if current == "maintenance":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result("maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False)
|
||||||
|
|
||||||
|
if facts.has_booking_conflict:
|
||||||
|
if current == "blocked":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result("blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False)
|
||||||
|
|
||||||
|
# No active rental, no maintenance need, no booking conflict.
|
||||||
|
if current in ("available", "cleaning", "blocked"):
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
if current == "rented":
|
||||||
|
return result("available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False)
|
||||||
|
if current == "maintenance":
|
||||||
|
# No positive fact confirms maintenance is actually finished (no completed
|
||||||
|
# service record is tracked here) -- clearing "maintenance" without such a
|
||||||
|
# fact would be exactly the kind of unsafe shortcut this evaluator forbids.
|
||||||
|
# Releasing a vehicle from maintenance remains an explicit, manual decision.
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
|
||||||
|
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
|
||||||
|
alembic upgrade head
|
||||||
|
fi
|
||||||
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mobilityops-api"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "MobilityOps API scaffold"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115,<1",
|
||||||
|
"uvicorn[standard]>=0.30,<1",
|
||||||
|
"pydantic-settings>=2.5,<3",
|
||||||
|
"sqlalchemy>=2.0,<3",
|
||||||
|
"psycopg[binary]>=3.2,<4",
|
||||||
|
"alembic>=1.13,<2",
|
||||||
|
"httpx>=0.27,<1",
|
||||||
|
"authlib>=1.6,<2",
|
||||||
|
"itsdangerous>=2.2,<3",
|
||||||
|
"prometheus-client>=0.24,<1"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8,<9",
|
||||||
|
# Starlette's TestClient (>=1.0) prefers httpx2 and warns when only httpx is present.
|
||||||
|
"httpx2>=2.10,<3",
|
||||||
|
"ruff>=0.8,<1",
|
||||||
|
"mypy>=1.13,<2"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["app"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
extend-exclude = ["alembic/versions", "seed"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "B", "UP"]
|
||||||
|
ignore = ["B008"]
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.12
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --constraint=requirements.lock --output-file=requirements-prod.lock pyproject.toml
|
||||||
|
#
|
||||||
|
alembic==1.18.5
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
annotated-doc==0.0.5
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# fastapi
|
||||||
|
annotated-types==0.8.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# pydantic
|
||||||
|
anyio==4.14.2
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# httpx
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
authlib==1.7.2
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
certifi==2026.7.22
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
cffi==2.1.1
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# cryptography
|
||||||
|
click==8.4.2
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# uvicorn
|
||||||
|
cryptography==50.0.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# authlib
|
||||||
|
# joserfc
|
||||||
|
fastapi==0.141.1
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
greenlet==3.5.4
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# sqlalchemy
|
||||||
|
h11==0.16.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# httpcore
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# httpx
|
||||||
|
httptools==0.8.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# uvicorn
|
||||||
|
httpx==0.28.1
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
idna==3.18
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
joserfc==1.7.4
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# authlib
|
||||||
|
mako==1.3.12
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# alembic
|
||||||
|
markupsafe==3.0.3
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mako
|
||||||
|
prometheus-client==0.26.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
psycopg[binary]==3.3.4
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
psycopg-binary==3.3.4
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# psycopg
|
||||||
|
pycparser==3.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# cffi
|
||||||
|
pydantic==2.13.4
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# fastapi
|
||||||
|
# pydantic-settings
|
||||||
|
pydantic-core==2.46.4
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# pydantic
|
||||||
|
pydantic-settings==2.14.2
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# pydantic-settings
|
||||||
|
# uvicorn
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# uvicorn
|
||||||
|
sqlalchemy==2.0.51
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# alembic
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
starlette==1.3.1
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# fastapi
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# alembic
|
||||||
|
# anyio
|
||||||
|
# fastapi
|
||||||
|
# psycopg
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# sqlalchemy
|
||||||
|
# starlette
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# fastapi
|
||||||
|
# pydantic
|
||||||
|
# pydantic-settings
|
||||||
|
uvicorn[standard]==0.52.1
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
uvloop==0.22.1
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# uvicorn
|
||||||
|
watchfiles==1.2.0
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# uvicorn
|
||||||
|
websockets==17.0.1
|
||||||
|
# via
|
||||||
|
# -c requirements.lock
|
||||||
|
# uvicorn
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
#
|
||||||
|
# This file is autogenerated by pip-compile with Python 3.12
|
||||||
|
# by the following command:
|
||||||
|
#
|
||||||
|
# pip-compile --extra=dev --output-file=requirements.lock pyproject.toml
|
||||||
|
#
|
||||||
|
alembic==1.18.5
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
annotated-doc==0.0.5
|
||||||
|
# via fastapi
|
||||||
|
annotated-types==0.8.0
|
||||||
|
# via pydantic
|
||||||
|
anyio==4.14.2
|
||||||
|
# via
|
||||||
|
# httpx
|
||||||
|
# httpx2
|
||||||
|
# starlette
|
||||||
|
# watchfiles
|
||||||
|
authlib==1.7.2
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
certifi==2026.7.22
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpx
|
||||||
|
cffi==2.1.1
|
||||||
|
# via cryptography
|
||||||
|
click==8.4.2
|
||||||
|
# via uvicorn
|
||||||
|
cryptography==50.0.0
|
||||||
|
# via
|
||||||
|
# authlib
|
||||||
|
# joserfc
|
||||||
|
fastapi==0.141.1
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
greenlet==3.5.4
|
||||||
|
# via sqlalchemy
|
||||||
|
h11==0.16.0
|
||||||
|
# via
|
||||||
|
# httpcore
|
||||||
|
# httpcore2
|
||||||
|
# uvicorn
|
||||||
|
httpcore==1.0.9
|
||||||
|
# via httpx
|
||||||
|
httpcore2==2.10.0
|
||||||
|
# via httpx2
|
||||||
|
httptools==0.8.0
|
||||||
|
# via uvicorn
|
||||||
|
httpx==0.28.1
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
httpx2==2.10.0
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
idna==3.18
|
||||||
|
# via
|
||||||
|
# anyio
|
||||||
|
# httpx
|
||||||
|
# httpx2
|
||||||
|
iniconfig==2.3.0
|
||||||
|
# via pytest
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
joserfc==1.7.4
|
||||||
|
# via authlib
|
||||||
|
librt==0.13.0
|
||||||
|
# via mypy
|
||||||
|
mako==1.3.12
|
||||||
|
# via alembic
|
||||||
|
markupsafe==3.0.3
|
||||||
|
# via mako
|
||||||
|
mypy==1.20.2
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
mypy-extensions==1.1.0
|
||||||
|
# via mypy
|
||||||
|
packaging==26.2
|
||||||
|
# via pytest
|
||||||
|
pathspec==1.1.1
|
||||||
|
# via mypy
|
||||||
|
pluggy==1.6.0
|
||||||
|
# via pytest
|
||||||
|
prometheus-client==0.26.0
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
psycopg[binary]==3.3.4
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
psycopg-binary==3.3.4
|
||||||
|
# via psycopg
|
||||||
|
pycparser==3.0
|
||||||
|
# via cffi
|
||||||
|
pydantic==2.13.4
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# pydantic-settings
|
||||||
|
pydantic-core==2.46.4
|
||||||
|
# via pydantic
|
||||||
|
pydantic-settings==2.14.2
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
pygments==2.20.0
|
||||||
|
# via pytest
|
||||||
|
pytest==8.4.2
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
# via
|
||||||
|
# pydantic-settings
|
||||||
|
# uvicorn
|
||||||
|
pyyaml==6.0.3
|
||||||
|
# via uvicorn
|
||||||
|
ruff==0.16.1
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
sqlalchemy==2.0.51
|
||||||
|
# via
|
||||||
|
# alembic
|
||||||
|
# mobilityops-api (pyproject.toml)
|
||||||
|
starlette==1.3.1
|
||||||
|
# via fastapi
|
||||||
|
truststore==0.10.4
|
||||||
|
# via
|
||||||
|
# httpcore2
|
||||||
|
# httpx2
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
# via
|
||||||
|
# alembic
|
||||||
|
# anyio
|
||||||
|
# fastapi
|
||||||
|
# httpx2
|
||||||
|
# mypy
|
||||||
|
# psycopg
|
||||||
|
# pydantic
|
||||||
|
# pydantic-core
|
||||||
|
# sqlalchemy
|
||||||
|
# starlette
|
||||||
|
# typing-inspection
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
# via
|
||||||
|
# fastapi
|
||||||
|
# pydantic
|
||||||
|
# pydantic-settings
|
||||||
|
uvicorn[standard]==0.52.1
|
||||||
|
# via mobilityops-api (pyproject.toml)
|
||||||
|
uvloop==0.22.1
|
||||||
|
# via uvicorn
|
||||||
|
watchfiles==1.2.0
|
||||||
|
# via uvicorn
|
||||||
|
websockets==17.0.1
|
||||||
|
# via uvicorn
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Generate the checked-in API contract from the FastAPI application."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "backend"))
|
||||||
|
|
||||||
|
from app.main import app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
schema = app.openapi()
|
||||||
|
schema["info"]["description"] = (
|
||||||
|
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
|
||||||
|
"MobilityOps remains the technical repository and service identifier."
|
||||||
|
)
|
||||||
|
schema["servers"] = [{"url": "http://localhost:8128"}]
|
||||||
|
output = ROOT / "contracts" / "openapi.yaml"
|
||||||
|
output.write_text(
|
||||||
|
yaml.safe_dump(schema, sort_keys=False, allow_unicode=True, width=100),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.db import Base, SessionLocal, engine
|
||||||
|
from app.main import app
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
|
def _seeded_database():
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
reset_and_seed(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client() -> TestClient:
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def login(client: TestClient, role: str) -> TestClient:
|
||||||
|
response = client.post("/api/v1/demo/login", json={"role": role})
|
||||||
|
assert response.status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ops_client(client: TestClient) -> TestClient:
|
||||||
|
return login(client, "operations_manager")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def employee_client(client: TestClient) -> TestClient:
|
||||||
|
return login(client, "rental_employee")
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_login_is_audited(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/audit", params={"action": "demo_login"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
events = response.json()
|
||||||
|
assert len(events) >= 1
|
||||||
|
assert events[0]["action"] == "demo_login"
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_requires_authentication(client):
|
||||||
|
response = client.get("/api/v1/audit")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.get("/api/v1/audit")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_audit_page_is_bounded_and_exposes_filter_metadata(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/audit", params={"page": 1, "page_size": 25})
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["items"]) <= 25
|
||||||
|
assert body["page"] == 1
|
||||||
|
assert body["page_size"] == 25
|
||||||
|
assert body["total"] >= len(body["items"])
|
||||||
|
assert body["total_pages"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
|
||||||
|
booking = db.scalar(
|
||||||
|
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
|
||||||
|
)
|
||||||
|
booking.status = "active"
|
||||||
|
booking.start_odometer_km = start_odometer_km
|
||||||
|
booking.end_odometer_km = None
|
||||||
|
db.commit()
|
||||||
|
return booking.public_ref
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_return_registered_audit_event_exposes_before_after_and_link(ops_client):
|
||||||
|
booking_ref = _activate_booking("MO-015", start_odometer_km=17000)
|
||||||
|
vehicle_before = ops_client.get("/api/v1/vehicles/MO-015").json()
|
||||||
|
ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking_ref}/return",
|
||||||
|
json={
|
||||||
|
"end_odometer_km": vehicle_before["odometer_km"] + 10,
|
||||||
|
"fuel_level_percent": 50,
|
||||||
|
"cleanliness_ok": True,
|
||||||
|
"damage_reported": False,
|
||||||
|
"technical_warning": False,
|
||||||
|
},
|
||||||
|
headers={"Idempotency-Key": "test-audit-before-after-001"},
|
||||||
|
)
|
||||||
|
|
||||||
|
key = "test-audit-before-after-001"
|
||||||
|
events = ops_client.get("/api/v1/audit", params={"action": "return_registered"}).json()
|
||||||
|
event = next(e for e in events if e["metadata"]["idempotency_key"] == key)
|
||||||
|
assert event["before"] == {"status": "active"}
|
||||||
|
assert event["after"]["status"] == "returned"
|
||||||
|
assert event["entity_ref"] == booking_ref
|
||||||
|
assert event["entity_link"] == f"/bookings/{booking_ref}"
|
||||||
|
|
||||||
|
vehicle_events = ops_client.get(
|
||||||
|
"/api/v1/audit",
|
||||||
|
params={"action": "vehicle_status_changed", "correlation_id": event["correlation_id"]},
|
||||||
|
).json()
|
||||||
|
assert len(vehicle_events) == 1
|
||||||
|
assert vehicle_events[0]["entity_ref"] == "MO-015"
|
||||||
|
assert vehicle_events[0]["entity_link"] == "/vehicles/MO-015"
|
||||||
|
assert vehicle_events[0]["before"]["odometer_km"] == vehicle_before["odometer_km"]
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, func, select
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal, engine
|
||||||
|
from app.models.audit import AuditEvent
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.seed_loader import reset_and_seed
|
||||||
|
from app.services.audit import record_audit_event
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_dashboard_is_rejected(client):
|
||||||
|
response = client.get("/api/v1/dashboard")
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()["error"]["code"] == "401"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_status_is_disabled_without_configuration(client):
|
||||||
|
assert client.get("/api/v1/auth/oidc/status").json() == {
|
||||||
|
"enabled": False,
|
||||||
|
"provider_name": None,
|
||||||
|
}
|
||||||
|
assert client.get("/api/v1/auth/oidc/login").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_callback_auto_provisions_and_logs_in(client, monkeypatch):
|
||||||
|
import app.api.routers.auth as auth_router
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def authorize_access_token(self, _request):
|
||||||
|
return {
|
||||||
|
"userinfo": {
|
||||||
|
"sub": "external-subject-1",
|
||||||
|
"email": "oidc.user@example.test",
|
||||||
|
"email_verified": True,
|
||||||
|
"name": "OIDC User",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeOAuth:
|
||||||
|
def create_client(self, _name):
|
||||||
|
return FakeClient()
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
|
||||||
|
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
|
||||||
|
|
||||||
|
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
|
||||||
|
assert response.status_code == 307
|
||||||
|
assert response.headers["location"].endswith("/dashboard")
|
||||||
|
session = client.get("/api/v1/auth/session")
|
||||||
|
assert session.status_code == 200
|
||||||
|
assert session.json()["display_name"] == "OIDC User"
|
||||||
|
assert session.json()["role"] == "rental_employee"
|
||||||
|
|
||||||
|
|
||||||
|
def test_oidc_callback_rejects_missing_email_verification_claim(client, monkeypatch):
|
||||||
|
import app.api.routers.auth as auth_router
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
async def authorize_access_token(self, _request):
|
||||||
|
return {"userinfo": {"sub": "unverified-subject", "email": "new@example.test"}}
|
||||||
|
|
||||||
|
class FakeOAuth:
|
||||||
|
def create_client(self, _name):
|
||||||
|
return FakeClient()
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_enabled", True)
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_issuer_url", "https://id.example.test")
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_client_id", "client")
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_client_secret", "secret")
|
||||||
|
monkeypatch.setattr(auth_router.settings, "oidc_allowed_email_domains", "example.test")
|
||||||
|
monkeypatch.setattr(auth_router, "oauth", FakeOAuth())
|
||||||
|
|
||||||
|
response = client.get("/api/v1/auth/oidc/callback", follow_redirects=False)
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert client.get("/api/v1/auth/session").status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_login_grants_access(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_rental_employee_cannot_reset_demo(employee_client):
|
||||||
|
response = employee_client.post("/api/v1/demo/reset")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_operations_manager_can_reset_demo(ops_client):
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["counts"]["vehicles"] == 50
|
||||||
|
assert body["anchor_date"]
|
||||||
|
assert body["seeded_at"]
|
||||||
|
assert body["scenario_integrity"]["all_ready"] is True
|
||||||
|
assert body["scenario_integrity"]["not_ready"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_reset_rejects_concurrent_rebuild(ops_client):
|
||||||
|
import app.api.routers.demo as demo_router
|
||||||
|
|
||||||
|
assert demo_router._reset_guard.acquire(blocking=False)
|
||||||
|
try:
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
finally:
|
||||||
|
demo_router._reset_guard.release()
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_reset_database_lock_blocks_another_api_replica(ops_client):
|
||||||
|
import app.api.routers.demo as demo_router
|
||||||
|
|
||||||
|
# Use a raw connection: an ordinary Session intentionally participates in the
|
||||||
|
# shared mutation barrier and would make an exclusive reset wait before it can test
|
||||||
|
# this separate non-blocking replica lock.
|
||||||
|
with engine.connect() as lock_connection:
|
||||||
|
lock_connection.scalar(select(func.pg_advisory_lock(demo_router._RESET_ADVISORY_LOCK_ID)))
|
||||||
|
try:
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
finally:
|
||||||
|
lock_connection.scalar(
|
||||||
|
select(func.pg_advisory_unlock(demo_router._RESET_ADVISORY_LOCK_ID))
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_reset_waits_for_active_mutation_and_then_replaces_it_atomically():
|
||||||
|
started = threading.Event()
|
||||||
|
completed = threading.Event()
|
||||||
|
errors: list[BaseException] = []
|
||||||
|
|
||||||
|
def reset_in_other_session() -> None:
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
with SessionLocal() as reset_db:
|
||||||
|
reset_and_seed(reset_db)
|
||||||
|
except BaseException as exc: # pragma: no cover - assertion reports thread failures
|
||||||
|
errors.append(exc)
|
||||||
|
finally:
|
||||||
|
completed.set()
|
||||||
|
|
||||||
|
with SessionLocal() as mutation_db:
|
||||||
|
vehicle = mutation_db.scalar(
|
||||||
|
select(Vehicle).where(Vehicle.public_ref == "MO-001").with_for_update()
|
||||||
|
)
|
||||||
|
vehicle.location = "Concurrent mutation marker"
|
||||||
|
mutation_db.flush()
|
||||||
|
worker = threading.Thread(target=reset_in_other_session, daemon=True)
|
||||||
|
worker.start()
|
||||||
|
assert started.wait(timeout=1)
|
||||||
|
assert not completed.wait(timeout=0.2)
|
||||||
|
mutation_db.commit()
|
||||||
|
|
||||||
|
worker.join(timeout=10)
|
||||||
|
assert not worker.is_alive(), "reset deadlocked behind the active mutation"
|
||||||
|
assert errors == []
|
||||||
|
with SessionLocal() as db:
|
||||||
|
restored = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-001"))
|
||||||
|
assert restored.location != "Concurrent mutation marker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_reset_cooldown_returns_retry_after(ops_client, monkeypatch):
|
||||||
|
import app.api.routers.demo as demo_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 60)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
cooldown_event = record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="user",
|
||||||
|
actor_label="Cooldown test",
|
||||||
|
action="demo_reset",
|
||||||
|
entity_type="system",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
cooldown_event_id = cooldown_event.id
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
assert response.status_code == 429
|
||||||
|
assert int(response.headers["retry-after"]) >= 1
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(delete(AuditEvent).where(AuditEvent.id == cooldown_event_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_reset_rolls_back_every_change_when_integrity_check_fails(ops_client, monkeypatch):
|
||||||
|
import app.api.routers.demo as demo_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(demo_router.settings, "demo_reset_cooldown_seconds", 0)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
probe = record_audit_event(
|
||||||
|
db,
|
||||||
|
actor_type="system",
|
||||||
|
actor_label="Atomic reset test",
|
||||||
|
action="reset_atomicity_probe",
|
||||||
|
entity_type="system",
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
probe_id = probe.id
|
||||||
|
|
||||||
|
def fail_integrity_check(_db):
|
||||||
|
raise RuntimeError("Injected integrity-check failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(demo_router, "scenario_integrity_report", fail_integrity_check)
|
||||||
|
with pytest.raises(RuntimeError, match="Injected integrity-check failure"):
|
||||||
|
ops_client.post("/api/v1/demo/reset")
|
||||||
|
|
||||||
|
with SessionLocal() as db:
|
||||||
|
assert db.scalar(select(AuditEvent).where(AuditEvent.id == probe_id)) is not None
|
||||||
|
db.execute(delete(AuditEvent).where(AuditEvent.id == probe_id))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||||
|
import app.api.routers.demo as demo_router
|
||||||
|
|
||||||
|
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", False)
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
# Restore real demo data: this test intentionally disabled reset, so a following test
|
||||||
|
# module must not inherit a database left mid-mutation by an earlier test.
|
||||||
|
monkeypatch.setattr(demo_router.settings, "demo_allow_reset", True)
|
||||||
|
assert ops_client.post("/api/v1/demo/reset").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_endpoint_requires_authentication(client):
|
||||||
|
response = client.get("/api/v1/demo/session")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_endpoint_confirms_logged_in_user(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["role"] == "operations_manager"
|
||||||
|
assert body["public_ref"] == "USR-OPS"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_invalidates_session(ops_client):
|
||||||
|
confirmed = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert confirmed.status_code == 200
|
||||||
|
|
||||||
|
logout = ops_client.post("/api/v1/demo/logout")
|
||||||
|
assert logout.status_code == 200
|
||||||
|
|
||||||
|
after = ops_client.get("/api/v1/demo/session")
|
||||||
|
assert after.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_rejects_a_cookie_even_if_the_browser_retains_it(ops_client):
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
cookie_name = get_settings().session_cookie_name
|
||||||
|
stolen_token = ops_client.cookies.get(cookie_name)
|
||||||
|
assert stolen_token
|
||||||
|
|
||||||
|
logout = ops_client.post("/api/v1/auth/logout")
|
||||||
|
assert logout.status_code == 200
|
||||||
|
|
||||||
|
# Simulate the browser cookie race (or a copied cookie): server-side revocation is
|
||||||
|
# authoritative and must reject the original signed token independently of deletion.
|
||||||
|
ops_client.cookies.set(cookie_name, stolen_token)
|
||||||
|
assert ops_client.get("/api/v1/auth/session").status_code == 401
|
||||||
|
|
||||||
|
# Remove the deliberately injected hostless cookie before exercising a normal browser
|
||||||
|
# login. Otherwise httpx sends it alongside the real testserver cookie, which is not a
|
||||||
|
# state a browser can create for the same origin/path pair.
|
||||||
|
ops_client.cookies.clear()
|
||||||
|
|
||||||
|
# A fresh login in the same second receives a distinct signed token and remains valid.
|
||||||
|
fresh_login = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||||
|
assert fresh_login.status_code == 200
|
||||||
|
assert ops_client.get("/api/v1/auth/session").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_without_a_session_is_safe(client):
|
||||||
|
response = client.post("/api/v1/demo/logout")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_demo_reset_preserves_integration_telemetry(ops_client, client):
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
probe = client.get(
|
||||||
|
"/api/v1/integrations/mcp/operations-summary",
|
||||||
|
headers={
|
||||||
|
"X-Service-Token": settings.mcp_hub_service_token,
|
||||||
|
"X-Client-Id": "itworx-mcp-hub:mobilityops:reset-probe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert probe.status_code == 200
|
||||||
|
assert ops_client.post("/api/v1/demo/reset").status_code == 200
|
||||||
|
|
||||||
|
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
|
||||||
|
events = client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||||
|
assert any(
|
||||||
|
event["actor_label"] == "itworx-mcp-hub"
|
||||||
|
and event["metadata"].get("reported_client_id", "").endswith(":reset-probe")
|
||||||
|
for event in events
|
||||||
|
)
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
import threading
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_bookings_filters_by_vehicle(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-024"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
bookings = response.json()
|
||||||
|
assert len(bookings) >= 1
|
||||||
|
assert all(b["vehicle_ref"] == "MO-024" for b in bookings)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_bookings_supports_bounded_search_pages(ops_client):
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
params={"query": "BK-", "page": 1, "page_size": 25},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["page"] == 1
|
||||||
|
assert body["page_size"] == 25
|
||||||
|
assert body["total"] > 25
|
||||||
|
assert body["total_pages"] > 1
|
||||||
|
assert len(body["items"]) == 25
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_bookings_filters_operational_window_and_location(ops_client):
|
||||||
|
booking = ops_client.get("/api/v1/bookings").json()[0]
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{booking['vehicle_ref']}").json()
|
||||||
|
starts_at = datetime.fromisoformat(booking["starts_at"])
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
params={
|
||||||
|
"starts_from": (starts_at - timedelta(minutes=1)).isoformat(),
|
||||||
|
"starts_to": (starts_at + timedelta(minutes=1)).isoformat(),
|
||||||
|
"location": vehicle["location"],
|
||||||
|
"sort": "starts_asc",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert booking["public_ref"] in {item["public_ref"] for item in response.json()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
|
||||||
|
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
||||||
|
conflict = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0001",
|
||||||
|
"vehicle_ref": existing["vehicle_ref"],
|
||||||
|
"starts_at": existing["starts_at"],
|
||||||
|
"ends_at": existing["ends_at"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
available_vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[
|
||||||
|
0
|
||||||
|
]["public_ref"]
|
||||||
|
created = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0001",
|
||||||
|
"vehicle_ref": available_vehicle,
|
||||||
|
"starts_at": "2030-09-01T10:00:00Z",
|
||||||
|
"ends_at": "2030-09-02T12:00:00Z",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
body = created.json()
|
||||||
|
assert body["status"] == "reserved"
|
||||||
|
assert body["vehicle_ref"] == available_vehicle
|
||||||
|
assert body["requirements_complete"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_booking_requirements_are_explicit_and_audited(ops_client):
|
||||||
|
window = {"starts_at": "2031-09-01T10:00:00Z", "ends_at": "2031-09-02T12:00:00Z"}
|
||||||
|
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
|
||||||
|
booking = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={"customer_ref": "CUS-0001", "vehicle_ref": vehicle["public_ref"], **window},
|
||||||
|
).json()
|
||||||
|
checkout = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||||
|
json={
|
||||||
|
"start_odometer_km": 100000,
|
||||||
|
"fuel_level_percent": 90,
|
||||||
|
"cleanliness_ok": True,
|
||||||
|
"damage_reported": False,
|
||||||
|
"technical_warning": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert checkout.status_code == 409
|
||||||
|
|
||||||
|
whitespace_only = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||||
|
json={"confirmation": " "},
|
||||||
|
)
|
||||||
|
assert whitespace_only.status_code == 422
|
||||||
|
assert (
|
||||||
|
ops_client.get(f"/api/v1/bookings/{booking['public_ref']}").json()["requirements_complete"]
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
confirmed = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||||
|
json={"confirmation": "Licence and rental conditions checked"},
|
||||||
|
)
|
||||||
|
assert confirmed.status_code == 200
|
||||||
|
assert confirmed.json()["requirements_complete"] is True
|
||||||
|
audit = ops_client.get("/api/v1/audit", params={"action": "booking_requirements_completed"})
|
||||||
|
assert audit.status_code == 200
|
||||||
|
assert any(item["entity_ref"] == booking["public_ref"] for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_booking_creation_cannot_preconfirm_requirements(ops_client):
|
||||||
|
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
|
||||||
|
vehicle = ops_client.get("/api/v1/bookings/availability", params=window).json()[0]
|
||||||
|
|
||||||
|
created = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0001",
|
||||||
|
"vehicle_ref": vehicle["public_ref"],
|
||||||
|
"requirements_complete": True,
|
||||||
|
**window,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["requirements_complete"] is False
|
||||||
|
audit = ops_client.get(
|
||||||
|
"/api/v1/audit",
|
||||||
|
params={"action": "booking_requirements_completed"},
|
||||||
|
)
|
||||||
|
assert all(item["entity_ref"] != created.json()["public_ref"] for item in audit.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_customer_search_returns_canonical_customers(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/customers", params={"query": "CUS-"})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()
|
||||||
|
assert all(item["public_ref"].startswith("CUS-") for item in response.json())
|
||||||
|
|
||||||
|
|
||||||
|
def test_booking_availability_excludes_overlapping_vehicle(ops_client):
|
||||||
|
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
||||||
|
response = ops_client.get(
|
||||||
|
"/api/v1/bookings/availability",
|
||||||
|
params={"starts_at": existing["starts_at"], "ends_at": existing["ends_at"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert existing["vehicle_ref"] not in {item["public_ref"] for item in response.json()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_booking_can_be_cancelled_once(ops_client):
|
||||||
|
window = {"starts_at": "2032-09-01T10:00:00Z", "ends_at": "2032-09-02T12:00:00Z"}
|
||||||
|
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||||
|
assert available
|
||||||
|
create_response = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0001",
|
||||||
|
"vehicle_ref": available[0]["public_ref"],
|
||||||
|
**window,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert create_response.status_code == 201
|
||||||
|
created = create_response.json()
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{created['public_ref']}/cancel",
|
||||||
|
json={"reason": "Customer changed plans"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "cancelled"
|
||||||
|
repeated = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{created['public_ref']}/cancel",
|
||||||
|
json={"reason": "Customer changed plans"},
|
||||||
|
)
|
||||||
|
assert repeated.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_reserved_booking_can_be_rescheduled_with_overlap_protection(ops_client):
|
||||||
|
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
|
||||||
|
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
|
||||||
|
booking = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={"customer_ref": "CUS-0001", "vehicle_ref": existing["vehicle_ref"], **window},
|
||||||
|
).json()
|
||||||
|
updated = ops_client.patch(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/schedule",
|
||||||
|
json={
|
||||||
|
"starts_at": "2033-09-03T10:00:00Z",
|
||||||
|
"ends_at": "2033-09-04T12:00:00Z",
|
||||||
|
"reason": "Customer requested a later collection",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert updated.status_code == 200
|
||||||
|
assert updated.json()["starts_at"].startswith("2033-09-03T10:00:00")
|
||||||
|
|
||||||
|
conflict = ops_client.patch(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/schedule",
|
||||||
|
json={
|
||||||
|
"starts_at": existing["starts_at"],
|
||||||
|
"ends_at": existing["ends_at"],
|
||||||
|
"reason": "Conflicting test move",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert conflict.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_bookings_only_reserve_vehicle_once():
|
||||||
|
results: list[int] = []
|
||||||
|
seed_client = TestClient(app)
|
||||||
|
seed_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||||
|
window = {"starts_at": "2040-09-01T10:00:00Z", "ends_at": "2040-09-02T12:00:00Z"}
|
||||||
|
available = seed_client.get("/api/v1/bookings/availability", params=window).json()
|
||||||
|
assert available
|
||||||
|
vehicle_ref = available[0]["public_ref"]
|
||||||
|
|
||||||
|
def submit() -> None:
|
||||||
|
client = TestClient(app)
|
||||||
|
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0001",
|
||||||
|
"vehicle_ref": vehicle_ref,
|
||||||
|
**window,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results.append(response.status_code)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=submit) for _ in range(2)]
|
||||||
|
for thread in threads:
|
||||||
|
thread.start()
|
||||||
|
for thread in threads:
|
||||||
|
thread.join()
|
||||||
|
|
||||||
|
assert results.count(201) == 1
|
||||||
|
assert results.count(409) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_records_inspection_and_activates_safe_booking(ops_client):
|
||||||
|
window = {"starts_at": "2050-09-01T10:00:00Z", "ends_at": "2050-09-02T12:00:00Z"}
|
||||||
|
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||||
|
vehicle_option = next(item for item in available if item["operational_status"] == "available")
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||||
|
booking = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0001",
|
||||||
|
"vehicle_ref": vehicle["public_ref"],
|
||||||
|
**window,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
confirmed = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||||
|
json={"confirmation": "Licence and rental conditions checked"},
|
||||||
|
)
|
||||||
|
assert confirmed.status_code == 200
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||||
|
json={
|
||||||
|
"start_odometer_km": vehicle["odometer_km"],
|
||||||
|
"fuel_level_percent": 95,
|
||||||
|
"cleanliness_ok": True,
|
||||||
|
"damage_reported": False,
|
||||||
|
"technical_warning": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["activated"] is True
|
||||||
|
assert response.json()["booking_status"] == "active"
|
||||||
|
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||||
|
assert updated_vehicle["operational_status"] == "rented"
|
||||||
|
assert any(item["type"] == "checkout" for item in updated_vehicle["inspections"])
|
||||||
|
checkout_audit = ops_client.get(
|
||||||
|
"/api/v1/audit",
|
||||||
|
params={"action": "booking_checkout_recorded"},
|
||||||
|
).json()
|
||||||
|
booking_event = next(
|
||||||
|
item for item in checkout_audit if item["entity_ref"] == booking["public_ref"]
|
||||||
|
)
|
||||||
|
vehicle_audit = ops_client.get(
|
||||||
|
"/api/v1/audit",
|
||||||
|
params={
|
||||||
|
"action": "vehicle_status_changed",
|
||||||
|
"correlation_id": booking_event["correlation_id"],
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert len(vehicle_audit) == 1
|
||||||
|
assert vehicle_audit[0]["entity_ref"] == vehicle["public_ref"]
|
||||||
|
assert vehicle_audit[0]["before"]["operational_status"] == "available"
|
||||||
|
assert vehicle_audit[0]["after"]["operational_status"] == "rented"
|
||||||
|
|
||||||
|
|
||||||
|
def test_checkout_odometer_regression_keeps_canonical_and_opens_bounded_quality_issue(
|
||||||
|
ops_client,
|
||||||
|
):
|
||||||
|
window = {"starts_at": "2052-09-01T10:00:00Z", "ends_at": "2052-09-02T12:00:00Z"}
|
||||||
|
existing_issues = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"status": "open", "rule_type": "odometer_regression"},
|
||||||
|
).json()
|
||||||
|
unavailable_refs = {issue["entity_ref"] for issue in existing_issues}
|
||||||
|
available = ops_client.get("/api/v1/bookings/availability", params=window).json()
|
||||||
|
vehicle_option = next(
|
||||||
|
item
|
||||||
|
for item in available
|
||||||
|
if item["operational_status"] == "available" and item["public_ref"] not in unavailable_refs
|
||||||
|
)
|
||||||
|
vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle_option['public_ref']}").json()
|
||||||
|
assert vehicle["odometer_km"] > 0
|
||||||
|
booking = ops_client.post(
|
||||||
|
"/api/v1/bookings",
|
||||||
|
json={
|
||||||
|
"customer_ref": "CUS-0003",
|
||||||
|
"vehicle_ref": vehicle["public_ref"],
|
||||||
|
**window,
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/complete-requirements",
|
||||||
|
json={"confirmation": "Licence and rental conditions checked"},
|
||||||
|
)
|
||||||
|
|
||||||
|
checkout = ops_client.post(
|
||||||
|
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||||
|
json={
|
||||||
|
"start_odometer_km": vehicle["odometer_km"] - 1,
|
||||||
|
"fuel_level_percent": 90,
|
||||||
|
"cleanliness_ok": True,
|
||||||
|
"damage_reported": False,
|
||||||
|
"technical_warning": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert checkout.status_code == 200
|
||||||
|
assert checkout.json()["booking_status"] == "blocked"
|
||||||
|
updated_vehicle = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
|
||||||
|
assert updated_vehicle["odometer_km"] == vehicle["odometer_km"]
|
||||||
|
issues = ops_client.get(
|
||||||
|
"/api/v1/data-quality/issues",
|
||||||
|
params={"status": "open", "rule_type": "odometer_regression"},
|
||||||
|
).json()
|
||||||
|
issue = next(item for item in issues if item["entity_ref"] == vehicle["public_ref"])
|
||||||
|
assert issue["evidence"]["source_type"] == "checkout"
|
||||||
|
assert issue["evidence"]["correctable_booking_refs"] == []
|
||||||
|
detail = ops_client.get(f"/api/v1/data-quality/issues/{issue['public_ref']}").json()
|
||||||
|
assert any(snapshot["entity_type"] == "inspection" for snapshot in detail["related_snapshots"])
|
||||||
|
invalid_correction = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||||
|
json={
|
||||||
|
"decision": "correct_reading",
|
||||||
|
"booking_ref": booking["public_ref"],
|
||||||
|
"corrected_odometer_km": vehicle["odometer_km"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert invalid_correction.status_code == 422
|
||||||
|
retained = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{issue['public_ref']}/resolve-odometer-regression",
|
||||||
|
json={"decision": "retain_canonical"},
|
||||||
|
)
|
||||||
|
assert retained.status_code == 200
|
||||||
|
assert retained.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_booking_detail(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["status"] == "active"
|
||||||
|
assert body["vehicle_ref"] == "MO-024"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_booking_404_for_unknown_ref(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/bookings/BK-UNKNOWN")
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_metrics_are_persisted_counts(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
metrics = body["metrics"]
|
||||||
|
total = (
|
||||||
|
metrics["available"]
|
||||||
|
+ metrics["rented"]
|
||||||
|
+ metrics["cleaning"]
|
||||||
|
+ metrics["maintenance"]
|
||||||
|
+ metrics["blocked"]
|
||||||
|
)
|
||||||
|
assert total == 50
|
||||||
|
assert metrics["open_quality_issues"] >= 1
|
||||||
|
assert metrics["pending_or_failed_workflows"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_metrics_change_when_persisted_fleet_data_changes(ops_client):
|
||||||
|
before = ops_client.get("/api/v1/dashboard").json()["metrics"]
|
||||||
|
db = SessionLocal()
|
||||||
|
probe = Vehicle(
|
||||||
|
public_ref="MO-METRIC-PROBE",
|
||||||
|
make="Synthetic",
|
||||||
|
model="Metric probe",
|
||||||
|
model_year=2026,
|
||||||
|
registration_number="TEST-METRIC-PROBE",
|
||||||
|
location="Test fixture",
|
||||||
|
operational_status="blocked",
|
||||||
|
odometer_km=0,
|
||||||
|
next_service_km=1,
|
||||||
|
active=True,
|
||||||
|
version=1,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.add(probe)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
after_response = ops_client.get("/api/v1/dashboard")
|
||||||
|
assert after_response.status_code == 200
|
||||||
|
after = after_response.json()["metrics"]
|
||||||
|
assert after["blocked"] == before["blocked"] + 1
|
||||||
|
fleet_statuses = ("available", "rented", "cleaning", "maintenance", "blocked")
|
||||||
|
assert (
|
||||||
|
sum(after[status] for status in fleet_statuses)
|
||||||
|
== sum(before[status] for status in fleet_statuses) + 1
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
persisted_probe = db.scalar(select(Vehicle).where(Vehicle.public_ref == "MO-METRIC-PROBE"))
|
||||||
|
if persisted_probe is not None:
|
||||||
|
db.delete(persisted_probe)
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_attention_items_link_to_records(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["attention_items"]) > 0
|
||||||
|
for item in body["attention_items"]:
|
||||||
|
assert item["link_ref"]
|
||||||
|
assert item["severity"] in ("low", "medium", "high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_attention_items_expose_localizable_signals_not_raw_text(ops_client):
|
||||||
|
"""The dashboard subtext used to be raw, untranslated evidence text (and for most
|
||||||
|
seeded issues, the meaningless placeholder 'Synthetic deterministic seed issue').
|
||||||
|
The API must never emit prose here -- only stable signal codes + params, exactly
|
||||||
|
like the data-quality issue detail page, for the frontend to localize."""
|
||||||
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["attention_items"]) > 0
|
||||||
|
for item in body["attention_items"]:
|
||||||
|
assert "detail" not in item
|
||||||
|
assert len(item["evidence_signals"]) > 0
|
||||||
|
for signal in item["evidence_signals"]:
|
||||||
|
assert signal["code"]
|
||||||
|
assert signal["code"] != "Synthetic deterministic seed issue"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_recent_automation_capped_at_five(ops_client):
|
||||||
|
response = ops_client.get("/api/v1/dashboard")
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["recent_automation"]) == 5
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from app.core.db import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("statement", "constraint_name"),
|
||||||
|
(
|
||||||
|
(
|
||||||
|
"UPDATE vehicles SET odometer_km = -1 WHERE public_ref = 'MO-001'",
|
||||||
|
"ck_vehicles_odometer",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"UPDATE bookings SET ends_at = starts_at WHERE public_ref = 'BK-DEMO-RETURN'",
|
||||||
|
"ck_bookings_time_window",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"UPDATE data_quality_issues SET status = 'invented' "
|
||||||
|
"WHERE public_ref = 'DQ-DEMO-DUPLICATE'",
|
||||||
|
"ck_data_quality_status",
|
||||||
|
),
|
||||||
|
("UPDATE outbox_events SET attempts = -1", "ck_outbox_attempts"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_database_rejects_invalid_domain_state(statement: str, constraint_name: str) -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
with pytest.raises(IntegrityError) as caught:
|
||||||
|
db.execute(text(statement))
|
||||||
|
db.commit()
|
||||||
|
db.rollback()
|
||||||
|
assert constraint_name in str(caught.value)
|
||||||
Loaded 100 of 317 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user