Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a24098c583 | ||
|
|
95c91797fa | ||
|
|
ec02aca0fd | ||
|
|
acd8b82b09 | ||
|
|
9e4fca5708 | ||
|
|
0045778dbb | ||
|
|
24dcb3494c | ||
|
|
a830e8a2d0 | ||
|
|
ca66083c8b | ||
|
|
ae39a8947f | ||
|
|
a9f48d6880 | ||
|
|
6859249570 | ||
|
|
1ca70187a2 | ||
|
|
26819354ee | ||
|
|
9dfbd7c4bf | ||
|
|
809ba0ddcc | ||
|
|
c7492bf6ad | ||
|
|
82a933f6cd | ||
|
|
be33b46228 | ||
|
|
b44915ff35 | ||
|
|
eecfcb4b79 | ||
|
|
cb7edb0b84 | ||
|
|
efab8d816f | ||
|
|
cfffb1ce54 | ||
|
|
29325b6c27 | ||
|
|
6365586e82 | ||
|
|
e1b700b10e | ||
|
|
b00d33af11 | ||
|
|
90cc3cf378 | ||
|
|
0935901f11 | ||
|
|
f0f1be83ae | ||
|
|
689e499634 | ||
|
|
c3f1cfc699 | ||
|
|
509cb95110 | ||
|
|
5dda5742e4 | ||
|
|
152d847a26 | ||
|
|
f715085f65 | ||
|
|
5d7a5e7359 | ||
|
|
fe06ff75a1 | ||
|
|
8030753dbc | ||
|
|
686795a452 | ||
|
|
2ee8b2d82b | ||
|
|
58fb515337 | ||
|
|
218599af7d | ||
|
|
15bdbe40ac | ||
|
|
4a3c3bd0a9 | ||
|
|
3f13912739 | ||
|
|
b2e1ae7f17 | ||
|
|
de151914b6 | ||
|
|
e577c16db5 | ||
|
|
c194c18ca9 | ||
|
|
948d5eb6a6 | ||
|
|
3cd9ddfa66 | ||
|
|
0bfcf71ff7 | ||
|
|
4cdf667dc1 | ||
|
|
0ef4a6fa98 | ||
|
|
2648cef8e3 | ||
|
|
aacf0e04bc |
@@ -0,0 +1,21 @@
|
||||
# Backend image build context is the repository root (see compose.yaml); keep it small.
|
||||
.git
|
||||
.gitea
|
||||
.state
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
**/__pycache__
|
||||
**/.venv
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/playwright-report
|
||||
**/test-results
|
||||
artifacts
|
||||
docs
|
||||
deploy
|
||||
n8n
|
||||
frontend
|
||||
*.tgz
|
||||
*.tar.gz
|
||||
.env
|
||||
@@ -2,24 +2,75 @@ COMPOSE_PROJECT_NAME=mobilityops
|
||||
MOBILITYOPS_ENV=development
|
||||
MOBILITYOPS_DEMO_MODE=true
|
||||
MOBILITYOPS_PUBLIC_URL=http://localhost:1228
|
||||
MOBILITYOPS_API_URL=http://localhost:8128
|
||||
# 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. Keep false for LAN/plain-HTTP deployments (including the
|
||||
# current Unraid review environment); set true only once MobilityOps is served over HTTPS,
|
||||
# otherwise browsers will silently drop the cookie and no one can log in.
|
||||
# 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.itworx.tech/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
|
||||
|
||||
# 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
|
||||
@@ -44,6 +95,9 @@ 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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
name: MobilityOps acceptance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Secret scan
|
||||
uses: trufflesecurity/trufflehog@b9dd330365132cd2d01dd5dc8a857a056a2544e1 # v3.79.0
|
||||
with:
|
||||
path: ./
|
||||
extra_args: --only-verified
|
||||
- name: Backend tests in isolated PostgreSQL stack
|
||||
run: sh scripts/run-isolated-tests.sh
|
||||
- name: Backend static checks
|
||||
run: |
|
||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
|
||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app
|
||||
- name: Contract drift gate
|
||||
run: |
|
||||
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm \
|
||||
-v "$PWD:/repo:ro" api python /repo/scripts/check-contracts.py
|
||||
python scripts/check-source-budgets.py
|
||||
- name: Build production API image for vulnerability scan
|
||||
run: |
|
||||
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" \
|
||||
--tag mobilityops-api-ci --file backend/Dockerfile .
|
||||
- name: Production API image vulnerability scan (HIGH/CRITICAL)
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-api-ci
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
- name: Build production web image for vulnerability scan
|
||||
run: |
|
||||
docker build --build-arg VCS_REF="$GITHUB_SHA" \
|
||||
--tag mobilityops-web-ci frontend
|
||||
- name: Production web image vulnerability scan (HIGH/CRITICAL)
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-web-ci
|
||||
format: table
|
||||
severity: HIGH,CRITICAL
|
||||
exit-code: "1"
|
||||
ignore-unfixed: true
|
||||
- name: Remove CI stack
|
||||
if: always()
|
||||
run: docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install locked dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci --no-audit --no-fund
|
||||
- name: Lint (tsc + ESLint with react-hooks and jsx-a11y)
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
- name: Typecheck and production build
|
||||
working-directory: frontend
|
||||
run: npm run build && npm run budget
|
||||
- name: Dependency audit
|
||||
working-directory: frontend
|
||||
run: npm audit --audit-level=high
|
||||
|
||||
e2e:
|
||||
# The five-minute Playwright demo is part of the definition of done
|
||||
# (docs/14-testing-and-acceptance.md); run it against the real Compose stack.
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Start the demo stack
|
||||
run: |
|
||||
cp .env.example .env
|
||||
docker compose -p mobilityops-e2e up --build -d db api web
|
||||
for _attempt in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:1228/health/ready >/dev/null 2>&1; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
curl -fsS http://localhost:1228/health/ready
|
||||
docker compose -p mobilityops-e2e exec -T api python -m app.cli seed --reset
|
||||
- name: Install Playwright
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium firefox
|
||||
- name: Run browser acceptance suite
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
|
||||
run: npx playwright test
|
||||
- name: Run non-destructive Chromium and Firefox smoke suite
|
||||
working-directory: frontend
|
||||
env:
|
||||
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
|
||||
run: npx playwright test --config=playwright.live.config.ts
|
||||
- name: Upload Playwright report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
frontend/playwright-report
|
||||
frontend/playwright-live-report
|
||||
- name: Stack logs on failure
|
||||
if: failure()
|
||||
run: docker compose -p mobilityops-e2e logs --tail=200 api web
|
||||
- name: Remove e2e stack
|
||||
if: always()
|
||||
run: docker compose -p mobilityops-e2e down -v --remove-orphans
|
||||
@@ -0,0 +1,42 @@
|
||||
name: MobilityOps release evidence
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
|
||||
jobs:
|
||||
release-evidence:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Build commit-labelled release images
|
||||
run: |
|
||||
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-api-release --file backend/Dockerfile .
|
||||
docker build --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-web-release frontend
|
||||
- name: Generate API CycloneDX SBOM
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-api-release
|
||||
format: cyclonedx
|
||||
output: mobilityops-api-sbom.cdx.json
|
||||
- name: Generate web CycloneDX SBOM
|
||||
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
|
||||
with:
|
||||
scan-type: image
|
||||
image-ref: mobilityops-web-release
|
||||
format: cyclonedx
|
||||
output: mobilityops-web-sbom.cdx.json
|
||||
- name: Record immutable image metadata
|
||||
run: |
|
||||
docker image inspect mobilityops-api-release > mobilityops-api-image.json
|
||||
docker image inspect mobilityops-web-release > mobilityops-web-image.json
|
||||
sha256sum mobilityops-*-sbom.cdx.json mobilityops-*-image.json > SHA256SUMS
|
||||
- name: Upload release evidence
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: mobilityops-${{ github.ref_name }}-evidence
|
||||
path: |
|
||||
mobilityops-*-sbom.cdx.json
|
||||
mobilityops-*-image.json
|
||||
SHA256SUMS
|
||||
@@ -8,6 +8,7 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
playwright-live-report/
|
||||
test-results/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
@@ -16,3 +17,9 @@ test-results/
|
||||
*.tsbuildinfo
|
||||
*.zip
|
||||
*.tar.gz
|
||||
|
||||
# Local Claude/Codex per-user settings and scratch archives
|
||||
.claude/settings.local.json
|
||||
*.tgz
|
||||
*.dump
|
||||
backups/
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes are documented here. The project follows semantic release tags for
|
||||
the deployable PoC; detailed validation evidence remains in `PROJECT_STATE.md`.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Added contract-drift, accessibility, Firefox smoke and frontend asset-budget gates.
|
||||
- Added immutable commit-labelled deployment with automatic application rollback.
|
||||
- Added real PostgreSQL restore drills and routed Alertmanager notifications.
|
||||
- Added RAGcore generation circuit breaking and retrieval telemetry.
|
||||
- Added scheduled dependency maintenance, dual-image vulnerability scans and release SBOMs.
|
||||
|
||||
## [1.0.0-poc] - 2026-08-21
|
||||
|
||||
- Completed the locked Fleet Ops proof of concept: operational core, transactional returns,
|
||||
data quality, n8n orchestration, grounded RAGcore knowledge, read-only MCP integration,
|
||||
privacy governance, observability, backup/recovery and full browser acceptance.
|
||||
@@ -1,25 +1,147 @@
|
||||
# File index
|
||||
|
||||
Tracked source, contract, documentation and configuration files. Release evidence and
|
||||
screenshots live under `artifacts/<release>/` and are omitted here for brevity.
|
||||
Regenerate with `git ls-files` when the tree changes.
|
||||
|
||||
- `.env.example`
|
||||
- `.gitattributes`
|
||||
- `.gitea/workflows/ci.yml`
|
||||
- `.gitignore`
|
||||
- `AGENTS.md`
|
||||
- `CLAUDE.md`
|
||||
- `FILE_INDEX.md`
|
||||
- `MASTER_BUILD_PROMPT.md`
|
||||
- `Makefile`
|
||||
- `PROJECT_STATE.md`
|
||||
- `README.md`
|
||||
- `START_HERE.md`
|
||||
- `backend/Dockerfile`
|
||||
- `backend/alembic.ini`
|
||||
- `backend/alembic/env.py`
|
||||
- `backend/alembic/script.py.mako`
|
||||
- `backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py`
|
||||
- `backend/alembic/versions/799d8800e241_outbox_last_error_code.py`
|
||||
- `backend/alembic/versions/a81d0ce9f662_oidc_identity.py`
|
||||
- `backend/alembic/versions/b7c7b536df85_operational_user_credentials.py`
|
||||
- `backend/alembic/versions/b913a72e8c14_customer_privacy_state.py`
|
||||
- `backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py`
|
||||
- `backend/alembic/versions/c9498525abb5_initial_schema.py`
|
||||
- `backend/alembic/versions/d1f83bc64170_revoked_sessions.py`
|
||||
- `backend/alembic/versions/e7b08389f47f_idempotency_records.py`
|
||||
- `backend/alembic/versions/f43d829ab610_quality_work_queue.py`
|
||||
- `backend/app/__init__.py`
|
||||
- `backend/app/api/__init__.py`
|
||||
- `backend/app/api/deps.py`
|
||||
- `backend/app/api/routers/__init__.py`
|
||||
- `backend/app/api/routers/audit.py`
|
||||
- `backend/app/api/routers/auth.py`
|
||||
- `backend/app/api/routers/bookings.py`
|
||||
- `backend/app/api/routers/customers.py`
|
||||
- `backend/app/api/routers/dashboard.py`
|
||||
- `backend/app/api/routers/data_quality.py`
|
||||
- `backend/app/api/routers/demo.py`
|
||||
- `backend/app/api/routers/integration_status.py`
|
||||
- `backend/app/api/routers/integrations.py`
|
||||
- `backend/app/api/routers/knowledge.py`
|
||||
- `backend/app/api/routers/mcp_integrations.py`
|
||||
- `backend/app/api/routers/observability.py`
|
||||
- `backend/app/api/routers/privacy.py`
|
||||
- `backend/app/api/routers/search.py`
|
||||
- `backend/app/api/routers/users.py`
|
||||
- `backend/app/api/routers/vehicles.py`
|
||||
- `backend/app/api/routers/workflows.py`
|
||||
- `backend/app/cli.py`
|
||||
- `backend/app/core/__init__.py`
|
||||
- `backend/app/core/config.py`
|
||||
- `backend/app/core/db.py`
|
||||
- `backend/app/core/errors.py`
|
||||
- `backend/app/core/observability.py`
|
||||
- `backend/app/core/ratelimit.py`
|
||||
- `backend/app/core/security.py`
|
||||
- `backend/app/main.py`
|
||||
- `backend/app/models/__init__.py`
|
||||
- `backend/app/models/audit.py`
|
||||
- `backend/app/models/booking.py`
|
||||
- `backend/app/models/customer.py`
|
||||
- `backend/app/models/data_quality.py`
|
||||
- `backend/app/models/idempotency.py`
|
||||
- `backend/app/models/inspection.py`
|
||||
- `backend/app/models/maintenance.py`
|
||||
- `backend/app/models/mixins.py`
|
||||
- `backend/app/models/outbox.py`
|
||||
- `backend/app/models/revoked_session.py`
|
||||
- `backend/app/models/user.py`
|
||||
- `backend/app/models/vehicle.py`
|
||||
- `backend/app/schemas.py`
|
||||
- `backend/app/seed_loader.py`
|
||||
- `backend/app/services/__init__.py`
|
||||
- `backend/app/services/audit.py`
|
||||
- `backend/app/services/data_quality.py`
|
||||
- `backend/app/services/demo_manifest.py`
|
||||
- `backend/app/services/dispatcher.py`
|
||||
- `backend/app/services/integration_status.py`
|
||||
- `backend/app/services/knowledge/__init__.py`
|
||||
- `backend/app/services/knowledge/demo.py`
|
||||
- `backend/app/services/knowledge/procedures.py`
|
||||
- `backend/app/services/knowledge/ragcore.py`
|
||||
- `backend/app/services/operations.py`
|
||||
- `backend/app/services/returns.py`
|
||||
- `backend/app/services/sessions.py`
|
||||
- `backend/app/services/vehicle_status.py`
|
||||
- `backend/entrypoint.sh`
|
||||
- `backend/pyproject.toml`
|
||||
- `backend/requirements.lock`
|
||||
- `backend/scripts/generate_openapi.py`
|
||||
- `backend/tests/conftest.py`
|
||||
- `backend/tests/test_audit.py`
|
||||
- `backend/tests/test_auth.py`
|
||||
- `backend/tests/test_bookings.py`
|
||||
- `backend/tests/test_dashboard.py`
|
||||
- `backend/tests/test_data_quality.py`
|
||||
- `backend/tests/test_database_constraints.py`
|
||||
- `backend/tests/test_demo_manifest.py`
|
||||
- `backend/tests/test_dispatcher.py`
|
||||
- `backend/tests/test_hardening.py`
|
||||
- `backend/tests/test_health.py`
|
||||
- `backend/tests/test_integration_status.py`
|
||||
- `backend/tests/test_integrations.py`
|
||||
- `backend/tests/test_knowledge.py`
|
||||
- `backend/tests/test_mcp_integrations.py`
|
||||
- `backend/tests/test_migrations.py`
|
||||
- `backend/tests/test_observability.py`
|
||||
- `backend/tests/test_operational_auth.py`
|
||||
- `backend/tests/test_privacy.py`
|
||||
- `backend/tests/test_return.py`
|
||||
- `backend/tests/test_search.py`
|
||||
- `backend/tests/test_seed.py`
|
||||
- `backend/tests/test_users.py`
|
||||
- `backend/tests/test_vehicle_status.py`
|
||||
- `backend/tests/test_vehicles.py`
|
||||
- `backend/tests/test_workflows.py`
|
||||
- `compose.observability.yaml`
|
||||
- `compose.test.yaml`
|
||||
- `compose.unraid.yaml`
|
||||
- `compose.yaml`
|
||||
- `contracts/events.schema.json`
|
||||
- `contracts/mcp-tools.json`
|
||||
- `contracts/openapi.yaml`
|
||||
- `contracts/ragcore-contract-assumptions.md`
|
||||
- `deploy/observability/alerts.yml`
|
||||
- `deploy/observability/grafana/dashboards/mobilityops-overview.json`
|
||||
- `deploy/observability/grafana/provisioning/dashboards/mobilityops.yml`
|
||||
- `deploy/observability/grafana/provisioning/datasources/prometheus.yml`
|
||||
- `deploy/observability/prometheus.yml`
|
||||
- `deploy/unraid/README.md`
|
||||
- `deploy/unraid/backup-postgres.sh`
|
||||
- `deploy/unraid/configure-env.sh`
|
||||
- `deploy/unraid/prune-postgres-backups.sh`
|
||||
- `deploy/unraid/restore-postgres.sh`
|
||||
- `deploy/unraid/scheduled-backup.sh`
|
||||
- `deploy/unraid/setup-existing-n8n.sh`
|
||||
- `deploy/unraid/setup-n8n.sh`
|
||||
- `deploy/unraid/setup-scheduled-scan.sh`
|
||||
- `deploy/unraid/verify-postgres-backups.sh`
|
||||
- `docs/00-product-brief.md`
|
||||
- `docs/01-scope-and-non-goals.md`
|
||||
- `docs/02-user-stories.md`
|
||||
@@ -38,32 +160,203 @@
|
||||
- `docs/15-build-plan.md`
|
||||
- `docs/16-portfolio-case-study.md`
|
||||
- `docs/17-runbook.md`
|
||||
- `docs/18-privacy-governance.md`
|
||||
- `docs/19-visual-product-roadmap.md`
|
||||
- `docs/deferred.md`
|
||||
- `docs/demo-release/current-demo-gap-audit.md`
|
||||
- `docs/demo-release/demo-concept.md`
|
||||
- `docs/demo-release/demo-data.md`
|
||||
- `docs/demo-release/demo-guide.md`
|
||||
- `docs/demo-release/demo-runbook.md`
|
||||
- `docs/demo-release/demo-scenarios.md`
|
||||
- `docs/design/current-ux-audit.md`
|
||||
- `docs/design/design-directions.md`
|
||||
- `docs/design/design-system.md`
|
||||
- `docs/design/implementation-validation.md`
|
||||
- `docs/design/stitch-manifest.md`
|
||||
- `docs/final-integrations/ai-operations-brief-runbook.md`
|
||||
- `docs/final-integrations/current-state-audit.md`
|
||||
- `docs/final-product-polish/audit.md`
|
||||
- `docs/fleet-ops-correction/current-gap-audit.md`
|
||||
- `docs/fleet-ops-correction/i18n-inventory.md`
|
||||
- `docs/fleet-ops-correction/vehicle-status-decision-table.md`
|
||||
- `docs/fleet-ops-final-localization/audit.md`
|
||||
- `docs/functional-completion/current-functional-audit.md`
|
||||
- `docs/functional-completion/server-baseline.md`
|
||||
- `docs/live-ai-integration/n8n-current-state.md`
|
||||
- `frontend/Dockerfile`
|
||||
- `frontend/e2e/_capture-demo-screenshots.spec.ts`
|
||||
- `frontend/e2e/_capture-recruiter-screenshots.spec.ts`
|
||||
- `frontend/e2e/_capture-screenshots.spec.ts`
|
||||
- `frontend/e2e/clickable-rows.spec.ts`
|
||||
- `frontend/e2e/demo-accessibility.spec.ts`
|
||||
- `frontend/e2e/demo-entry.spec.ts`
|
||||
- `frontend/e2e/demo-guide.spec.ts`
|
||||
- `frontend/e2e/demo-legibility.spec.ts`
|
||||
- `frontend/e2e/demo.spec.ts`
|
||||
- `frontend/e2e/error-messages.spec.ts`
|
||||
- `frontend/e2e/fleet-ops-correction.spec.ts`
|
||||
- `frontend/e2e/greeting-live.spec.ts`
|
||||
- `frontend/e2e/greeting.spec.ts`
|
||||
- `frontend/e2e/guided-demo-full.spec.ts`
|
||||
- `frontend/e2e/i18n-coverage.spec.ts`
|
||||
- `frontend/e2e/interactive-elements.spec.ts`
|
||||
- `frontend/e2e/operational-workflows.spec.ts`
|
||||
- `frontend/e2e/privacy.spec.ts`
|
||||
- `frontend/e2e/recruiter-polish.spec.ts`
|
||||
- `frontend/e2e/responsive-i18n.spec.ts`
|
||||
- `frontend/e2e/roadmap-regression.spec.ts`
|
||||
- `frontend/e2e/ui-redesign.spec.ts`
|
||||
- `frontend/index.html`
|
||||
- `frontend/nginx.conf`
|
||||
- `frontend/package-lock.json`
|
||||
- `frontend/package.json`
|
||||
- `frontend/playwright.config.ts`
|
||||
- `frontend/public/favicon.svg`
|
||||
- `frontend/public/og-fleet-ops.svg`
|
||||
- `frontend/src/App.tsx`
|
||||
- `frontend/src/api/apiError.ts`
|
||||
- `frontend/src/api/client.ts`
|
||||
- `frontend/src/api/errorMessages.ts`
|
||||
- `frontend/src/api/types.ts`
|
||||
- `frontend/src/components/Badge.tsx`
|
||||
- `frontend/src/components/CheckoutForm.tsx`
|
||||
- `frontend/src/components/DemoBadge.tsx`
|
||||
- `frontend/src/components/DemoGuide.tsx`
|
||||
- `frontend/src/components/Icons.tsx`
|
||||
- `frontend/src/components/LanguageSwitcher.tsx`
|
||||
- `frontend/src/components/Layout.tsx`
|
||||
- `frontend/src/components/PageChrome.tsx`
|
||||
- `frontend/src/components/Pagination.tsx`
|
||||
- `frontend/src/components/RequireAuth.tsx`
|
||||
- `frontend/src/components/ReturnForm.tsx`
|
||||
- `frontend/src/components/VehicleMaintenanceActions.tsx`
|
||||
- `frontend/src/context/AuthContext.tsx`
|
||||
- `frontend/src/context/DemoGuideContext.tsx`
|
||||
- `frontend/src/context/DemoManifestContext.tsx`
|
||||
- `frontend/src/data/demoGuideSteps.ts`
|
||||
- `frontend/src/data/evidenceSignals.ts`
|
||||
- `frontend/src/data/integrationLabels.ts`
|
||||
- `frontend/src/hooks/useViewportTier.ts`
|
||||
- `frontend/src/i18n/brusselsDateTime.ts`
|
||||
- `frontend/src/i18n/config.ts`
|
||||
- `frontend/src/i18n/format.ts`
|
||||
- `frontend/src/i18n/greeting.ts`
|
||||
- `frontend/src/i18n/locales/en-GB/accessibility.json`
|
||||
- `frontend/src/i18n/locales/en-GB/audit.json`
|
||||
- `frontend/src/i18n/locales/en-GB/auth.json`
|
||||
- `frontend/src/i18n/locales/en-GB/bookings.json`
|
||||
- `frontend/src/i18n/locales/en-GB/common.json`
|
||||
- `frontend/src/i18n/locales/en-GB/dashboard.json`
|
||||
- `frontend/src/i18n/locales/en-GB/demo.json`
|
||||
- `frontend/src/i18n/locales/en-GB/errors.json`
|
||||
- `frontend/src/i18n/locales/en-GB/fleet.json`
|
||||
- `frontend/src/i18n/locales/en-GB/integrations.json`
|
||||
- `frontend/src/i18n/locales/en-GB/knowledge.json`
|
||||
- `frontend/src/i18n/locales/en-GB/navigation.json`
|
||||
- `frontend/src/i18n/locales/en-GB/operations.json`
|
||||
- `frontend/src/i18n/locales/en-GB/privacy.json`
|
||||
- `frontend/src/i18n/locales/en-GB/quality.json`
|
||||
- `frontend/src/i18n/locales/en-GB/returns.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/accessibility.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/audit.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/auth.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/bookings.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/common.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/dashboard.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/demo.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/errors.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/fleet.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/integrations.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/knowledge.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/navigation.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/operations.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/privacy.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/quality.json`
|
||||
- `frontend/src/i18n/locales/fr-BE/returns.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/accessibility.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/audit.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/auth.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/bookings.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/common.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/dashboard.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/demo.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/errors.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/fleet.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/integrations.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/knowledge.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/navigation.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/operations.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/privacy.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/quality.json`
|
||||
- `frontend/src/i18n/locales/nl-BE/returns.json`
|
||||
- `frontend/src/i18n/useGreetingPeriod.ts`
|
||||
- `frontend/src/main.tsx`
|
||||
- `frontend/src/pages/AboutDemo.tsx`
|
||||
- `frontend/src/pages/Audit.tsx`
|
||||
- `frontend/src/pages/Automation.tsx`
|
||||
- `frontend/src/pages/BookingCreate.tsx`
|
||||
- `frontend/src/pages/BookingDetail.tsx`
|
||||
- `frontend/src/pages/Bookings.tsx`
|
||||
- `frontend/src/pages/Dashboard.tsx`
|
||||
- `frontend/src/pages/DataQuality.tsx`
|
||||
- `frontend/src/pages/DataQualityIssueDetail.tsx`
|
||||
- `frontend/src/pages/Highlights.tsx`
|
||||
- `frontend/src/pages/Knowledge.tsx`
|
||||
- `frontend/src/pages/Login.tsx`
|
||||
- `frontend/src/pages/Privacy.tsx`
|
||||
- `frontend/src/pages/Scenarios.tsx`
|
||||
- `frontend/src/pages/Users.tsx`
|
||||
- `frontend/src/pages/VehicleDetail.tsx`
|
||||
- `frontend/src/pages/Vehicles.tsx`
|
||||
- `frontend/src/product.ts`
|
||||
- `frontend/src/styles.css`
|
||||
- `frontend/src/vite-env.d.ts`
|
||||
- `frontend/tsconfig.json`
|
||||
- `frontend/vite.config.ts`
|
||||
- `knowledge/manifest.json`
|
||||
- `knowledge/procedures/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/02-vehicle-return.md`
|
||||
- `knowledge/procedures/03-damage-handling.md`
|
||||
- `knowledge/procedures/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/07-customer-documents.md`
|
||||
- `knowledge/procedures/08-privacy.md`
|
||||
- `knowledge/procedures/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/en-GB/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/en-GB/02-vehicle-return.md`
|
||||
- `knowledge/procedures/en-GB/03-damage-handling.md`
|
||||
- `knowledge/procedures/en-GB/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/en-GB/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/en-GB/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/en-GB/07-customer-documents.md`
|
||||
- `knowledge/procedures/en-GB/08-privacy.md`
|
||||
- `knowledge/procedures/en-GB/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/en-GB/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/en-GB/11-vehicle-availability.md`
|
||||
- `knowledge/procedures/fr-BE/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/fr-BE/02-vehicle-return.md`
|
||||
- `knowledge/procedures/fr-BE/03-damage-handling.md`
|
||||
- `knowledge/procedures/fr-BE/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/fr-BE/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/fr-BE/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/fr-BE/07-customer-documents.md`
|
||||
- `knowledge/procedures/fr-BE/08-privacy.md`
|
||||
- `knowledge/procedures/fr-BE/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/fr-BE/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/fr-BE/11-vehicle-availability.md`
|
||||
- `knowledge/procedures/nl-BE/01-vehicle-checkout.md`
|
||||
- `knowledge/procedures/nl-BE/02-vehicle-return.md`
|
||||
- `knowledge/procedures/nl-BE/03-damage-handling.md`
|
||||
- `knowledge/procedures/nl-BE/04-odometer-anomalies.md`
|
||||
- `knowledge/procedures/nl-BE/05-cleaning-checklist.md`
|
||||
- `knowledge/procedures/nl-BE/06-maintenance-escalation.md`
|
||||
- `knowledge/procedures/nl-BE/07-customer-documents.md`
|
||||
- `knowledge/procedures/nl-BE/08-privacy.md`
|
||||
- `knowledge/procedures/nl-BE/09-booking-conflicts.md`
|
||||
- `knowledge/procedures/nl-BE/10-roles-and-escalation.md`
|
||||
- `knowledge/procedures/nl-BE/11-vehicle-availability.md`
|
||||
- `n8n/README.md`
|
||||
- `n8n/workflows/MANIFEST.md`
|
||||
- `n8n/workflows/fleet-ops-vehicle-return.json`
|
||||
- `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||
- `n8n/workflows/check_drift.py`
|
||||
- `n8n/workflows/fleet-ops-data-quality-scan.json`
|
||||
- `n8n/workflows/fleet-ops-error-handler.json`
|
||||
- `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`
|
||||
- `n8n/workflows/fleet-ops-vehicle-return.json`
|
||||
- `n8n/workflows/merge_credential_refs.py`
|
||||
- `scripts/run-isolated-tests.sh`
|
||||
- `seed/README.md`
|
||||
- `seed/bookings.csv`
|
||||
- `seed/customers.csv`
|
||||
|
||||
@@ -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.
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: up down logs test lint seed reset n8n-setup n8n-setup-scan demo e2e
|
||||
.PHONY: up down logs test lint contracts seed reset n8n-setup n8n-setup-scan demo e2e live-smoke
|
||||
|
||||
up:
|
||||
docker compose up --build -d
|
||||
@@ -10,11 +10,17 @@ logs:
|
||||
docker compose logs -f --tail=200
|
||||
|
||||
test:
|
||||
docker compose run --rm api pytest
|
||||
sh scripts/run-isolated-tests.sh
|
||||
|
||||
lint:
|
||||
docker compose run --rm api ruff check .
|
||||
docker compose run --rm api mypy app
|
||||
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
|
||||
@@ -48,3 +54,6 @@ demo: up
|
||||
|
||||
e2e:
|
||||
cd frontend && npx playwright test
|
||||
|
||||
live-smoke:
|
||||
cd frontend && npx playwright test --config=playwright.live.config.ts
|
||||
|
||||
@@ -1,5 +1,262 @@
|
||||
# Project state
|
||||
|
||||
## M47 — final production acceptance evidence (2026-08-21)
|
||||
|
||||
- Promoted immutable M46 revision `95c91797fa2c599443d69d9c96d83a85ee0711f7`
|
||||
from checksum-verified archive
|
||||
`559b035b4f563d7580926c1193a579f3bd43ba791fcad2110d241dbc68b1126f`
|
||||
after creating production backup `mobilityops-20260821T164628Z.dump`.
|
||||
- The API and web OCI labels plus `.deploy/source-revision` all matched M46. API, web,
|
||||
PostgreSQL, backup and Alertmanager were healthy; Prometheus and Grafana were running;
|
||||
public readiness reported `ready/database up`; Alembic reported `4f2b9c8d7e61 (head)`.
|
||||
- Trivy 0.70 scanned the exact production API and web images with fixed findings enabled:
|
||||
both reported zero HIGH/CRITICAL vulnerabilities. Prometheus scraped the protected API
|
||||
target successfully with an empty error and Alertmanager exposed the active watchdog.
|
||||
- The first post-promotion Firefox pass encountered one transient module fetch error while
|
||||
every asset and Chromium request was returning HTTP 200. Direct asset verification was
|
||||
HTTP 200 with immutable caching; an immediate clean rerun passed **4/4** in Chromium and
|
||||
Firefox, including HTTPS readiness, all operator routes and a real grounded RAGcore answer.
|
||||
- Exact next action: commit/push this evidence-only milestone, deploy that exact revision
|
||||
(application bytes are unchanged from accepted M46), verify revision/readiness, tag the
|
||||
accepted PoC release and leave the repository synchronized and clean.
|
||||
|
||||
## M46 — refresh vulnerable web runtime base (2026-08-21)
|
||||
|
||||
- The production image gate found fixed HIGH/CRITICAL Alpine vulnerabilities in the
|
||||
previously pinned Nginx 1.27 runtime, after all functional production checks passed.
|
||||
- Refreshed the official runtime to Nginx 1.30.4 on Alpine 3.24.1 and pinned its immutable
|
||||
multi-platform digest `sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46`.
|
||||
Trivy 0.70 reports zero fixed HIGH/CRITICAL findings for that base.
|
||||
- Exact next action: build and scan the complete web image, commit and push M46, deploy
|
||||
the exact revision, then repeat production image and browser acceptance gates.
|
||||
|
||||
## M45 — authenticate production metrics scraping (2026-08-21)
|
||||
|
||||
- Pre-deployment inspection confirmed production protects `/metrics` with a non-empty
|
||||
bearer token. Prometheus now renders that token into its private scrape authorization
|
||||
config at container start instead of silently receiving HTTP 401.
|
||||
- Validation: the real pinned Prometheus and Alertmanager images started with rendered
|
||||
configs; `promtool` accepted the configuration and all seven alert rules.
|
||||
- Exact next action: push M45, then execute the M44/M45 production release procedure.
|
||||
|
||||
## M44 — release integrity and assurance hardening (2026-08-21)
|
||||
|
||||
- Replaced mutable archive overlays with checksum-verified, commit-named release staging,
|
||||
OCI revision-labelled API/web images, health-gated promotion and automatic application
|
||||
rollback. Routine deployment no longer resets persisted demo data.
|
||||
- Scheduled backups now execute a weekly real restore into a disposable database and gate
|
||||
health on both backup and restore-drill freshness. The isolated drill passed at Alembic
|
||||
`4f2b9c8d7e61` with restored counts `2 users / 50 vehicles / 254 bookings / 1 audit event`.
|
||||
- Added Alertmanager routing with a continuous watchdog and an authenticated fifth n8n
|
||||
workflow targeting the existing watched M365 owner mailbox. Added weekly Renovate,
|
||||
API/web image vulnerability scans, tag-triggered CycloneDX SBOM evidence and immutable
|
||||
image metadata.
|
||||
- Contract drift is now executable for OpenAPI, events, MCP endpoints and all five n8n
|
||||
workflows. RAGcore generation failures open a bounded circuit breaker so grounded search
|
||||
fallback avoids repeated five-second delays; provider-stage outcomes and retrieval scores
|
||||
are measurable.
|
||||
- Added Axe accessibility, platform-independent visual regression, non-destructive
|
||||
Chromium/Firefox canaries, frontend asset budgets and source-growth budgets. The checks
|
||||
found and fixed two real WCAG contrast defects.
|
||||
- Validation: backend **271/271**, Playwright **155/155** in 4.6 minutes, live-safe canary
|
||||
**4/4**, focused knowledge/observability **42/42**, frontend lint/build/audit/budgets,
|
||||
contract gate, Compose rendering, Prometheus/Alertmanager validation and shell parsing
|
||||
passed. The user approved the repository security policy before it was written.
|
||||
- Exact next action: commit and push M44, create a production backup, publish/test the alert
|
||||
receiver, deploy the exact archive, run the non-destructive production canary and record
|
||||
M45 live evidence.
|
||||
|
||||
## M43 — publish and redeploy review remediation (2026-08-21)
|
||||
|
||||
- Published M41 hardening commit `24dcb3494c522fadf536fa9d6826450227aeff4e`
|
||||
and M42 RAGcore calibration commit `0045778dbbd2255f8ed35e7be22978a4d4497341`
|
||||
to `origin/master`; fetch/revision checks matched before and after deployment.
|
||||
- Created and verified the pre-deployment PostgreSQL backup
|
||||
`backups/postgres/mobilityops-20260821T150738Z.dump`. The M41 deployment archive
|
||||
verified at SHA-256 `5b1868b5f2d8800a3a8678bea8c55f58d35605c54234eb26b022560bbca8ebfb`;
|
||||
the M42 hotfix archive verified at
|
||||
`dbbaf19678aba15dc52e10186c9c784ca748a236819a5c543d597a92c87792a9`.
|
||||
- Production now records M42's exact source revision, uses
|
||||
`https://fleetops.itworx.tech` with Secure session cookies, returns HTTPS 200 with HSTS
|
||||
and redirects HTTP to HTTPS. API, web, PostgreSQL and backup are healthy; Alembic is
|
||||
`4f2b9c8d7e61 (head)`; Grafana and Prometheus stayed running. The API runtime contains
|
||||
neither tests nor pytest and the post-deploy API/web critical-error scan is clean.
|
||||
- Exported all 30 central n8n workflows before import to
|
||||
`.deploy/n8n-backups/pre-m41-20260821T171139Z.json` (SHA-256
|
||||
`9622a1345607624f4254f87ad219fc9e93de98981b93f029f946cfb3e97bd746`).
|
||||
Re-imported, published and restarted the four Fleet Ops workflows; n8n is healthy,
|
||||
all four are `active:true`, and every Fleet Ops callback is HTTPS.
|
||||
- The full live browser run recorded **92 passed**, **8 failed** and **53 skipped** because
|
||||
the acceptance suite requests repeated resets while production intentionally enforces
|
||||
a 60-second reset cooldown (confirmed 429 plus `Retry-After`). Local acceptance remains
|
||||
**153/153**. A production-compatible isolated five-minute run then exposed the RRF
|
||||
threshold issue fixed in M42; its final rerun passed live in **13.5 s**.
|
||||
- Final hand-off: a final audited demo reset returned 200 with all five scenarios ready
|
||||
and canonical synthetic counts (2 users, 180 customers, 50 vehicles, 254 bookings,
|
||||
75 inspections, 40 maintenance records, 33 quality issues and 20 workflow runs).
|
||||
- Exact next action: no implementation or deployment work remains; monitor the next
|
||||
scheduled n8n heartbeats and normal production telemetry.
|
||||
|
||||
## M42 — calibrate grounded RAGcore fallback (2026-08-21)
|
||||
|
||||
- The first live five-minute acceptance run exposed a real calibration error in M41's
|
||||
new fallback threshold: `/v1/answers` timed out, while `/v1/search` correctly returned
|
||||
`damage-procedure.md` at rank one with fused score `0.0163934426`. RAGcore uses
|
||||
reciprocal-rank fusion (roughly `1 / (60 + rank)`), so the normalized-looking `0.05`
|
||||
threshold could never accept a legitimate result.
|
||||
- Set the default minimum to `0.016`: this accepts the first two normal RRF ranks while
|
||||
still rejecting missing scores and the existing low-score `0.01` abuse case. Damage
|
||||
questions continue to require explicit damage evidence and out-of-domain questions
|
||||
continue to return `insufficient`; no generated answer is trusted when retrieval is
|
||||
absent or weak.
|
||||
- Added a regression using RAGcore's observed rank-one fused score. Focused knowledge
|
||||
tests **36 passed**; complete isolated PostgreSQL backend suite **270 passed**; Ruff and
|
||||
mypy are clean and `git diff --check` passes.
|
||||
- Exact next action: commit and push M42, deploy that exact archive over the healthy M41
|
||||
production deployment, then repeat the live five-minute demo and final health checks.
|
||||
|
||||
## M41 — full review remediation and hardening (2026-08-21)
|
||||
|
||||
- Closed all findings from the repository-wide review without expanding the locked PoC:
|
||||
production now refuses placeholder MCP credentials, cleartext public URLs and insecure
|
||||
session cookies; OIDC requires an explicit verified-email claim; nginx overwrites the
|
||||
forwarded client address and the API uses the proxy-appended hop for rate limiting.
|
||||
- Added bounded per-IP/per-session knowledge requests and an explicit minimum RAGcore
|
||||
retrieval score. Weak or concept-mismatched search fallback evidence is returned as
|
||||
`insufficient`, never `grounded`. MCP audit attribution now authenticates the fixed Hub
|
||||
service identity and stores the Hub-reported caller only as non-authoritative metadata.
|
||||
- Serialised data-quality scans with a PostgreSQL transaction advisory lock, added a
|
||||
partial unique index for one open issue per condition, and locked issue rows for every
|
||||
mutating resolution. Concurrent scan and concurrent-resolution regression tests pass.
|
||||
- Split the backend production/test image stages and locks: the runtime contains no test
|
||||
suite, pytest, Ruff or mypy. All container bases and CI actions are digest/SHA pinned.
|
||||
CI now builds and scans the real runtime image. The initial Debian 13 base exposed 36
|
||||
fixable HIGH findings; switching to the pinned Python 3.12 Bookworm image reduced the
|
||||
final Trivy result to **0 HIGH/CRITICAL** across OS and Python packages.
|
||||
- Moved every central n8n callback/source URL to the existing HTTPS endpoint
|
||||
`fleetops.itworx.tech`, refreshed workflow checksums, corrected stale workflow status/
|
||||
node-count documentation, fixed the return-odometer documentation and made the Unraid
|
||||
bootstrap enforce HTTPS plus Secure cookies. Makefile lint now always builds the test
|
||||
target and cannot silently inspect a stale runtime image.
|
||||
- Validation evidence: focused security/integration/data-quality suite **116 passed**;
|
||||
final isolated PostgreSQL backend suite **270 passed**; Ruff and mypy clean; frontend
|
||||
lint/build clean; npm audit **0 vulnerabilities**; production runtime contains no dev
|
||||
dependencies/tests; Trivy runtime scan **0 HIGH/CRITICAL**; full Playwright acceptance
|
||||
**153/153 passed in 5.1 minutes**; Compose test/Unraid configs and `git diff --check`
|
||||
clean. Existing public TLS returns 200 with HSTS and HTTP redirects to HTTPS.
|
||||
- Exact next action: commit and push M41, take a verified production backup, update the
|
||||
deployment's public URL/Secure-cookie settings, deploy the committed archive and
|
||||
migration `4f2b9c8d7e61`, safely republish the four HTTPS n8n definitions, then repeat
|
||||
live health, migration, security-header and browser acceptance checks.
|
||||
|
||||
## M40 — publish and redeploy M39 (2026-08-17)
|
||||
|
||||
- Published three validated commits to Gitea `master`: the backend dependency and secret
|
||||
CI gates (`6859249`, `a9f48d6`) plus the complete M39 hardening milestone (`ae39a89`).
|
||||
A fresh fetch confirmed local `HEAD` and `origin/master` both resolved to
|
||||
`ae39a8947fff1ec1b60116556ed411567b11f0d2` before deployment.
|
||||
- Local validation used freshly rebuilt API/web images: isolated PostgreSQL backend suite
|
||||
**261 passed** (one Alembic configuration deprecation warning), Ruff clean, mypy clean
|
||||
across 59 source files, frontend lint and production build clean, full and production
|
||||
npm audits at **0 vulnerabilities**, Trivy/secret scan clean, and Playwright
|
||||
**153/153 passed in 3.1 minutes**.
|
||||
- Created and verified the pre-deployment custom-format backup
|
||||
`backups/postgres/mobilityops-20260817T011857Z.dump`; checksum verification and
|
||||
`pg_restore --list` both passed. The committed source archive matched locally and on
|
||||
Unraid at SHA-256 `473293921717b7f1363689f4a152493797778ceb91f284cc21b5b68600803de6`.
|
||||
- Deployed the exact committed archive to `/mnt/user/appdata/mobilityops`, preserving
|
||||
`.env`, `.deploy`, volumes and the central n8n. API/web were rebuilt and recreated;
|
||||
PostgreSQL data remained on its named volume. Production secrets were verified as
|
||||
non-empty/non-placeholder without exposing their values. Readiness is `ready`, API,
|
||||
database, web and backup are healthy, and Alembic is `0a4c1d2e3f5b (head)`.
|
||||
- Live Playwright acceptance completed **152/153** on the first pass. The sole failure was
|
||||
the expected honest degradation path: one RAGcore request exhausted both bounded 5 s
|
||||
calls (`/v1/answers` then `/v1/search`) and rendered “knowledge service unavailable”
|
||||
while returning HTTP 200 and leaving operations unaffected. The complete guided-demo
|
||||
test passed on immediate focused rerun in **11.3 s**. The final API/web critical-log
|
||||
scan is clean.
|
||||
- Final hand-off state: all five synthetic scenarios are ready; reset cooldown is restored
|
||||
to 60 seconds; RAGcore and MCP Hub report operational. Central n8n is healthy and all
|
||||
four Fleet Ops workflows are active. Its aggregate state is temporarily `degraded`
|
||||
solely because the hourly quality-scan heartbeat is stale; there are zero unexpected
|
||||
delivery failures and the one failed outbox row is the labelled demo scenario.
|
||||
- Exact next action: no code or deployment work remains. Confirm the next scheduled
|
||||
quality-scan heartbeat clears the temporary stale n8n state; investigate the central
|
||||
workflow scheduler only if it does not.
|
||||
|
||||
## M39 — Hardening review (2026-08-16)
|
||||
|
||||
Full-repository audit (backend, frontend, infra, docs) followed by targeted fixes. Every
|
||||
change is covered by the existing gates plus new regression tests; nothing in the locked
|
||||
scope changed.
|
||||
|
||||
- **Security**: `get_settings()` refuses to boot with `MOBILITYOPS_ENV=production` while
|
||||
`APP_SECRET`/`MOBILITYOPS_CALLBACK_TOKEN` (or the MCP token when registration is on)
|
||||
still hold placeholder values (`insecure_default_secrets`). `POST /api/v1/demo/reset`
|
||||
now returns 404 outside demo mode (it previously only checked `DEMO_ALLOW_RESET`).
|
||||
Failed password logins are throttled per client IP (`app/core/ratelimit.py`,
|
||||
`LOGIN_MAX_FAILURES`/`LOGIN_FAILURE_WINDOW_SECONDS`, 429 + `Retry-After`; only failures
|
||||
count, so tests are unaffected). OIDC email-based linking of an existing local account
|
||||
now requires `email_verified: true` (absent claim = unverified). n8n service tokens are
|
||||
compared with `hmac.compare_digest`; the return callback body is a bounded Pydantic model
|
||||
(`ReturnCallbackIn`, malformed `correlation_id` → 422 instead of 500).
|
||||
- **Correctness**: dashboard "today" buckets bookings by the Europe/Brussels calendar day
|
||||
instead of the UTC date; audit export accepts naive datetimes (were 500) and
|
||||
`correlation_id` is validated as UUID (was a DB error); paged booking lists for an
|
||||
unknown `vehicle_ref` keep the page shape; the data-quality scan skips anonymised
|
||||
customers (they were re-flagged as `missing_required_field` after every scan);
|
||||
`resolve_odometer_regression` locks booking→vehicle like every other flow (was the
|
||||
opposite order → deadlock risk); `merge_customers` locks both rows in a deterministic
|
||||
order, rejects already-merged customers (`CUSTOMER_ALREADY_MERGED`, localised in the
|
||||
three locales) and validates override lengths against the column sizes; a booking that
|
||||
was `blocked` at checkout can now be cancelled (it had no exit state); demo reset no
|
||||
longer wipes `revoked_sessions` (logged-out cookies were revived).
|
||||
- **Idempotency**: `idempotency_records.request_fingerprint` (migration `0a4c1d2e3f5b`);
|
||||
replaying an `Idempotency-Key` with a different body → 409 `IDEMPOTENCY_KEY_REUSED`.
|
||||
- **Observability**: unmatched paths are labelled `<unmatched>` in the HTTP metrics
|
||||
(404 probes no longer create unbounded Prometheus series). Integration status reads the
|
||||
latest heartbeat/failure per workflow with `DISTINCT ON` instead of loading every row.
|
||||
- **Frontend**: nginx hashed-asset regex never matched Vite's `name-HASH.js` output, so
|
||||
bundles were served `no-cache`; fixed (`Cache-Control: public, max-age=1y, immutable`),
|
||||
plus gzip and `server_tokens off`. Global search and the Vehicles/Bookings/Audit/DQ
|
||||
lists abort stale requests and use `replace` navigation (no history entry per
|
||||
keystroke). Bookings/Audit date filters use Brussels day boundaries via
|
||||
`brusselsDateTime.ts`. DQ "demo scenarios only" is a server-side filter (`demo_only`)
|
||||
so it spans all pages. `AbortSignal.any` fallback, `sessionStorage` parse guard,
|
||||
per-action error notices on booking detail, dead `.about-cta` guide target fixed.
|
||||
- **Build/CI**: root and frontend `.dockerignore`; backend image runs as non-root `app`;
|
||||
`VITE_API_BASE_URL` is a build arg (was a no-op runtime env); CI gains an `e2e` job that
|
||||
runs the full Playwright suite against the Compose stack; `npm audit --audit-level=high`.
|
||||
`httpx2` moved to dev extras (Starlette TestClient), unused `pytest-asyncio` removed.
|
||||
- **Tests**: `tests/test_migrations.py` upgrades an empty database through Alembic and
|
||||
asserts `compare_metadata` is empty (found and fixed a real drift: missing
|
||||
`index=True` on `Customer.anonymized_at`). `tests/test_hardening.py` covers the items
|
||||
above. `contracts/openapi.yaml` regenerated (it lacked `complete-requirements` and
|
||||
`schedule`).
|
||||
- **Docs**: `docs/18-visual-product-roadmap.md` → `docs/19-…` (duplicate number),
|
||||
`FILE_INDEX.md` regenerated from `git ls-files`, README points at `N8N_WEBHOOK_URL`
|
||||
and the definitive acceptance summary, `.gitignore` covers `.claude/settings.local.json`,
|
||||
`*.tgz`, `*.dump`, `backups/`.
|
||||
- Gates run from this checkout: 261 backend tests, Ruff, mypy (59 files), frontend
|
||||
`tsc -b && vite build`, and the complete Playwright suite (153 tests) against a local
|
||||
nginx + uvicorn + PostgreSQL 16 stack — all green.
|
||||
- **Lint**: ESLint 9 flat config (`frontend/eslint.config.js`) with typescript-eslint,
|
||||
`react-hooks` (`rules-of-hooks` + `exhaustive-deps` as errors; the React-Compiler-era
|
||||
`set-state-in-effect`/`purity`/`refs` rules are off because the app's "reset then fetch
|
||||
in an effect" pattern is deliberate) and `jsx-a11y`. `npm run lint` = `tsc -b --noEmit &&
|
||||
eslint .`; wired into `make lint` and CI. Fixed the real findings: missing `t`
|
||||
dependencies in `BookingDetail`/`Dashboard`/`DataQualityIssueDetail` loaders, missing
|
||||
`setCollapsedToChip` in `DemoGuide`, stale-object dependency in the reschedule
|
||||
pre-fill. `i18next`/`react-i18next` pinned exactly; `engines.node >= 22`.
|
||||
- README headline no longer links to the LAN-only demo (`192.168.10.150`); it gives the
|
||||
two-command local run and points to `deploy/unraid/README.md` for the hosted reference.
|
||||
- Left as-is on purpose: the bundled `n8n` service still starts with `make demo` (the
|
||||
runbook's local automation demo relies on it; production disables it via
|
||||
`compose.unraid.yaml`).
|
||||
- Exact next action: rebuild images (`docker compose build`) so the new migration
|
||||
applies on the next `up`; on Unraid confirm `.env` has a real `MOBILITYOPS_CALLBACK_TOKEN`
|
||||
and `APP_SECRET` before deploying, because production now refuses placeholders.
|
||||
|
||||
## Publication and Unraid deployment (2026-08-02)
|
||||
|
||||
- Unraid deployment is live at `http://192.168.10.150:1236` from
|
||||
@@ -2226,5 +2483,636 @@ evidence yet."
|
||||
Docker Desktop is unavailable and the configured `db` hostname cannot resolve; run the
|
||||
complete suite inside the Unraid Compose API container after deployment.
|
||||
- **Commit**: `f2cdad194ccafd0e9aa59e91ddd7dbe0c79278af` (`UX: implement visual product roadmap`).
|
||||
- **Next action**: push, fast-forward `master`, redeploy API/web to Unraid, run the
|
||||
container test suite and live responsive verification at 390/768/1440px.
|
||||
- **Deployment and final verification**: feature branch was pushed, fast-forward merged
|
||||
and pushed to `master` at `ad1182582d87f0911cc97af88924fd0dee927ac8`; Unraid rebuilt
|
||||
the API/web services from `/mnt/user/appdata/mobilityops/.deploy/source-ad11825.tar.gz`
|
||||
and recorded that revision in `.deploy/source-revision`. The isolated Compose test run
|
||||
with the explicit demo contract (`KNOWLEDGE_PROVIDER=demo`, MCP Hub registration off)
|
||||
passed **192 tests**; `ruff check .` and `mypy app` passed. The default production
|
||||
environment intentionally exposes the live RAGcore/MCP settings, so its five old
|
||||
demo-only assertions are not a production regression.
|
||||
- **Live visual evidence**: at 1440px Vehicles renders 25 bounded rows and “Pagina 1 van
|
||||
2”; at 390px the return workflow has no horizontal overflow and the dashboard attention
|
||||
queue is capped at six with a direct continuation link. A cached pre-roadmap CSS asset
|
||||
was found in one browser session still hiding `.page-actions`; the final live asset
|
||||
hash explicitly keeps the mobile status/action row visible.
|
||||
|
||||
## Booking list scale-up follow-up (2026-08-10)
|
||||
|
||||
- **Improvement found and completed**: Bookings was the last operations list that fetched
|
||||
and searched every booking in the browser. It now uses the same bounded server-side
|
||||
query contract as Vehicles, Data Quality and Audit: optional `query`, `page` and
|
||||
`page_size <= 25`, with a total and total-page count. Unpaged API calls retain their
|
||||
original list response for existing consumers.
|
||||
- **Usability**: booking search, status and page are URL-backed (`q`, `status`, `page`),
|
||||
so an operator can reload, share or navigate back to an exact queue view.
|
||||
- **Evidence**: local web build, ruff and mypy passed. On Unraid the isolated demo-contract
|
||||
suite passed **193 tests**; ruff and mypy passed. Live at 1440px, 254 bookings render as
|
||||
25 rows over 11 pages, and `?q=BK-DEMO&page=1` returns four records with the filter
|
||||
visibly retained. The synthetic demo seed was reset after the test run (2 users, 180
|
||||
customers, 50 vehicles, 254 bookings, 33 quality issues and 20 workflow runs).
|
||||
- **Deployment**: committed and pushed as `0ef4a6fa98e6e4b4f73b0bcaa2581ecc82fbbc51`
|
||||
(`UX: paginate booking operations`), deployed to Unraid; `.deploy/source-revision`
|
||||
matches that commit.
|
||||
|
||||
## Operational-mode foundation (2026-08-10)
|
||||
|
||||
- Added a non-demo operational mode with email/password login, scrypt password hashes,
|
||||
an explicitly configured first Operations Manager and database-backed active-user
|
||||
revalidation on every operational request. Demo mode remains the default and retains
|
||||
its deterministic reset semantics; outside demo mode, demo login/manifest/reset are
|
||||
unavailable and demo-only controls are hidden in the UI.
|
||||
- Added migration `b7c7b536df85`, production environment guidance in `.env.example` and
|
||||
`docs/17-runbook.md`, and focused authentication contract tests. Also normalized the
|
||||
return response through its declared schema so preview and committed return risks use
|
||||
identical UTC serialization.
|
||||
- Evidence: local web build, ruff and mypy clean; deployed Unraid Compose test suite
|
||||
**195 passed**. API/web/db healthy, deterministic demo seed restored. Live source
|
||||
revision: `948d5eb6a60a138dcfc539fd9e36f885200190de`.
|
||||
|
||||
## Knowledge trust and persistent integration telemetry (2026-08-10)
|
||||
|
||||
- RAGcore fallback now refuses unrelated questions, ranks multilingual domain evidence
|
||||
before answering and returns `insufficient` with no answer when no MobilityOps concept
|
||||
is present. A live damage question is grounded in `damage-procedure.md`; an unrelated
|
||||
football question is explicitly insufficient.
|
||||
- Demo reset preserves operational MCP, n8n and knowledge telemetry while CLI/test reset
|
||||
remains fully deterministic by default.
|
||||
- Evidence: deployed Unraid suite **199 passed**; ruff and mypy clean. Committed as
|
||||
`de15191`, with follow-up deterministic test corrections through `3f13912`.
|
||||
|
||||
## Operational booking lifecycle (2026-08-10)
|
||||
|
||||
- Added authenticated canonical-customer search, interval-aware vehicle availability,
|
||||
booking creation and audited cancellation. The web app now provides a localized,
|
||||
responsive creation flow and cancellation action instead of requiring direct API use.
|
||||
- Booking creation obtains a PostgreSQL row lock on the selected vehicle before checking
|
||||
overlap. The concurrent contract test proves two simultaneous requests yield exactly
|
||||
one reservation and one conflict.
|
||||
- Evidence: production web build passed; ruff and mypy clean; the modified code passed
|
||||
the full Unraid Compose suite: **203 passed, 1 warning**.
|
||||
- Exact next action: implement audited checkout/activation, maintenance capture and
|
||||
operations-manager user administration, then repeat the complete validation gate.
|
||||
|
||||
## Complete daily operations cycle (2026-08-10)
|
||||
|
||||
- Reserved bookings now have an audited checkout inspection. A safe inspection atomically
|
||||
activates the booking and marks the vehicle rented; odometer regression, dirt, damage
|
||||
or a technical warning blocks the booking and routes the vehicle to cleaning or
|
||||
maintenance without an unsafe activation.
|
||||
- Operations Managers can register persisted maintenance evidence, advance service and
|
||||
odometer values, explicitly release a vehicle only when no active rental or open
|
||||
high-severity vehicle issue remains, and create/activate/deactivate operational users.
|
||||
Self-deactivation and self-demotion are prevented. Rental employees receive 403 for
|
||||
manager actions.
|
||||
- Added localized web workflows for checkout, maintenance/release and user access
|
||||
administration. All actions use persisted API state and expose actionable errors.
|
||||
- Evidence: frontend lint and production build passed; ruff, mypy and diff check passed;
|
||||
focused Unraid contracts **19 passed** and the full suite **208 passed, 1 warning**.
|
||||
- Exact next action: harden MCP per-client authorization and evidence completeness, then
|
||||
replace inferred n8n status with explicit heartbeat/execution telemetry.
|
||||
|
||||
## MCP trust boundary and trace completeness (2026-08-10)
|
||||
|
||||
- The MCP API now validates the ITWorx Hub delegated client-id shape in addition to the
|
||||
shared service secret, supports an explicit tenant assertion and rejects cross-tenant
|
||||
calls. Readiness and project-bound client identities remain compatible with the Hub's
|
||||
documented connector contract; arbitrary/spoofed labels no longer enter the audit log.
|
||||
- Every successful tool response returns `X-Correlation-Id`, `X-Tenant-Id` and
|
||||
`Cache-Control: no-store`. Knowledge calls additionally expose available versus
|
||||
returned source counts and persist tenant, locale and source coverage in their audit
|
||||
metadata. `contracts/mcp-tools.json` is versioned to 1.2.0 with this trust contract.
|
||||
- Evidence: ruff and mypy passed; focused MCP tests **14 passed** and full Unraid suite
|
||||
**210 passed, 1 warning**.
|
||||
- Exact next action: add authenticated n8n workflow heartbeats and execution results,
|
||||
surface stale/healthy workflow state, then update generated contracts and E2E coverage.
|
||||
|
||||
## n8n execution health telemetry (2026-08-10)
|
||||
|
||||
- Added an authenticated, canonical-workflow-only, idempotent heartbeat contract. Status
|
||||
now records each workflow's last execution ID/result and classifies it as healthy,
|
||||
stale, failed or without evidence. Scheduled scan evidence expires after 2h30 and the
|
||||
daily RAGcore sync after 30h; event-driven workflows are not falsely marked stale just
|
||||
because no business event arrived.
|
||||
- Registered target-workflow failures override older success evidence until a newer
|
||||
successful execution arrives. The Automation UI renders these explicit states and
|
||||
execution IDs instead of treating any historical timestamp as permanently healthy.
|
||||
- All four versioned n8n definitions now report a successful execution heartbeat with
|
||||
bounded retries; the central error handler continues to register failed target runs.
|
||||
- Evidence: JSON validation for all four definitions, frontend lint, ruff and mypy passed;
|
||||
focused integration tests **26 passed** and full Unraid suite **212 passed, 1 warning**.
|
||||
- Live deployment: API/web deployed at `c9a8609`; all five pre-existing n8n workflows
|
||||
were exported to the recoverable appdata backup
|
||||
`backups/mobilityops-pre-heartbeat-20260810.json` before the four definitions were
|
||||
imported and published. The import initially exposed n8n CLI's unsafe name-only
|
||||
credential resolution (both Header Auth nodes resolved to the service credential);
|
||||
credential IDs were restored from the backup before republishing. A real return then
|
||||
completed through outbox → server n8n → callback → heartbeat as execution `337`;
|
||||
its first 403 delivery remained safely retryable and succeeded after the credential
|
||||
correction. Live n8n state is operational and Vehicle Return is `healthy` with its
|
||||
execution ID visible.
|
||||
- Exact next action: regenerate the checked-in OpenAPI contract, add E2E coverage for the
|
||||
new operator workflows, document credential-safe n8n upgrades and run acceptance.
|
||||
|
||||
## Contract and acceptance synchronization (2026-08-10)
|
||||
|
||||
- Replaced the obsolete hand-maintained OpenAPI baseline with a deterministic snapshot
|
||||
generated directly from the FastAPI application. The committed contract now describes
|
||||
all 53 paths and 75 schemas, including booking lifecycle, operational users, MCP trust
|
||||
headers and n8n execution telemetry.
|
||||
- Added browser acceptance coverage for booking creation/cancellation, user
|
||||
creation/deactivation and vehicle maintenance/release. Existing acceptance journeys
|
||||
now reset their own state, tolerate the configured honest knowledge provider and allow
|
||||
the provider's bounded response window instead of depending on suite order or a demo
|
||||
provider that is not active in production.
|
||||
- Added a credential-reference merge utility and a runbook procedure that preserves live
|
||||
n8n credential IDs during workflow upgrades without exporting or committing secrets.
|
||||
- Hardened logout beyond browser cookie deletion: signed sessions now carry a unique
|
||||
nonce, logout persists a token-hash denylist, expired revocations are pruned, and both
|
||||
demo and operational authentication reject retained or copied cookies server-side.
|
||||
Repeated live refresh/logout coverage passed **40/40**.
|
||||
- Removed a return-form initialization race: the return form now mounts only after the
|
||||
persisted demo manifest is ready, so operator input cannot be overwritten by a late
|
||||
manifest response. The final mobile row interaction is represented by actual link
|
||||
semantics rather than a nested interactive table row.
|
||||
- Split frontend dependency installation from source compilation in the Docker build so
|
||||
dependency layers are cached and reproducible. Upgraded the build toolchain to pinned
|
||||
Vite **8.2.1** and `@vitejs/plugin-react` **6.0.5**; both the production-only and full
|
||||
npm audits report **0 vulnerabilities**.
|
||||
- **Final acceptance evidence**: generated OpenAPI output is byte-for-byte deterministic;
|
||||
frontend TypeScript/production build, ruff, mypy and diff checks pass; Alembic reports
|
||||
`d1f83bc64170 (head)`; the complete backend suite passes **213 tests**; the complete
|
||||
Playwright suite passes **144 tests in 4.8 minutes** against the deployed production
|
||||
bundle.
|
||||
- **Live integration evidence**: a real vehicle return completed through local commit →
|
||||
outbox → the existing central n8n → callback → heartbeat. After the final browser run,
|
||||
Vehicle Return execution **376** is healthy, n8n is operational with zero pending
|
||||
events, MCP Hub is reachable and operational with a real audited
|
||||
`fleet_ops_get_operations_summary` call, and RAGcore reports available/ready.
|
||||
- **Restored hand-off state**: the synthetic reset reports all scenarios ready with 2
|
||||
users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance
|
||||
records, 33 data-quality issues and 20 workflow runs; `BK-DEMO-RETURN` is active again.
|
||||
- **Exact next action**: none for the locked PoC. All acceptance criteria are satisfied;
|
||||
subsequent work is routine production operation, monitoring and explicitly approved
|
||||
scope beyond this build.
|
||||
|
||||
## M16 — reliability boundary and truthful delivery foundation (2026-08-10)
|
||||
|
||||
- Added `compose.test.yaml` and `scripts/run-isolated-tests.sh`: backend acceptance now
|
||||
runs in a fixed, disposable `mobilityops-test` Compose project with its own PostgreSQL
|
||||
database/volume. The script cleans up on success, failure or interruption. The live
|
||||
deployment database is no longer an acceptable test target.
|
||||
- Added a Gitea Actions workflow for isolated backend tests, ruff/mypy, the locked
|
||||
frontend build and npm audit. `make test` now routes through the isolated test runner.
|
||||
- Split process liveness from database-backed readiness (`/health/live` and
|
||||
`/health/ready`) while retaining `/health` compatibility.
|
||||
- Replaced count-derived return-inspection and scan-issue references with prefixed UUID
|
||||
references, eliminating collisions between independent concurrent transactions.
|
||||
- Updated the README/runbook where they still claimed demo RAGcore, two n8n workflows or
|
||||
unsafe in-place pytest execution.
|
||||
- Evidence: Compose merge validated; ruff and mypy clean; full isolated PostgreSQL suite
|
||||
**218 passed** and the disposable database/network/volume were removed automatically.
|
||||
- Exact next action: implement honest loading states and RAG/source deduplication, then
|
||||
revalidate live Knowledge and Integration flows.
|
||||
|
||||
## M17 — grounded knowledge and integration evidence UX (2026-08-10)
|
||||
|
||||
- Replaced transient false demo/unavailable/not-configured labels with explicit loading,
|
||||
settled-unavailable and provider-aware states on Knowledge and Automation.
|
||||
- Deduplicated RAGcore citations by their human-visible identity instead of volatile
|
||||
document/version UUIDs and capped each answer at three concise, collapsible source
|
||||
cards. Re-uploaded copies can no longer dominate an answer.
|
||||
- Added answer latency and authenticated helpful/not-helpful feedback. Feedback is
|
||||
correlation-bound to the requesting user, auditable and safely updateable without
|
||||
creating duplicate audit events.
|
||||
- Explained the expected cadence of all four central n8n workflows so event-driven and
|
||||
scheduled no-event states are understandable rather than looking broken.
|
||||
- Regenerated the OpenAPI contract. Evidence: frontend production build passed; focused
|
||||
backend knowledge suite **31 passed**; targeted ruff and mypy checks passed. The E2E
|
||||
journey now verifies the three-source limit, unique source titles and persisted
|
||||
feedback.
|
||||
- Exact next action: turn the data-quality queue, booking planning, fleet overview and
|
||||
user administration into complete daily operational workspaces.
|
||||
|
||||
## M18 — daily operational workspaces (2026-08-10)
|
||||
|
||||
- Turned data quality into an owned work queue: every newly detected open issue receives
|
||||
a severity-based SLA deadline (4h high, 1d medium, 3d low), managers can filter by
|
||||
assignee/overdue state and assign or reschedule up to 25 selected issues atomically.
|
||||
Every change is row-locked, validated against an active user and independently audited.
|
||||
- Added the PostgreSQL ownership/deadline migration with indexed nullable assignment,
|
||||
`ON DELETE SET NULL`, live-data backfill and deterministic demo-reset deadlines.
|
||||
- Upgraded booking planning with operational-priority ordering, inclusive date-window,
|
||||
location and explicit sort filters plus Today/Upcoming presets. The default no longer
|
||||
leads with the furthest-future booking.
|
||||
- Upgraded the fleet register with exact location filtering, next-booking context,
|
||||
remaining service distance and explicit attention reasons (blocked, service due or
|
||||
open quality issue) instead of one unexplained warning label.
|
||||
- Completed user administration: managers can now edit names/roles, reset passwords and
|
||||
activate/deactivate accounts from the UI; existing self-demotion/deactivation guards
|
||||
and auditing remain authoritative in the API.
|
||||
- Evidence: frontend TypeScript production build passed; ruff and mypy passed; focused
|
||||
PostgreSQL suites **58 passed**; migration applied in the isolated stack; OpenAPI was
|
||||
regenerated. E2E coverage now includes bulk queue assignment and full user editing.
|
||||
- Exact next action: split the frontend bundle, harden mobile layout and operational
|
||||
backup/deployment controls, then run clean full acceptance and redeploy.
|
||||
|
||||
## M19 — performance and recoverable operations (2026-08-10)
|
||||
|
||||
- Route-level React lazy loading reduced the initial production JavaScript chunk from
|
||||
about **572 kB to 212 kB**; every operational page now ships as a separate bounded
|
||||
chunk and the previous Vite large-chunk warning is gone. Both npm audits report zero
|
||||
vulnerabilities.
|
||||
- Reflowed the five dashboard readiness metrics into a readable 3+2 mobile grid instead
|
||||
of an overflowing horizontal strip at 390 px.
|
||||
- Compose now gates dependants on database-backed `/health/ready`. The optional bundled
|
||||
n8n fallback is pinned to `n8nio/n8n:2.33.7`, matching the central server n8n version;
|
||||
Unraid still starts no second n8n instance.
|
||||
- Added guarded Unraid PostgreSQL backup/restore scripts. Backups use custom format and
|
||||
are structurally verified; restore requires an explicit confirmation, makes a safety
|
||||
backup, stops API writes, recreates only the configured database and checks Alembic.
|
||||
A disposable dump/restore drill recovered all **50 vehicles** into a second database.
|
||||
- Evidence: merged Compose configurations and shell syntax pass; frontend production
|
||||
build and audits pass; full isolated PostgreSQL suite **225 passed, 1 warning**; ruff
|
||||
and mypy are clean.
|
||||
- Exact next action: commit and deploy this milestone, run complete Playwright and visual
|
||||
acceptance against Unraid, refresh final evidence, push and verify the live hash.
|
||||
|
||||
## M20 — final production acceptance and hand-off (2026-08-10)
|
||||
|
||||
- Tightened RAGcore citation identity to title plus named section. Multiple chunks from
|
||||
the same unsectioned document now render as one source card, while distinct named
|
||||
sections remain independently citable.
|
||||
- Stabilized acceptance selectors around persistent business identity instead of table
|
||||
position: the guided demo filters for the generated MO-024 odometer issue, vehicle
|
||||
attention checks use semantic reason markers, and reassigned work verifies the exact
|
||||
captured issue reference after the queue reorders.
|
||||
- Validation evidence: targeted knowledge **31 passed**; complete isolated PostgreSQL
|
||||
backend **225 passed, 1 dependency warning**; complete live production Playwright
|
||||
**145 passed in 4.4 minutes**. The live visual audit covered desktop dashboard, mobile
|
||||
data-quality, Integration Management and a grounded RAGcore exchange without overlap
|
||||
or horizontal page overflow.
|
||||
- Live hand-off: readiness is `ready`; migration is `f43d829ab610 (head)`; API, database
|
||||
and web containers are healthy. Reset restored all scenarios with the canonical 2 / 180
|
||||
/ 50 / 254 / 75 / 40 / 33 / 20 entity counts. Central n8n, MCP Hub and RAGcore all
|
||||
report operational/available; no separate n8n was started.
|
||||
- Recovery evidence: verified live backup
|
||||
`/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T111913Z.dump`.
|
||||
- Exact next action: none for the locked PoC. Routine operation, monitoring and any scope
|
||||
expansion require a separate approved milestone.
|
||||
|
||||
## M21 — optional organisation identity alongside the public demo (2026-08-10)
|
||||
|
||||
- Added standards-based OpenID Connect login while preserving both public demo roles and
|
||||
the guided demo. Provider discovery, authorization-code exchange, state/nonce checks
|
||||
and ID-token validation are delegated to Authlib's OIDC client.
|
||||
- External identities bind uniquely to issuer plus subject. A verified email is required;
|
||||
deployments can enforce an email-domain allowlist and disable auto-provisioning. New
|
||||
users receive the least-privileged rental role and every provision/link/login is
|
||||
audited. Deactivated users remain blocked by the canonical user record.
|
||||
- Added the nullable external-identity migration `a81d0ce9f662`, configuration contract,
|
||||
trilingual login action and runbook. OIDC secrets stay deployment-only.
|
||||
- Added Starlette's supported `httpx2` test transport, removing the prior suite-wide
|
||||
deprecation warning rather than suppressing it.
|
||||
- Evidence: focused authentication **13 passed with zero warnings**; frontend production
|
||||
build passed; ruff clean. Exact next action: implement structured request logging,
|
||||
correlation, metrics, dashboards and alerts.
|
||||
|
||||
## M22 — operational observability (2026-08-10)
|
||||
|
||||
- Added UUID request correlation propagated through response headers, structured API
|
||||
errors and machine-readable JSON request logs. Logs include UTC time, route, method,
|
||||
status, latency and client IP; Docker rotates bounded 10 MB files.
|
||||
- Added Prometheus metrics for request rate/status, duration buckets, in-flight requests,
|
||||
database readiness and persisted outbox state separated into real and synthetic
|
||||
scenarios. `/metrics` supports constant-time Bearer protection if exposed beyond the
|
||||
private Compose network.
|
||||
- Added an optional pinned Prometheus/Grafana Compose profile, provisioned datasource,
|
||||
six-panel operational dashboard and six validated alert rules. Real failures and
|
||||
backlogs alert; the deliberate demo retry does not.
|
||||
- Evidence: focused observability **5 passed without warnings**; ruff/mypy clean;
|
||||
Prometheus `promtool` accepted the scrape config and all six rules; merged Compose and
|
||||
Grafana dashboard JSON validate. Exact next action: automate verified backups,
|
||||
retention and restore-readiness checks.
|
||||
|
||||
## M23 — scheduled and retained recovery points (2026-08-10)
|
||||
|
||||
- Added a continuously running Unraid backup service that waits for healthy PostgreSQL,
|
||||
creates an immediate custom-format dump and repeats at a configurable interval without
|
||||
access to the Docker socket. The existing on-demand path remains supported.
|
||||
- Every backup must pass `pg_restore --list` before publication and receives a SHA-256
|
||||
sidecar. Retention defaults to 30 days while always preserving at least seven newest
|
||||
restore points. An optional independently mounted secondary destination receives the
|
||||
same verified artifacts.
|
||||
- Added a latest-success marker and container healthcheck that detects a missed 26-hour
|
||||
recovery point, a standalone verification command, guarded destinations and bounded
|
||||
backup/log storage.
|
||||
- Evidence: all shell scripts pass Alpine `sh -n`; merged Unraid Compose validates; a
|
||||
disposable PostgreSQL instance produced a real dump, checksum verification passed and
|
||||
`pg_restore --list` accepted the artifact. Exact next action: implement privacy export,
|
||||
anonymisation safeguards, retention reporting and governance documentation.
|
||||
|
||||
## M24 — executable privacy governance (2026-08-10)
|
||||
|
||||
- Added a manager-only Privacy workspace with persisted policy metrics, customer dossier
|
||||
export, bounded audit CSV export and irreversible customer anonymisation. Privacy is
|
||||
localized in all three supported languages, searchable and hidden from rental staff.
|
||||
- Anonymisation is row-locked and requires the exact stable customer reference plus a
|
||||
reason. Reserved/active bookings and bookings inside the configurable minimum retention
|
||||
window block the action. PII is cleared while stable references and operational history
|
||||
remain valid; repeated requests are idempotent.
|
||||
- Every export and anonymisation is audited. The anonymisation audit records state and
|
||||
justification but deliberately never copies erased PII. Audit CSV ranges are capped at
|
||||
90 days and a configurable maximum row count.
|
||||
- Added explicit customer anonymisation state/migration `b913a72e8c14`, a governance
|
||||
runbook covering inventory, retention, data-subject requests, access review and incident
|
||||
handling, plus regenerated OpenAPI.
|
||||
- Evidence: privacy API **6 passed without warnings**; ruff/mypy clean; React review led
|
||||
to stable callback/effect dependencies and a lazy route chunk; TypeScript, lint and
|
||||
production build pass. Exact next action: extend RAGcore corpus statistics and health
|
||||
evidence, then run complete acceptance and deploy all production-readiness milestones.
|
||||
|
||||
## M25 — provenance-aware knowledge statistics (2026-08-10)
|
||||
|
||||
- Expanded knowledge health with separate counts for authoritative local source
|
||||
documents, the latest persisted n8n sync report and documents independently verified
|
||||
as indexed. The API includes failed sync count, report time and an explicit statistics
|
||||
provenance state.
|
||||
- Confirmed against RAGcore's checked-in OpenAPI and route implementation that the
|
||||
service intentionally exposes identity-based single-document lookup but no corpus-size
|
||||
or space-browse endpoint. Fleet Ops therefore keeps `document_count=null` for RAGcore
|
||||
and never mislabels an accepted upload as proven indexing/publication.
|
||||
- Knowledge, dashboard and Integration Management now present the available source and
|
||||
sync evidence in all three locales. The deterministic demo provider continues to
|
||||
report its directly verified per-language corpus count.
|
||||
- Regenerated the Fleet Ops OpenAPI contract and documented the provenance rules.
|
||||
- Evidence: focused knowledge/demo suite **36 passed with zero warnings** from a rebuilt
|
||||
image; ruff/mypy passed; frontend TypeScript lint and production build passed. The
|
||||
prior Starlette/httpx warning is confirmed absent in the rebuilt environment.
|
||||
- Exact next action: run complete clean acceptance, push all five milestone commits,
|
||||
create a verified live backup, redeploy and execute live browser acceptance.
|
||||
|
||||
## M26 — clean acceptance hardening (2026-08-10)
|
||||
|
||||
- The clean full-suite run exposed and fixed two observability regressions that targeted
|
||||
runs against a stale image had missed: readiness now returns immediately with 503 only
|
||||
on database failure and reaches the healthy 200 branch correctly; Prometheus outbox
|
||||
series now use the documented label order.
|
||||
- All synthetic/operational and pending/delivering/succeeded/failed outbox combinations
|
||||
are initialized to zero before persisted counts are applied. Fresh installs, restores
|
||||
and demo resets therefore produce stable zero-valued metrics instead of misleading
|
||||
`no data` panels and absent alert inputs.
|
||||
- Evidence from the final rebuilt isolated stack: complete backend **239 passed with zero
|
||||
warnings**, ruff passed and mypy passed across 58 source files. Frontend TypeScript
|
||||
lint and production build pass; full and production-only npm audits both report zero
|
||||
vulnerabilities.
|
||||
- Exact next action: commit/push, take a verified pre-deployment backup, redeploy to
|
||||
Unraid, then run migration, health, browser and full Playwright acceptance.
|
||||
|
||||
## M27 — portable backup evidence (2026-08-10)
|
||||
|
||||
- Live restore-readiness validation found that scheduled checksum files named their dump
|
||||
with the container-only `/backups` path. Dumps were structurally valid, but host-side
|
||||
`sha256sum -c` could not resolve that path.
|
||||
- Scheduled and on-demand backups now write basename-only checksum entries, so dump plus
|
||||
sidecar remains verifiable after copying to the host or an independent secondary
|
||||
destination.
|
||||
- Evidence: Alpine shell syntax passed; the recreated live backup service produced
|
||||
`mobilityops-20260810T141944Z.dump`; checksum validation and `pg_restore --list` both
|
||||
passed through the documented host verification command.
|
||||
|
||||
## M28 — final production acceptance and hand-off (2026-08-10)
|
||||
|
||||
- Pushed milestones M21–M27 to Gitea `master`, deployed committed source archives to
|
||||
`/mnt/user/appdata/mobilityops`, and applied migration `b913a72e8c14 (head)`. Public
|
||||
passwordless demo access remains enabled; OIDC remains an optional disabled addition.
|
||||
- Activated the pinned internal Prometheus/Grafana profile with a server-generated
|
||||
Grafana password and loopback-only ports. Prometheus reports the API target up; all six
|
||||
alert rules evaluate healthy; Grafana 12.2.0 reports database `ok`. API, database, web,
|
||||
scheduled backup, Prometheus and Grafana are running, while the existing shared n8n
|
||||
remains the only n8n instance used by Fleet Ops.
|
||||
- Live visual acceptance covered desktop dashboard and knowledge statistics plus Privacy
|
||||
at desktop and a fresh 390 × 844 load. There was no horizontal overflow; the mobile
|
||||
shell starts closed and the irreversible privacy action remains guarded.
|
||||
- Complete live Playwright acceptance is **147 passed in 4.6 minutes**. A race in the new
|
||||
privacy test login helper was removed by waiting for the authenticated dashboard; the
|
||||
privacy pair also passed three consecutive repetitions before the full green run.
|
||||
- Final demo reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
|
||||
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs**. API
|
||||
readiness is `ready`, RAGcore is available with 11 authoritative NL sources and an
|
||||
honestly unknown provider index count, and the latest backup verifies. No API/backup
|
||||
error or traceback appears in post-deployment logs.
|
||||
- Exact next action: none for the selected scope. Optional secret rotation and new n8n
|
||||
execution evidence remain intentionally deferred by the user.
|
||||
|
||||
## M29 — recruiter-facing product polish and engineering evidence (2026-08-10)
|
||||
|
||||
- Added a passwordless **90-second Highlights** entry that presents three directly
|
||||
verifiable engineering stories: transactional vehicle returns, human-controlled data
|
||||
correction and citation-bound knowledge. Rebuilt the Engineering page around domain
|
||||
ownership, commit-before-orchestration, AI evidence, architecture and honest live/demo
|
||||
integration states.
|
||||
- Return completion now carries its persisted correlation ID into a dedicated audit
|
||||
processing trace, linking the return, vehicle state, transactional outbox and delivery
|
||||
evidence. Data-quality detail pages lead with the human rule name instead of a raw
|
||||
identifier; Knowledge Hub source titles, staged progress and provider diagnostics are
|
||||
clearer without overstating RAGcore index evidence.
|
||||
- Improved recruiter entry metadata and social preview, route skeletons, 360 px touch
|
||||
targets, reduced-motion behaviour, trilingual content and deterministic guide fallbacks.
|
||||
Added automated recruiter and mobile-overflow coverage plus reproducible evidence
|
||||
screenshot tooling. Refocused README, architecture and portfolio documentation on
|
||||
inspectable product and reliability claims.
|
||||
- Validation: isolated backend **239 passed**; Ruff clean; mypy clean across 58 files;
|
||||
frontend lint/build passed; full Playwright **150 passed in 5.2 minutes**; full and
|
||||
production npm audits report zero vulnerabilities; screenshot capture **1 passed** and
|
||||
desktop/mobile output was visually inspected.
|
||||
- Exact next action: commit and push M29, create and verify a live database backup,
|
||||
deploy the committed archive to Unraid, then run live health, migration, browser and
|
||||
acceptance checks before recording the final production evidence.
|
||||
|
||||
## M30 — recruiter polish production acceptance (2026-08-10)
|
||||
|
||||
- Pushed M29 to Gitea `master` as `29325b6`, created and verified pre-deployment backup
|
||||
`mobilityops-20260810T161659Z.dump`, and deployed that exact source archive while
|
||||
preserving `.env`, volumes and the existing central n8n. Deployment revision is
|
||||
`29325b6c2775806080dc40e1988634d6ceb300a0`; Alembic is `b913a72e8c14 (head)`.
|
||||
- API, web, PostgreSQL, scheduled backup, Prometheus and Grafana are healthy/current.
|
||||
Live readiness is `ready`; post-deployment logs contain no traceback or critical error.
|
||||
- The initial production browser run exposed two stale assertions that expected raw
|
||||
RAGcore filenames. Fleet Ops now intentionally presents localized source titles. The
|
||||
tests were tightened around localized titles plus language-specific evidence; the
|
||||
focused live matrix passed 4/4 and the complete live suite then passed **150/150 in
|
||||
4.2 minutes**.
|
||||
- Final live visual audit covered login, Highlights and Engineering. Desktop overflow is
|
||||
zero and the 360/390 px layouts are automated. The integration story is evidence-based:
|
||||
n8n operational (4/4 workflows, 19 successful, zero pending/unexpected failures),
|
||||
RAGcore operational (11 managed sources, provider index count honestly unknown), and
|
||||
MCP labelled not connected because the final reset clears recent tool-call evidence.
|
||||
- Final reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
|
||||
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs** with
|
||||
all five scenarios ready. Live evidence screenshots and the canonical acceptance
|
||||
summary were refreshed in `artifacts/evidence/`.
|
||||
- Exact next action: commit/push this evidence-only hand-off update and refresh the
|
||||
server's source archive/revision marker; no runtime rebuild or database change is needed.
|
||||
|
||||
## M31 — verified RAG inventory and dashboard attention polish (2026-08-10)
|
||||
|
||||
- RAGcore health now verifies every language-specific managed source through the
|
||||
documented exact `/v1/documents` identity lookup. Only an active document with a
|
||||
published active version is counted. RAGcore's provider-owned `content_sha256` hashes
|
||||
its canonical parsed artifact rather than the raw upload, so it is deliberately not
|
||||
compared with Fleet Ops's source-file hash.
|
||||
Lookups are bounded, concurrent and cached for five minutes; an unavailable verifier
|
||||
remains explicitly unknown instead of being presented as zero or as a reported count.
|
||||
- Persisted n8n sync provenance no longer downgrades the stronger provider-verified
|
||||
statistics state. The Knowledge Hub shows the verified count as a separate evidence
|
||||
fact alongside source, sync and recency evidence, in all three supported languages.
|
||||
- The dashboard's remaining-attention action is now a compact 52 px evidence-backed row
|
||||
with count badge, legible title/hint and a 14 px directional icon. The attention panel
|
||||
no longer stretches to the neighbouring full-day timeline; desktop and responsive
|
||||
layouts remain overflow-free.
|
||||
- Validation: isolated backend **241 passed**; Ruff clean; mypy clean across 58 files in
|
||||
the locked container; frontend lint/build passed; focused visual regressions **5 passed**;
|
||||
localized knowledge regressions **3 passed**; complete Playwright **152 passed in 5.4
|
||||
minutes**; full and production npm audits report zero vulnerabilities. In-app visual
|
||||
inspection confirmed the new action dimensions and presentation.
|
||||
- Production RAGcore presents curated localized citation titles while the deterministic
|
||||
provider presents the Markdown frontmatter title. Acceptance now permits both explicit
|
||||
provider contracts while still requiring the language-specific evidence excerpt.
|
||||
- Exact next action: commit and push M31, create and verify a live database backup,
|
||||
redeploy the committed archive, then verify live RAG inventory, create a real post-reset
|
||||
MCP Hub tool-call audit record, and complete production browser/acceptance evidence.
|
||||
|
||||
## M32 — M31 production acceptance and integration proof (2026-08-10)
|
||||
|
||||
- Pushed M31 to Gitea `master`, deployed runtime commit `cb7edb0`, and refreshed the
|
||||
server source marker to the current evidence HEAD on `master`. The verified
|
||||
pre-deployment custom-format backup is
|
||||
`backups/postgres/mobilityops-20260810T185833Z.dump`; its SHA-256 and
|
||||
`pg_restore --list` both passed. Alembic remains `b913a72e8c14 (head)`.
|
||||
- Live RAGcore exact lookups report **11/11 verified active published documents** for
|
||||
each of NL/EN/FR. The Knowledge Hub visibly separates that index proof from the 33/0
|
||||
n8n sync report and labels the provider operational.
|
||||
- A real production MCP Hub `mobilityops.operations.summary` call reached Fleet Ops under
|
||||
the tenant-bound Hub client identity. The final short-lived demo bearer returned HTTP
|
||||
200 with JSON and HTTP 401 after immediate revocation; Fleet Ops now reports MCP Hub
|
||||
operational and retains three real verification audit events.
|
||||
- Complete live Playwright acceptance passed **152/152 in 4.7 minutes** after the focused
|
||||
three-language RAG title/evidence matrix passed 3/3. Final in-app visual inspection
|
||||
confirmed the compact dashboard attention action, the four-fact RAG evidence strip and
|
||||
the operational MCP card. Post-deployment API logs contain no traceback or critical
|
||||
error.
|
||||
- The final reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
|
||||
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs**, with
|
||||
all five scenarios ready; the last action after reset was the read-only Hub proof.
|
||||
- Exact next action: none for the requested scope; keep the public synthetic demo online
|
||||
and monitor its existing health, backup and integration evidence surfaces.
|
||||
|
||||
## M33 — explicit booking readiness and Brussels-safe planning (2026-08-10)
|
||||
|
||||
- New reservations now default to incomplete requirements. Checkout remains unavailable
|
||||
until an operator records a deliberate requirements confirmation; that transition and
|
||||
its bounded evidence are persisted in the audit trail.
|
||||
- Booking creation and availability use Europe/Brussels wall-clock conversion independent
|
||||
of the visitor's browser timezone. Customer and vehicle searches cancel stale requests,
|
||||
use a bounded timeout and expose server-side vehicle filtering up to 50 results.
|
||||
- Validation: isolated booking API suite **12 passed**; backend Ruff clean; frontend
|
||||
TypeScript lint and production build passed.
|
||||
- Exact next action: add database invariants/indexes and harden the shared public demo reset.
|
||||
|
||||
## M34 — database-enforced domain integrity (2026-08-10)
|
||||
|
||||
- Added named PostgreSQL checks for booking windows/status/odometers, vehicle state and
|
||||
non-negative counters, quality rule/severity/lifecycle values, outbox state/attempts and
|
||||
audit actor types. Model metadata and migration `c24f6a9d013e` stay aligned.
|
||||
- Added workload indexes for vehicle availability windows, quality work queues, outbox
|
||||
retries and audit filtering/entity traces.
|
||||
- Validation: migration upgraded from an empty PostgreSQL database to head; the complete
|
||||
deterministic seed loaded with the expected 2/180/50/254/75/40/33/20 counts; four
|
||||
direct invalid-state writes were rejected by their named constraints; Ruff passed.
|
||||
- Exact next action: serialize and rate-limit shared demo reset, add production web guards
|
||||
and cache MCP reachability evidence.
|
||||
|
||||
## M35 — shared public demo and edge hardening (2026-08-10)
|
||||
|
||||
- Demo resets now use a non-blocking process guard plus a PostgreSQL transaction advisory
|
||||
lock, and enforce a configurable post-success cooldown with a standards-based
|
||||
`Retry-After`. Test deployments explicitly disable only the cooldown, never locking.
|
||||
- Nginx rate-limits public demo login/reset endpoints and adds CSP, anti-framing, MIME,
|
||||
referrer and browser capability headers. Hashed assets receive long-lived caching while
|
||||
the application shell is revalidated. Production FastAPI deployments no longer expose
|
||||
Swagger, ReDoc or OpenAPI routes.
|
||||
- MCP Hub reachability probes are synchronized and cached for 60 seconds, removing a
|
||||
remote network call from every Integration Management page load while retaining honest
|
||||
failure evidence.
|
||||
- Validation: authentication/reset suite **15 passed**; Ruff and mypy passed across 58
|
||||
source files; production web image built and `nginx -t` passed; production API docs-off
|
||||
assertion passed.
|
||||
- Exact next action: improve mobile Data Quality operations, technical evidence labels,
|
||||
RAG scope clarity and sticky resolution actions.
|
||||
|
||||
## M36 — operational UX depth and scalable quality review (2026-08-10)
|
||||
|
||||
- Reserved bookings can now be moved through an audited, overlap-safe schedule command.
|
||||
The detail page uses Brussels wall-clock input and explains/rechecks availability.
|
||||
- Mobile Data Quality starts with a compact filter trigger, exposes removable active
|
||||
filters, supports select/deselect-visible, aligns checkboxes with record cards and keeps
|
||||
bulk/resolution controls reachable above the mobile navigation. The row link now has one
|
||||
accessible reference instead of duplicate screen-reader text.
|
||||
- MCP evidence leads with the friendly ITWorx Hub connector and keeps the raw client
|
||||
identity in technical disclosure. RAGcore statistics explicitly distinguish the current
|
||||
language from the all-language n8n report.
|
||||
- Duplicate-customer scanning now blocks on the exact identifiers required to reach its
|
||||
score threshold before running name similarity. This replaces quadratic all-pairs work
|
||||
without changing detection semantics. Booking list hydration also fetches only related
|
||||
customer and vehicle rows.
|
||||
- React review confirmed primitive effect dependencies, aborted request handling, semantic
|
||||
controls, keyboard names and no new render waterfalls. Validation: focused booking/data
|
||||
quality API **50 passed**; Ruff/mypy and frontend lint/build passed; focused Playwright
|
||||
booking and mobile quality flows passed. Visual inspection at 390 px and 1440 px found
|
||||
no overflow or console warnings; the only logged error was the intentional anonymous
|
||||
session probe 401.
|
||||
- Exact next action: run complete clean backend/frontend acceptance and the full five-minute
|
||||
browser suite, then commit/push, back up and deploy.
|
||||
|
||||
## M37 — complete local acceptance and production guard calibration (2026-08-10)
|
||||
|
||||
- The complete backend suite passed **249/249** against an isolated clean PostgreSQL
|
||||
database. Ruff is clean, mypy reports no issues across 58 source files and the final
|
||||
migration/seed path remains reproducible.
|
||||
- The complete Playwright matrix passed **153/153 in 5.7 minutes** with one worker. This
|
||||
covers the five-minute scenario, recruiter entry, booking readiness/rescheduling,
|
||||
operational CRUD, Data Quality resolution, privacy, RAG/MCP evidence, three locales,
|
||||
keyboard behavior and responsive layouts from 360 px through 1440 px.
|
||||
- Frontend TypeScript lint and production build passed; the production dependency audit
|
||||
reports zero vulnerabilities. The rebuilt Nginx configuration passes `nginx -t`.
|
||||
- Demo reset cooldown is now explicitly passed into the API container. Acceptance can set
|
||||
it to zero without weakening the advisory/process locks, while production defaults to
|
||||
60 seconds. Login throttling remains active at ten requests/second with a 100-request
|
||||
burst so a public demo is protected without rejecting the deterministic acceptance run.
|
||||
- Exact next action: commit and push M37, create and verify a live PostgreSQL backup,
|
||||
deploy the committed archive, migrate to `c24f6a9d013e`, then repeat live acceptance and
|
||||
restore the public reset cooldown to 60 seconds.
|
||||
|
||||
## M38 — production deployment and final acceptance (2026-08-10)
|
||||
|
||||
- Pushed M33–M37 to Gitea `master` and deployed runtime commit `2681935` to Unraid at
|
||||
`http://192.168.10.150:1236`. The verified pre-deployment custom-format backup is
|
||||
`backups/postgres/mobilityops-20260810T211951Z.dump`; SHA-256 verification and
|
||||
`pg_restore --list` both passed.
|
||||
- All application, database, backup and observability containers are healthy. Alembic is
|
||||
`c24f6a9d013e (head)`, the production API returns 404 for Swagger/OpenAPI internally,
|
||||
readiness returns 200 and the final ten-minute traceback/critical-error scan is clean.
|
||||
- Complete live Playwright acceptance passed **153/153 in 5.6 minutes** against the public
|
||||
Unraid URL. Nginx exposes the CSP, anti-framing, MIME and referrer guards and revalidates
|
||||
the app shell.
|
||||
- The final reset restored **2 users / 180 customers / 50 vehicles / 254 bookings / 75
|
||||
inspections / 40 maintenance records / 33 quality issues / 20 workflow runs** and all
|
||||
five demo scenarios report ready. The public reset cooldown was restored to 60 seconds.
|
||||
- The shared n8n at `192.168.10.150:5678` returns health 200; no bundled n8n container was
|
||||
started. Fleet Ops reports 19 successful deliveries and zero unexpected failures; its
|
||||
one expected failure is the labelled error-handling demo scenario. MCP Hub is operational
|
||||
with three retained real tool calls. RAGcore is available with 11/11 verified sources in
|
||||
the active language collection and 33/0 all-language sync evidence.
|
||||
- Exact next action: none for the locked scope; keep the public synthetic demo online and
|
||||
monitor the existing health, backup and integration evidence surfaces.
|
||||
|
||||
@@ -1,138 +1,94 @@
|
||||
# Fleet Ops
|
||||
|
||||
**Connected operations for vehicle rental and service teams.**
|
||||
**A recruiter-ready operations platform for vehicle rental and service teams.**
|
||||
|
||||
Fleet Ops is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub.
|
||||
**Try it in two commands** (`cp .env.example .env && make demo`, then open `http://localhost:1228`) · no password required · choose **Highlights in 90 seconds** for the shortest tour. The reference deployment runs on a private LAN (see [deploy/unraid/README.md](deploy/unraid/README.md)); ask for a link if you want the hosted version.
|
||||
|
||||
**Naming:** "Fleet Ops" is the product's visible name everywhere in the UI, the demo
|
||||
knowledge base, and this documentation. "MobilityOps" remains the technical
|
||||
identifier only — the repository name, local directory, package/module names, Docker
|
||||
Compose project, deployment directory, and database names. The UI is fully trilingual
|
||||
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
|
||||
architecture, the vehicle-status decision table, and the correction evidence, and
|
||||
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
|
||||
NL/FR translation gaps, centralized API-error localization, the time-dependent
|
||||
Europe/Brussels dashboard greeting).
|
||||
Fleet Ops turns fragmented vehicle, booking and procedure data into one controlled operational workspace. It is a complete synthetic-data product demo: the company and records are fictional, while the workflows, persistence, validation, authorization, audit trail and integration boundaries are implemented.
|
||||
|
||||
The web application uses the premium responsive **Control Rail** interface: a compact
|
||||
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
|
||||
review-before-commit return handling and mobile navigation designed down to 390 px. See
|
||||
`docs/design/design-directions.md` and `docs/design/implementation-validation.md` for the
|
||||
design decision and visual evidence.
|
||||

|
||||
|
||||
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real.
|
||||
## The 90-second tour
|
||||
|
||||
## Demo
|
||||
1. Open **Highlights** from the login screen.
|
||||
2. Follow a vehicle return from review to atomic commit, quality issue, outbox and correlated audit trace.
|
||||
3. Compare and merge a duplicate customer with explicit human confirmation.
|
||||
4. Ask the Knowledge Hub a damage question and inspect its cited procedure evidence.
|
||||
5. Open **Engineering** for the architecture, reliability guarantees, test evidence and honest scope boundary.
|
||||
|
||||
The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van
|
||||
rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app
|
||||
guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page
|
||||
all make the fictional context, synthetic-data status, and real-vs-simulated boundaries
|
||||
explicit without any verbal explanation. See `docs/demo-release/` for the full demo
|
||||
concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour
|
||||
design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy,
|
||||
rollback).
|
||||
## What makes it more than a mock-up
|
||||
|
||||
## Scope
|
||||
- **Transactional operations:** a return writes the inspection, vehicle/booking state, audit events and outbox record atomically. n8n downtime never rolls back the local business transaction.
|
||||
- **Explainable data quality:** five persisted rule types, SLA deadlines, assignment, bulk queue controls and bounded resolution flows—not decorative warning cards.
|
||||
- **Grounded knowledge:** the live deployment uses RAGcore; insufficient or unavailable evidence produces no invented answer. Citations and provider provenance remain inspectable.
|
||||
- **Safe AI exposure:** four tenant-bound, service-authenticated, read-only Fleet Ops tools are published through ITWorx MCP Hub and audited with correlation IDs.
|
||||
- **Operational reliability:** bounded retries, delivery leases, health/readiness, Prometheus metrics, Grafana, scheduled verified backups and graceful external-dependency degradation.
|
||||
- **Real product ergonomics:** nl-BE, en-GB and fr-BE; responsive from 360 px; keyboard-accessible navigation; role-aware global search; route-level lazy loading; server-enforced permissions.
|
||||
|
||||
The PoC implements:
|
||||
## Architecture
|
||||
|
||||
- operations dashboard with a truthful aggregate n8n/MCP integration-status card;
|
||||
- vehicle and booking views with working search, filters and pagination;
|
||||
- server-backed session lifecycle (refresh-safe, central 401 handling);
|
||||
- a role matrix enforced server-side and mirrored in the UI (see
|
||||
`docs/12-security-and-audit.md`);
|
||||
- vehicle return capture → authoritative server-evaluated review → commit → result;
|
||||
- five deterministic data-quality checks, each with a bounded resolution flow, plus a
|
||||
manual scan action;
|
||||
- human review and customer merge;
|
||||
- audit trail with human-readable before/after evidence and safe entity links;
|
||||
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
|
||||
- safe, confirmed demo reset;
|
||||
- RAGcore-backed knowledge assistant with citations;
|
||||
- two n8n workflows: return processing, and a scheduled data-quality scan with
|
||||
crash-recoverable outbox delivery leases;
|
||||
- four read-only MCP tools through ITWorx MCP Hub;
|
||||
- deterministic demo reset and five-minute showcase.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI["React + TypeScript\nresponsive operations UI"] -->|session cookie| API["FastAPI\nbusiness rules + RBAC"]
|
||||
API --> DB[(PostgreSQL)]
|
||||
API -->|grounded retrieval| RAG[RAGcore]
|
||||
DB --> OUT["Transactional outbox"]
|
||||
OUT -->|bounded retry| N8N["Existing central n8n"]
|
||||
N8N -->|authenticated callback| API
|
||||
HUB["ITWorx MCP Hub"] -->|4 read-only tools| API
|
||||
```
|
||||
|
||||
It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent.
|
||||
Fleet Ops owns operational truth. RAGcore owns retrieval, n8n performs post-commit orchestration, and MCP Hub owns tool transport/publication. Neither RAGcore nor MCP Hub accesses the Fleet Ops database directly. See [the as-built architecture](artifacts/evidence/architecture.md).
|
||||
|
||||
## Integration status
|
||||
## Demonstrable scope
|
||||
|
||||
- **n8n**: fully implemented and verified against a real n8n instance, including
|
||||
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending`
|
||||
with backoff, self-heals once n8n returns), the failed-delivery manual-retry path,
|
||||
stale-delivery-lease recovery after a simulated crash, and a second (scheduled
|
||||
quality-scan) workflow live-verified end to end against a real n8n instance.
|
||||
`GET /api/v1/integrations/status` reports a truthful aggregate state from outbox
|
||||
delivery counts, not just the most recent event.
|
||||
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
|
||||
over the local procedure documents) is what satisfies the knowledge-assistant
|
||||
acceptance criteria and is what's active in production (`KNOWLEDGE_PROVIDER=demo`). A
|
||||
`RAGcoreKnowledgeProvider` HTTP adapter is implemented, unit-tested, and has been
|
||||
exercised live against the deployed RAGcore instance: a real filesystem-permission bug
|
||||
that caused every live retrieval to return zero candidates was found and fixed
|
||||
(`docs/final-integrations/current-state-audit.md`), but a second, deeper gap — RAGcore's
|
||||
reranker adapter calls an Ollama HTTP route (`/api/rerank`) that does not exist on the
|
||||
deployed Ollama version — still blocks real grounded answers. `KNOWLEDGE_PROVIDER` stays
|
||||
`demo` until that is resolved on the RAGcore side.
|
||||
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
|
||||
directly `curl`-verified with correct auth enforcement and audit logging.
|
||||
`MCP_HUB_REGISTRATION_ENABLED` is actually wired into `Settings` and reported honestly
|
||||
by the integration-status endpoint (evidence-based: real tool-call audit history, not
|
||||
just the flag). The Fleet Ops connector is confirmed live in the ITWorx MCP Hub's own
|
||||
production deployment (Tower), with a real contract fix already applied there
|
||||
(`vehicle.get`'s wire parameter normalized to `vehicleRef`).
|
||||
- dashboard, vehicle fleet, booking lifecycle and controlled returns;
|
||||
- data-quality queue, assignment, review, merge and resolution;
|
||||
- correlated human-readable audit history;
|
||||
- cited Knowledge Hub with honest provider state;
|
||||
- n8n delivery monitoring and manual retry;
|
||||
- user administration, privacy export/anonymisation and retention guards;
|
||||
- deterministic reset with 2 users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance records, 33 quality issues and 20 workflow runs.
|
||||
|
||||
See `artifacts/functional-completion/final-summary.md` for the functional-completion
|
||||
audit evidence (supersedes the design-validation summary below for integration status),
|
||||
and `artifacts/final-acceptance/summary.md` for the original M0–M7 acceptance evidence.
|
||||
This is deliberately not accounting, payments, a public reservation site, generic CRM, inventory, HR or an autonomous write agent.
|
||||
|
||||
## Repository map
|
||||
## Stack
|
||||
|
||||
- `CLAUDE.md` — binding implementation rules.
|
||||
- `MASTER_BUILD_PROMPT.md` — prompt to start an autonomous Claude run.
|
||||
- `PROJECT_STATE.md` — short persistent project memory.
|
||||
- `docs/` — product, architecture, UX and acceptance specification.
|
||||
- `contracts/` — OpenAPI, event and MCP contracts.
|
||||
- `knowledge/` — fictitious source documents for the MobilityOps RAGcore workspace.
|
||||
- `seed/` — deterministic synthetic dataset and generator.
|
||||
- `n8n/` — importable workflow definitions.
|
||||
- `backend/` — FastAPI/SQLAlchemy/Alembic API.
|
||||
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`).
|
||||
- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
|
||||
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
|
||||
- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline.
|
||||
- `artifacts/functional-completion/` — functional-completion acceptance evidence.
|
||||
- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided
|
||||
tour, and runbook.
|
||||
- `artifacts/demo-release/` — demo-productization acceptance evidence.
|
||||
React, TypeScript, Vite, FastAPI, SQLAlchemy 2, PostgreSQL, Alembic, n8n, RAGcore, ITWorx MCP Hub, Docker Compose, Prometheus, Grafana and Playwright.
|
||||
|
||||
## Quickstart
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
make demo
|
||||
```
|
||||
|
||||
This builds and starts the full stack (migrations run automatically) and loads the
|
||||
deterministic demo dataset. See `docs/17-runbook.md` for the one-time n8n workflow setup
|
||||
required for the automation demo, and the full operational runbook.
|
||||
|
||||
Endpoints:
|
||||
|
||||
- Web: `http://localhost:1228`
|
||||
- API health: `http://localhost:8128/health`
|
||||
- n8n: `http://localhost:5678`
|
||||
- API readiness: `http://localhost:8128/health/ready`
|
||||
- Existing n8n server: point `N8N_WEBHOOK_URL` at its return-processing webhook (see `.env.example`). The bundled `n8n` service in `compose.yaml` is a local fallback only; production reuses the server's central n8n (`compose.unraid.yaml` disables the bundled one).
|
||||
|
||||
All defaults are configurable via `.env` (see `.env.example`).
|
||||
The deterministic local knowledge provider supports clean-checkout acceptance without pretending to be the live RAGcore integration. Configuration is documented in `.env.example`; operations and recovery are in [docs/17-runbook.md](docs/17-runbook.md).
|
||||
|
||||
## Quality gates
|
||||
|
||||
```bash
|
||||
make test # backend: pytest (151 tests)
|
||||
make lint # backend: ruff + mypy (strict, zero errors)
|
||||
make e2e # frontend: Playwright end-to-end (138 tests, live stack required)
|
||||
make test # isolated PostgreSQL backend suite
|
||||
make lint # Ruff + strict mypy
|
||||
make e2e # complete Playwright browser acceptance
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
|
||||
Release-scoped results and production evidence are recorded in [artifacts/final-acceptance/summary.md](artifacts/final-acceptance/summary.md); older milestone evidence remains explicitly historical. [PROJECT_STATE.md](PROJECT_STATE.md) records the commands and exact deployment revision.
|
||||
|
||||
## Repository map
|
||||
|
||||
- `backend/` — FastAPI domain, API, migrations and tests
|
||||
- `frontend/` — React app and Playwright acceptance suite
|
||||
- `contracts/` — OpenAPI, event and MCP contracts
|
||||
- `knowledge/` — versioned fictional procedures
|
||||
- `n8n/` — importable workflow definitions for the existing server
|
||||
- `seed/` — deterministic synthetic dataset
|
||||
- `docs/` — architecture, security, UX, testing and runbooks
|
||||
- `artifacts/` — dated, release-scoped acceptance evidence and screenshots
|
||||
|
||||
“MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users.
|
||||
|
||||
@@ -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 `jens@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.
|
||||
@@ -18,7 +18,7 @@ Claude must use `PROJECT_STATE.md` as its compact memory between sessions. Do no
|
||||
- architecture and domain decisions;
|
||||
- API and event contracts;
|
||||
- realistic deterministic synthetic seed data;
|
||||
- ten fictitious procedures for RAGcore;
|
||||
- eleven fictitious procedures for RAGcore (nl-BE, en-GB, fr-BE);
|
||||
- an initial n8n workflow export;
|
||||
- MCP tool definitions for ITWorx MCP Hub;
|
||||
- a minimal bootable frontend/API scaffold;
|
||||
|
||||
@@ -1,63 +1,68 @@
|
||||
# MobilityOps — as-built architecture
|
||||
# Fleet Ops — as-built architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Browser
|
||||
UI["MobilityOps Web<br/>React + TypeScript"]
|
||||
end
|
||||
UI["Fleet Ops Web\nReact + TypeScript + Vite"]
|
||||
|
||||
subgraph MobilityOps["MobilityOps (this repo)"]
|
||||
API["FastAPI backend<br/>/api/v1/*"]
|
||||
DISPATCH["Outbox dispatcher<br/>background thread"]
|
||||
subgraph CORE["Fleet Ops — this repository"]
|
||||
API["FastAPI /api/v1\nRBAC + domain rules"]
|
||||
OUT["Outbox dispatcher\nleases + bounded retry"]
|
||||
DB[(PostgreSQL)]
|
||||
OBS["Prometheus metrics\nGrafana dashboards"]
|
||||
API --> DB
|
||||
DISPATCH --> DB
|
||||
OUT --> DB
|
||||
API --> OBS
|
||||
end
|
||||
|
||||
subgraph External["External central services"]
|
||||
N8N["n8n<br/>return-processing workflow"]
|
||||
RAGDEMO["Demo KnowledgeProvider<br/>TF-IDF extractive, local files"]
|
||||
RAGCORE["RAGcore<br/>(adapter built, no live instance)"]
|
||||
HUB["ITWorx MCP Hub<br/>(endpoints built, no live instance)"]
|
||||
subgraph EXT["Existing external platforms"]
|
||||
N8N["Central n8n\nsecondary orchestration"]
|
||||
RAG["RAGcore\ngrounded procedure retrieval"]
|
||||
HUB["ITWorx MCP Hub\ntool transport + publication"]
|
||||
end
|
||||
|
||||
UI -->|session cookie| API
|
||||
API -->|GroundedAnswer| RAGDEMO
|
||||
API -.->|configurable, unavailable-safe| RAGCORE
|
||||
DISPATCH -->|POST vehicle.returned.v1| N8N
|
||||
N8N -->|callback, X-Service-Token| API
|
||||
HUB -.->|X-Service-Token, read-only| API
|
||||
|
||||
classDef unverified stroke-dasharray: 5 5;
|
||||
class RAGCORE,HUB unverified;
|
||||
UI -->|secure session cookie| API
|
||||
API -->|tenant/workspace adapter| RAG
|
||||
OUT -->|vehicle.returned.v1| N8N
|
||||
N8N -->|service-authenticated callback| API
|
||||
HUB -->|service-authenticated read-only tools| API
|
||||
```
|
||||
|
||||
Dashed boxes/arrows are implemented and unit/contract-tested but were never exercised
|
||||
against a live instance in this environment (no reachable RAGcore or ITWorx MCP Hub).
|
||||
Solid boxes were verified end-to-end, including a real n8n instance.
|
||||
## Ownership and trust boundaries
|
||||
|
||||
## Component responsibility (unchanged from `docs/03-architecture.md`)
|
||||
| Component | Owns | Explicitly does not own |
|
||||
|---|---|---|
|
||||
| Fleet Ops | vehicles, customers, bookings, inspections, quality issues, audit, permissions, outbox state | external workflow execution or procedure retrieval |
|
||||
| RAGcore | indexing/retrieval and grounded procedure evidence | Fleet Ops database or business state |
|
||||
| ITWorx MCP Hub | MCP transport, connector publication and central tool-call audit | Fleet Ops database or write actions |
|
||||
| n8n | post-commit workflow orchestration | critical business rules or the source-of-truth transaction |
|
||||
|
||||
| Component | Owns |
|
||||
|---|---|
|
||||
| MobilityOps | vehicles, customers, bookings, inspections, data-quality issues, audit, outbox/delivery state |
|
||||
| RAGcore | procedure retrieval and grounded answers (demo provider substitutes locally) |
|
||||
| ITWorx MCP Hub | MCP transport, tool publication, central tool-call audit |
|
||||
| n8n | post-commit secondary orchestration only — never the source of truth for vehicle state |
|
||||
## End-to-end return trace
|
||||
|
||||
## Reliability boundaries verified in this build
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
actor Operator
|
||||
participant Web
|
||||
participant API
|
||||
participant DB
|
||||
participant n8n
|
||||
Operator->>Web: Review and confirm return
|
||||
Web->>API: POST return with idempotency key
|
||||
API->>DB: Lock booking and validate invariants
|
||||
API->>DB: Commit inspection, state, audit and outbox atomically
|
||||
API-->>Web: Result + correlation ID
|
||||
Web-->>Operator: Human result and full processing trace
|
||||
API->>n8n: Deliver persisted outbox event
|
||||
n8n->>API: Authenticated status callback
|
||||
API->>DB: Persist delivery/audit evidence
|
||||
```
|
||||
|
||||
1. **Return commits atomically with its outbox event** — `app/services/returns.py`, one
|
||||
transaction; verified by `test_concurrent_returns_only_one_succeeds` (real Postgres row
|
||||
locking, not mocked).
|
||||
2. **Outbox delivery is at-least-once, idempotent by event ID** — verified live: the n8n
|
||||
callback checks for an existing `AuditEvent` by event ID before recording a second time.
|
||||
3. **RAGcore failure disables knowledge answers only** — `RAGcoreKnowledgeProvider` degrades
|
||||
to `unavailable`; the rest of the app is unaffected because the knowledge router is the
|
||||
only consumer.
|
||||
4. **MCP Hub failure does not affect the web application** — the four MCP provider
|
||||
endpoints are a separate authenticated surface (`X-Service-Token`), invisible to the
|
||||
browser-facing API/UI.
|
||||
5. **n8n failure leaves events pending with bounded retries** — verified live: a seeded
|
||||
`failed` event, retried through the UI, was picked up by the background dispatcher and
|
||||
delivered through the real n8n instance within one poll cycle.
|
||||
## Verified reliability properties
|
||||
|
||||
1. Concurrent returns serialize through PostgreSQL row locking; only one can commit.
|
||||
2. Local return success is independent of n8n availability. Pending delivery remains persisted and retryable.
|
||||
3. Outbox delivery is at-least-once and idempotent by event ID, with crash-recoverable leases and bounded backoff.
|
||||
4. RAGcore failure affects knowledge answers only. The UI reports unavailable/insufficient evidence and does not invent an answer.
|
||||
5. MCP endpoints are a separate tenant-bound, client-identity-validated, read-only surface; every call is audited with a correlation ID.
|
||||
6. Browser authorization is enforced again on the API. Hiding a navigation item is never the security boundary.
|
||||
|
||||
The live deployment has exercised all three external boundaries. Local clean-checkout acceptance can use the deterministic extractive knowledge provider while reporting that mode honestly.
|
||||
|
||||
@@ -1,177 +1,74 @@
|
||||
# MobilityOps — final acceptance evidence
|
||||
# Fleet Ops — final acceptance evidence
|
||||
|
||||
## Commit
|
||||
## Accepted implementation
|
||||
|
||||
Built on top of commit `c5b7e21f81694f0339ad31e3bf044db952d0fbe0` (M6, "implement ITWorx
|
||||
MCP Hub publication"). This evidence file and the rest of M7's polish are committed as
|
||||
`M7: portfolio polish and final acceptance` — run `git log --oneline` for the exact hash.
|
||||
- Deployment source marker: current `master`; the application image was built from runtime commit `cb7edb0` and the following commits contain acceptance/evidence only.
|
||||
- Live demo: `http://192.168.10.150:1236` — public passwordless synthetic demo.
|
||||
- Deployment: Unraid `/mnt/user/appdata/mobilityops`, Compose project `mobilityops`.
|
||||
- Database migration: `b913a72e8c14 (head)`.
|
||||
- Product name: Fleet Ops; MobilityOps remains the technical repository/deployment ID.
|
||||
|
||||
## Exact commands (clean checkout)
|
||||
## Clean acceptance — 2026-08-10
|
||||
|
||||
```bash
|
||||
git clone <repo> && cd MobilityOps
|
||||
cp .env.example .env
|
||||
make demo # docker compose up --build -d ; migrations run automatically ; seed --reset
|
||||
```text
|
||||
Backend, isolated PostgreSQL: 241 passed, zero warnings
|
||||
Playwright, fresh local build: 152 passed (5.4 min)
|
||||
Playwright, live production: 152 passed (4.7 min)
|
||||
Ruff: clean
|
||||
Mypy: clean across 58 source files
|
||||
Frontend lint/build: passed
|
||||
npm audit, full and prod: 0 vulnerabilities
|
||||
Alembic: b913a72e8c14 (head)
|
||||
```
|
||||
|
||||
One-time n8n setup (see `docs/17-runbook.md` for full detail — this cannot be scripted
|
||||
end-to-end because it requires a one-time owner account created through n8n's web UI):
|
||||
The browser suite covers both roles, protected routes, booking/return/maintenance/user/privacy workflows, five data-quality resolution types, correlated audit, retryable orchestration, grounded knowledge in three languages, the complete guided demo, the recruiter highlights, keyboard behaviour and responsive layouts from 360 to 1440 px.
|
||||
|
||||
```bash
|
||||
# open http://localhost:5678/setup in a browser, create any owner account
|
||||
make n8n-setup
|
||||
The provider matrix explicitly covers both valid title contracts: the deterministic provider uses Markdown frontmatter titles, while production RAGcore returns curated localized presentation titles. Both must include the language-specific source fragment. The focused live matrix passed 3/3, followed by the complete 152/152 green production run.
|
||||
|
||||
## Production hand-off state
|
||||
|
||||
The final reset completed immediately before the verified Hub call at `2026-08-10T19:16Z` and restored:
|
||||
|
||||
```text
|
||||
users 2 · customers 180 · vehicles 50 · bookings 254 · inspections 75
|
||||
maintenance 40 · data-quality issues 33 · workflow runs 20
|
||||
scenario_integrity.all_ready = true (5/5 scenarios)
|
||||
```
|
||||
|
||||
Verification:
|
||||
API readiness is `ready`, PostgreSQL is `up`, and API, web, database, scheduled backup, Prometheus and Grafana are running healthy/current. No traceback or critical error appears in post-deployment API/backup logs.
|
||||
|
||||
```bash
|
||||
docker compose run --rm api pytest -q # 66 passed
|
||||
docker compose run --rm api ruff check . # All checks passed
|
||||
cd frontend && npm run build # clean tsc + vite build
|
||||
cd frontend && npx playwright test # 1 passed (full 5-minute demo script)
|
||||
```
|
||||
A pre-deployment custom-format backup was created and independently verified by SHA-256 plus `pg_restore --list`:
|
||||
|
||||
## Test counts
|
||||
`/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T185833Z.dump`
|
||||
|
||||
- **Backend**: 66 tests passing (`pytest`), 0 skipped, 0 failed. Ruff clean. Coverage by
|
||||
area: seed determinism (2), auth/roles (4), dashboard (3), vehicles (4), bookings (3),
|
||||
return workflow incl. real concurrent-submission test (9), data quality incl. S2/S4
|
||||
scenarios (10), audit (2), n8n dispatcher incl. malformed-payload regression (6),
|
||||
n8n callback idempotency (3), workflows/retry (4), knowledge incl. S6 scenario (7),
|
||||
MCP provider endpoints (8), health (1).
|
||||
- **Frontend**: `npm run build` — clean TypeScript + Vite build, zero errors.
|
||||
- **End-to-end**: 1 Playwright test (`frontend/e2e/demo.spec.ts`) automating the full
|
||||
documented 5-minute demo script (login → dashboard → S1 return → S2 merge → S6 knowledge
|
||||
question → audit → 360px responsive check) — **passing** against the live stack.
|
||||
## External integration evidence
|
||||
|
||||
## Screenshots of the seven main pages
|
||||
- **Existing central n8n:** configured, dispatch enabled and operational. All four expected workflows have recent healthy heartbeats. The reset state contains 19 successful runs, zero pending, zero unexpected failures and one explicitly synthetic failed-retry scenario.
|
||||
- **RAGcore:** reachable and ready for tenant `northstar-mobility-demo`, workspace `mobilityops`, collection `internal-procedures`. Exact identity lookups independently confirm 11/11 active published documents in each of NL/EN/FR; the latest n8n report separately records 33 synchronized documents and zero failures. The canonical parsed-artifact hash is not misrepresented as a raw-source hash.
|
||||
- **ITWorx MCP Hub:** registration is enabled, the Hub is reachable and Fleet Ops is **operational**. Three real `mobilityops.operations.summary` verification calls are audited under the Hub's tenant-bound client identity; the final short-lived bearer produced HTTP 200/JSON and HTTP 401 after immediate revocation. All exposed Fleet Ops tools remain read-only.
|
||||
|
||||
Captured live against the deterministic seed (`artifacts/evidence/screenshots/`,
|
||||
via `frontend/e2e/_capture-screenshots.spec.ts`):
|
||||
## Recruiter and visual acceptance
|
||||
|
||||
| # | Page | File |
|
||||
|---|---|---|
|
||||
| 1 | Login | `1-login.png` |
|
||||
| 2 | Dashboard | `2-dashboard.png` |
|
||||
| 3 | Vehicles | `3-vehicles.png` |
|
||||
| 4 | Bookings | `4-bookings.png` |
|
||||
| 5 | Data Quality | `5-data-quality.png` |
|
||||
| 6 | Knowledge (grounded S6 answer) | `6-knowledge.png` |
|
||||
| 7 | Automation | `7-automation.png` |
|
||||
| — | Audit (bonus, 8th nav item) | `8-audit.png` |
|
||||
| — | Dashboard at 360px (responsive proof) | `9-mobile-dashboard.png` |
|
||||
The public entry now offers a direct **90-second Highlights** route. It links three product actions to their engineering proof, while the **Engineering** workspace explains domain ownership, commit-before-orchestration, citation-bound AI, security and explicit scope.
|
||||
|
||||
## RAGcore evidence
|
||||
Interactive live inspection confirmed:
|
||||
|
||||
**Success (demo provider, the one actually satisfying acceptance in this environment)** —
|
||||
S6 question against the real `/api/v1/knowledge/questions` endpoint:
|
||||
- no horizontal overflow on Highlights or Engineering at desktop; automated coverage confirms 360/390 px mobile layouts;
|
||||
- clear hierarchy, consistent primary actions and readable proof cards;
|
||||
- compact remaining-attention action (count badge, two-line label, small directional icon) without the former stretched empty panel;
|
||||
- truthful live n8n/RAGcore/MCP labels after reset, including 11 verified RAG documents and the real Hub client/tool evidence;
|
||||
- no dead links, placeholder numbers or unexplained raw data-quality references;
|
||||
- complete synthetic-data disclosure on login and in the persistent shell.
|
||||
|
||||
```json
|
||||
{
|
||||
"answer": "Per \"Vehicle return procedure\" (v2.0), section \"1. Register the return\": Open the active booking and record the ending odometer, fuel level, cleanliness, visible damage, technical warnings and relevant notes.",
|
||||
"evidence_state": "grounded",
|
||||
"sources": [
|
||||
{"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "1. Register the return", "excerpt": "..."},
|
||||
{"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "3. Determine next state", "excerpt": "..."},
|
||||
{"document_id": "damage-procedure", "title": "Damage handling procedure", "version": "1.3", "section": "1. Immediate actions", "excerpt": "..."}
|
||||
],
|
||||
"provider": "demo",
|
||||
"correlation_id": "b50094b7-1c84-4e39-9055-1dc03e8fd1f8"
|
||||
}
|
||||
```
|
||||
Current evidence images are in `artifacts/evidence/screenshots/`:
|
||||
|
||||
**Unavailable (RAGcore adapter, live-demonstrated against an unreachable host)** —
|
||||
`KNOWLEDGE_PROVIDER=ragcore`, `RAGCORE_BASE_URL=http://ragcore-not-reachable:9999`:
|
||||
- `1-login.png`
|
||||
- `2-highlights.png`
|
||||
- `3-engineering-story.png`
|
||||
- `4-dashboard.png`
|
||||
- `5-knowledge-evidence.png`
|
||||
- `6-highlights-mobile.png`
|
||||
|
||||
```
|
||||
health: {'provider': 'ragcore', 'available': False, 'detail': 'RAGcore unavailable: ConnectError: ...', 'document_count': 0}
|
||||
ask: {'answer': '', 'evidence_state': 'unavailable', 'sources': [], 'provider': 'ragcore', 'correlation_id': 'demo-correlation'}
|
||||
```
|
||||
## Deliberate boundary
|
||||
|
||||
No live RAGcore instance was reachable in this environment, so the adapter's actual
|
||||
request/response contract against a real RAGcore is unverified beyond this
|
||||
degrade-safely behavior — see `contracts/ragcore-contract-assumptions.md` and
|
||||
`PROJECT_STATE.md`'s M5 notes.
|
||||
|
||||
## n8n evidence
|
||||
|
||||
**Success** — a real return registered on `BK-DEMO-RETURN`, delivered through the actual
|
||||
n8n instance (not mocked), confirmed via `GET /api/v1/workflows`:
|
||||
|
||||
```json
|
||||
{"event_id": "aa5dfeee-90ca-452a-bdd1-0a0b6d3dd63f", "event_type": "vehicle.returned.v1", "aggregate_ref": "BK-DEMO-RETURN", "status": "succeeded", "attempts": 2, "last_error": null}
|
||||
```
|
||||
|
||||
(`attempts: 2` because the first delivery attempt landed while n8n was mid-restart from
|
||||
the one-time workflow-activation step — the dispatcher's backoff-and-retry handled it
|
||||
without any manual intervention, which is itself evidence of the retry behavior working.)
|
||||
|
||||
**Retry (S5 scenario)** — seeded `BK-H-0020` (event `00000000-...-0020`), initially
|
||||
`failed` after 3 attempts with `"Synthetic connection timeout to n8n"`:
|
||||
|
||||
1. Before: `{"status": "failed", "attempts": 3, "last_error": "Synthetic connection timeout to n8n"}`
|
||||
2. Operations Manager clicks Retry on `/automation`.
|
||||
3. Within one ~3s dispatcher poll cycle, delivered through the live n8n instance.
|
||||
4. After: `{"status": "succeeded", "attempts": 4, "last_error": null}`
|
||||
|
||||
## MCP tool sample calls
|
||||
|
||||
All four provider endpoints, authenticated with `X-Service-Token`:
|
||||
|
||||
```
|
||||
$ curl -H "X-Service-Token: <token>" http://localhost:8128/api/v1/integrations/mcp/operations-summary
|
||||
{"tenant":"northstar-mobility-demo","metrics":{"available":21,"rented":11,"cleaning":6,"maintenance":5,"blocked":7,"open_quality_issues":22,"pending_or_failed_workflows":1}}
|
||||
|
||||
$ curl -H "X-Service-Token: <token>" "http://localhost:8128/api/v1/integrations/mcp/attention-vehicles?minimum_severity=high&limit=3"
|
||||
[{"vehicle_ref":"MO-016","severity":"high","rule_type":"booking_overlap",...},
|
||||
{"vehicle_ref":"MO-016","severity":"high","rule_type":"vehicle_status_conflict",...},
|
||||
{"vehicle_ref":"MO-031","severity":"high","rule_type":"missing_required_field",...}]
|
||||
|
||||
$ curl -H "X-Service-Token: <token>" http://localhost:8128/api/v1/integrations/mcp/vehicles/MO-016
|
||||
{"public_ref":"MO-016","make":"Hymer","model":"Exsis","model_year":2021,"location":"Geel","operational_status":"available","odometer_km":30497,"next_service_km":40000,"open_quality_issue_count":2,"current_booking_ref":null}
|
||||
|
||||
$ curl -H "X-Service-Token: <token>" -X POST -d '{"question":"What must I do when a vehicle returns with damage?","max_sources":2}' http://localhost:8128/api/v1/integrations/mcp/search-knowledge
|
||||
{"answer":"Per \"Vehicle return procedure\" ...","evidence_state":"grounded","sources":[...2 items...],"provider":"demo",...}
|
||||
```
|
||||
|
||||
Auth verified: missing header → `422`; wrong token → `401`. All four calls confirmed
|
||||
recorded in `GET /api/v1/audit?action=mcp_tool_request` with `actor_type: "service"`.
|
||||
|
||||
No live ITWorx MCP Hub instance was reachable in this environment — these are direct
|
||||
calls to MobilityOps's own provider endpoints, not a Hub round trip.
|
||||
|
||||
## Known PoC limitations
|
||||
|
||||
- **RAGcore and ITWorx MCP Hub were never reachable in this build environment.** Both
|
||||
integrations are implemented against best-effort/documented contracts and are
|
||||
unit/contract-tested (including their failure-degradation paths), but neither was
|
||||
verified against a real counterpart service. The demo `KnowledgeProvider` is what
|
||||
actually satisfies the knowledge-assistant acceptance criteria here.
|
||||
- **n8n requires a one-time manual owner-account setup** per fresh environment
|
||||
(`docker compose down -v` wipes it) — this is a property of the n8n 2.x image itself
|
||||
(`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass.
|
||||
Documented precisely in `docs/17-runbook.md`; the workflow import/activation itself
|
||||
*is* scripted (`make n8n-setup`).
|
||||
- **Inspection public refs are a simple `count+1` sequence**, not gap-safe under true
|
||||
concurrent writers — acceptable for this single-tenant demo, would need a DB sequence
|
||||
for a multi-writer production system.
|
||||
- **The five data-quality rules use simplified idempotency** — `(rule_type, entity_type,
|
||||
entity_id)` while open, rather than the doc's literal evidence-fingerprint scheme — see
|
||||
`PROJECT_STATE.md`'s M3 notes for the reasoning (the fingerprint scheme would have let
|
||||
the scan double-report issues already present in the seeded CSV).
|
||||
- **No production authentication** — demo login is an HMAC-signed session cookie tied to
|
||||
two fixed seeded users, appropriate for a PoC, not a real identity provider.
|
||||
|
||||
## Portfolio wording (truthful)
|
||||
|
||||
MobilityOps is a working proof of concept, not a production system and not deployed for
|
||||
any real company. All customers, vehicles, bookings, and documents are synthetic
|
||||
(deterministically generated). The application logic it demonstrates is real: a
|
||||
transactional vehicle-return workflow with idempotency and concurrency control tested
|
||||
against real concurrent database transactions; five explainable, deterministic
|
||||
data-quality rules with a working customer-merge UI; a background outbox dispatcher
|
||||
verified end-to-end against a real n8n instance including failure/retry; a
|
||||
TF-IDF-weighted extractive knowledge assistant that never fabricates answers; and four
|
||||
read-only, audited, service-authenticated integration endpoints. RAGcore and the ITWorx
|
||||
MCP Hub integrations are implemented and tested in isolation but were not verified
|
||||
against live instances of those systems in this environment.
|
||||
This is a completed, production-shaped public demo—not claimed customer adoption and not a general ERP. Accounting, payments, public reservations, CRM, inventory, HR, a second RAG stack, a separate MCP server and autonomous write agents remain intentionally excluded. A real personal-data rollout would additionally require the adopting organisation's identity provider, retention approvals, secrets lifecycle, alert ownership and disaster-recovery governance.
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
After Width: | Height: | Size: 194 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -1,265 +1,51 @@
|
||||
# MobilityOps — final acceptance audit summary
|
||||
# Fleet Ops release acceptance
|
||||
|
||||
This audit was run after M0–M7 had already been implemented and committed, specifically
|
||||
to independently re-verify the finished system end to end rather than trust the
|
||||
milestone-by-milestone build log. It found and fixed one real category of defect
|
||||
(`mypy` had never been run across the whole build) and confirmed everything else — every
|
||||
user journey, every button/filter/form, both external-dependency degraded modes, secret
|
||||
hygiene, and the clean-checkout path — works as documented.
|
||||
This file is release-scoped evidence, not a timeless claim. Older evidence under
|
||||
`artifacts/evidence/` is historical. Exact commands and production revisions are recorded
|
||||
in `PROJECT_STATE.md`.
|
||||
|
||||
## Final commit
|
||||
## 2026-08-21 release candidate
|
||||
|
||||
This audit's fixes are committed as the commit immediately following
|
||||
`108b5d04fc6f7c5ff9c47009032d6469df29cf3c` ("M7: portfolio polish and final acceptance").
|
||||
Run `git log -1 --format="%H %s"` for the exact hash.
|
||||
- Backend: **271/271** tests passed against an isolated clean PostgreSQL database.
|
||||
- Browser acceptance: **155/155** Chromium tests passed in 4.6 minutes.
|
||||
- Live-safe browser canary: **4/4** passed across Chromium and Firefox against the local
|
||||
deployed stack; unlike the acceptance suite, it never resets or mutates demo records.
|
||||
- Accessibility: the principal login, dashboard, data-quality, knowledge, automation and
|
||||
audit routes have no automated critical/serious WCAG 2 A/AA/2.1 AA violations.
|
||||
- Frontend: TypeScript, ESLint, production build, dependency audit and per-asset JS/CSS
|
||||
budgets passed; committed visual baselines cover the public entry and engineering story.
|
||||
- Contracts: committed OpenAPI, event schema, MCP tools and all five n8n definitions match
|
||||
their code/manifest sources.
|
||||
- Recovery: a custom-format PostgreSQL dump was restored into a disposable database; the
|
||||
Alembic revision and non-zero canonical table counts matched the source database.
|
||||
- Operations: Prometheus/Alertmanager configuration validation passed, including the
|
||||
watchdog and authenticated n8n receiver route.
|
||||
|
||||
## Exact commands executed
|
||||
## 2026-08-21 production verification
|
||||
|
||||
Clean-checkout drill (run twice during this audit, most recently against fully wiped
|
||||
Docker volumes):
|
||||
- Immutable application revision `95c91797fa2c599443d69d9c96d83a85ee0711f7` was promoted
|
||||
from a checksum-verified source archive after a fresh production backup.
|
||||
- Source revision and both OCI revision labels matched. Public readiness was green,
|
||||
Alembic was at head, all health-gated services were healthy and persisted demo data was
|
||||
retained without a deployment reset.
|
||||
- Trivy found zero fixed HIGH/CRITICAL vulnerabilities in each exact production image.
|
||||
- Prometheus successfully scraped the bearer-protected API target, Alertmanager carried
|
||||
the active delivery watchdog, and the authenticated n8n alert receiver remained active.
|
||||
- The final non-destructive HTTPS canary passed **4/4** across Chromium and Firefox,
|
||||
including the core operator routes and a grounded answer from the real knowledge stack.
|
||||
|
||||
```bash
|
||||
git status # working tree clean before starting
|
||||
docker compose down -v # wipe all volumes — genuinely clean state
|
||||
cp .env.example .env
|
||||
docker compose up --build -d # migrations run automatically (backend/entrypoint.sh)
|
||||
docker compose exec api python -m app.cli seed --reset
|
||||
docker compose run --rm api pytest -q
|
||||
docker compose run --rm api ruff check .
|
||||
docker compose run --rm api mypy app
|
||||
cd frontend && npm run build
|
||||
cd frontend && npx playwright test
|
||||
```
|
||||
## Evidence boundary
|
||||
|
||||
n8n one-time setup (owner account via browser at `http://localhost:5678/setup`, then):
|
||||
The complete local suite uses the deterministic provider and an isolated database so it is
|
||||
repeatable and safely destructive. Production verification is deliberately smaller and
|
||||
non-destructive; it verifies the real RAGcore/MCP/n8n health surfaces without resetting the
|
||||
shared demo. A successful local result is never presented as proof that an external service
|
||||
was live. The production subsection is added only after the exact committed release is
|
||||
deployed and observed.
|
||||
|
||||
```bash
|
||||
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json
|
||||
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
|
||||
docker compose restart n8n
|
||||
```
|
||||
## Remaining product boundary
|
||||
|
||||
Degraded-mode drills:
|
||||
|
||||
```bash
|
||||
docker compose stop n8n # then register a return via the API — commits, event stays pending
|
||||
docker compose start n8n # dispatcher self-heals, no manual intervention
|
||||
docker compose run --rm -e KNOWLEDGE_PROVIDER=ragcore -e RAGCORE_BASE_URL=http://ragcore-not-reachable:9999 \
|
||||
api python -c "from app.services.knowledge import get_knowledge_provider; ..."
|
||||
```
|
||||
|
||||
## Test and validation results
|
||||
|
||||
| Check | Command | Result |
|
||||
|---|---|---|
|
||||
| Backend unit/integration tests | `docker compose run --rm api pytest -q` | **66 passed**, 0 failed, 0 skipped |
|
||||
| Backend lint | `docker compose run --rm api ruff check .` | **All checks passed** |
|
||||
| Backend type check | `docker compose run --rm api mypy app` | **Success: no issues found in 44 source files** (found and fixed 43 pre-existing errors this audit — see below) |
|
||||
| Frontend build + typecheck | `cd frontend && npm run build` | Clean (`tsc -b && vite build`, zero errors) |
|
||||
| End-to-end (Playwright) | `cd frontend && npx playwright test` | **12 passed** (`demo.spec.ts` — full 9-step demo script; `interactive-elements.spec.ts` — 11 tests covering every nav item, filter, tab, and role boundary) |
|
||||
| Clean-checkout migrations | `docker compose down -v && docker compose up --build -d` | 11 tables created automatically, `alembic current` → `e7b08389f47f (head)`, zero manual step |
|
||||
| Deterministic seed | `docker compose exec api python -m app.cli seed --reset` | `users:2 customers:180 vehicles:50 bookings:246 inspections:75 maintenance:40 data_quality_issues:26 workflow_runs:20` — identical across every reseed this session |
|
||||
| Secret scan | `git ls-files \| grep -x .env`; `git log --all -p -- '*.env'`; history grep for AWS/private-key/`sk-` patterns | No `.env` ever committed; no secrets found in history |
|
||||
|
||||
### mypy defects found and fixed (the one real gap this audit uncovered)
|
||||
|
||||
`mypy` is a declared dev dependency (`backend/pyproject.toml`) but was never added to any
|
||||
milestone's validation loop — only `ruff` was run throughout M0–M7. Running it cold
|
||||
surfaced 43 errors across 10 files. All were triaged and fixed (not suppressed):
|
||||
|
||||
- **Two genuine defensive-programming gaps**, not just type-annotation issues:
|
||||
- `app/services/returns.py`: the vehicle lookup after acquiring the row lock had no
|
||||
`None` guard; a dangling FK would have crashed with an unhandled 500 instead of a
|
||||
clean `404 VEHICLE_NOT_FOUND`. Fixed.
|
||||
- `app/api/routers/bookings.py`: same pattern in `get_booking` for the customer/vehicle
|
||||
lookups — now returns a clean `500` with a message instead of an `AttributeError`.
|
||||
- `app/api/deps.py` / `app/api/routers/demo.py`: `CurrentUser.role` is validated by
|
||||
Pydantic at runtime already, but `get_current_user` now explicitly checks role
|
||||
membership before construction, turning a would-be unhandled `ValidationError` into a
|
||||
clean `401` for a corrupted/tampered session cookie.
|
||||
- Two instances of reusing one variable name for both a `Vehicle` and a `Customer` across
|
||||
branches (`dashboard.py`, `data_quality.py`) — renamed for clarity, not just to satisfy
|
||||
mypy.
|
||||
- `Booking.__table__.update()` / `Customer.__table__.update()` switched to the idiomatic
|
||||
`sqlalchemy.update(Model)` construct (also fixes the type error).
|
||||
- Remainder: deprecated `conint()` → `Annotated[int, Field(...)]`, a `Sequence` vs `list`
|
||||
`.sort()` call, an `assert`-guarded None-narrow after a `WHERE ... IS NOT NULL` filter
|
||||
mypy can't see through, and a couple of narrowly-scoped `# type: ignore[...]` comments
|
||||
for known SQLAlchemy stub gaps (`Result.rowcount`).
|
||||
|
||||
`make lint` now runs `ruff check .` **and** `mypy app`.
|
||||
|
||||
## Application URLs and ports
|
||||
|
||||
| Service | URL | Notes |
|
||||
|---|---|---|
|
||||
| Web (React SPA) | `http://localhost:1228` | nginx-served static build |
|
||||
| API | `http://localhost:8128` | FastAPI, `/health` for liveness |
|
||||
| API docs | `http://localhost:8128/docs` | auto-generated OpenAPI/Swagger UI |
|
||||
| n8n | `http://localhost:5678` | requires one-time owner setup, see below |
|
||||
| PostgreSQL | `localhost:5432` (container-internal only, no host port published) | |
|
||||
|
||||
## Demo users and access method
|
||||
|
||||
No passwords. Two demo-role buttons on `http://localhost:1228/login`:
|
||||
|
||||
- **Open as Operations Manager** → `USR-OPS`, "Amelie De Ridder". Full access: dashboard,
|
||||
data-quality resolution/merge, automation retry, demo reset, MCP/service-token routes
|
||||
are separate (not user-facing).
|
||||
- **Open as Rental Employee** → `USR-EMP`, "Karim Boujaddaine". Can register returns and
|
||||
browse vehicles/bookings/knowledge; Automation page is visible but shows a
|
||||
role-restricted message instead of the delivery table (enforced both in the UI and by
|
||||
the backend's `require_operations_manager` dependency — verified by
|
||||
`test_retry_requires_operations_manager` and the e2e role-restriction test).
|
||||
|
||||
Session is an HMAC-signed, `HttpOnly` cookie (`app/core/security.py`) — a demo mechanism,
|
||||
not a real identity provider (documented as a known limitation).
|
||||
|
||||
## Implemented functionality
|
||||
|
||||
- Operations dashboard with 100% database-backed metrics, attention items linking to the
|
||||
underlying data-quality issue, "today" departures/returns, and recent automation runs.
|
||||
- Vehicle and booking list/detail pages with working filters (status, attention-only) and
|
||||
a tabbed vehicle detail view (overview/bookings/inspections/maintenance/quality).
|
||||
- Full transactional vehicle-return workflow: row-locked, idempotent by
|
||||
`Idempotency-Key`, canonical-odometer regression handling (never silently lowers the
|
||||
canonical value), vehicle status derivation, two audit events, and a schema-compliant
|
||||
outbox event — verified against real concurrent submissions (1×201 + 2×409).
|
||||
- Invalid-mileage rejection: negative values and non-numeric input both correctly
|
||||
rejected with `422` and a precise Pydantic validation message.
|
||||
- Data Quality Workbench: five deterministic rules (duplicate customer via TF-IDF-style
|
||||
weighted signal scoring, missing required field, odometer regression, booking overlap,
|
||||
vehicle status conflict), issue list/detail/defer/reject, and a two-column
|
||||
duplicate-customer compare-and-merge UI with an inline (non-native-dialog) confirmation
|
||||
step, transactional booking rewiring, and audit logging.
|
||||
- Full audit trail: every significant action (login, return, vehicle status change,
|
||||
data-quality issue lifecycle, customer merge, workflow retry, demo reset, n8n
|
||||
callback, MCP tool request, knowledge question) is recorded with actor, correlation ID,
|
||||
and before/after state; filterable by action.
|
||||
- Knowledge Assistant: deterministic TF-IDF-weighted extractive retrieval over the 10
|
||||
procedure documents — never generative, always cites real excerpts, and honestly
|
||||
reports `insufficient`/`unavailable` states rather than fabricating an answer.
|
||||
- n8n automation: background outbox dispatcher (`FOR UPDATE SKIP LOCKED` claim,
|
||||
exponential backoff, no DB transaction held during the HTTP call), a live-verified
|
||||
round trip through an actual n8n workflow, manual retry for failed deliveries, and an
|
||||
Automation page (Operations Manager only) showing all runs with filtering.
|
||||
- Four read-only, service-token-authenticated MCP Hub provider endpoints, each recording
|
||||
its own service-request audit event, with zero write/mutation endpoints anywhere in
|
||||
that namespace.
|
||||
- Responsive UI verified down to 360px width (nav wraps, tables become cards, metric
|
||||
tiles reflow to a 2-column grid, no horizontal overflow) — both by an automated
|
||||
Playwright viewport/overflow assertion and by a captured screenshot.
|
||||
|
||||
## RAGcore integration status: implemented, not live-verified
|
||||
|
||||
The active `KnowledgeProvider` in this environment is `DemoKnowledgeProvider` — fully
|
||||
implemented, fully tested, fully live-verified, and what actually satisfies the
|
||||
knowledge-assistant acceptance criteria. A `RAGcoreKnowledgeProvider` HTTP adapter also
|
||||
exists (`app/services/knowledge/ragcore.py`), targeting a best-effort contract inferred
|
||||
from `contracts/ragcore-contract-assumptions.md` (no live RAGcore API spec was available).
|
||||
Its **unavailable-degradation path is live-verified this audit**: pointed at an
|
||||
unreachable host, it returns `{"evidence_state": "unavailable", "answer": "", "sources":
|
||||
[]}` with no fabrication, exactly as required — but an actual successful round trip
|
||||
against a real RAGcore instance has never been performed, because no such instance was
|
||||
reachable in this environment.
|
||||
|
||||
## MCP Hub integration status: implemented, not live-verified
|
||||
|
||||
All four contracted read-only tools (`mobilityops_get_operations_summary`,
|
||||
`mobilityops_list_attention_vehicles`, `mobilityops_get_vehicle_details`,
|
||||
`mobilityops_search_knowledge`) are implemented as service-token-protected endpoints under
|
||||
`/api/v1/integrations/mcp/`, directly `curl`-verified this audit (auth enforcement,
|
||||
correct data shape, no write methods, service-request audit logging). No live ITWorx MCP
|
||||
Hub instance was reachable in this environment, so an actual Hub-mediated tool call was
|
||||
never performed — only direct calls to MobilityOps's own provider API.
|
||||
|
||||
## n8n integration status: implemented and fully live-verified
|
||||
|
||||
The only external integration with a real, running counterpart service available in this
|
||||
environment. Fully verified this audit, including both success and degraded paths:
|
||||
|
||||
- **Success**: a real return registered via the API was delivered by the background
|
||||
dispatcher to an actual n8n instance (owner account + imported/activated workflow),
|
||||
which called back into MobilityOps and was recorded `succeeded`.
|
||||
- **Degraded mode**: `docker compose stop n8n`, then a return was registered — it
|
||||
**committed successfully** (`201`, booking `status: returned` persisted) exactly as
|
||||
required by the architecture's reliability boundary ("a return command and its outbox
|
||||
event commit in one transaction" and "n8n failure leaves events pending with bounded
|
||||
retries"). The outbox event stayed `pending` with two real `ConnectError`s logged and
|
||||
exponential backoff.
|
||||
- **Self-healing**: restarting n8n required no manual intervention — the background
|
||||
dispatcher picked the pending event back up on its next poll cycle and delivered it to
|
||||
`succeeded` (5 total attempts across the outage).
|
||||
- **Manual retry (S5 scenario)**: a seeded `failed` delivery, retried from the Automation
|
||||
page, moved to `pending` and was delivered to `succeeded` by the live dispatcher within
|
||||
one poll cycle.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- RAGcore and the ITWorx MCP Hub were never reachable in this build/audit environment;
|
||||
both integrations are implemented and tested against inferred/documented contracts but
|
||||
not verified against real instances of those systems (see above).
|
||||
- n8n requires a one-time, per-fresh-environment manual owner-account setup through its
|
||||
own web UI (`http://localhost:5678/setup`) — a property of the n8n 2.x image itself
|
||||
(`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass.
|
||||
The workflow import/activation itself *is* scripted (`make n8n-setup`).
|
||||
- Demo authentication is an HMAC-signed session cookie tied to two fixed seeded users —
|
||||
appropriate for a PoC, not a production identity provider.
|
||||
- Inspection public references are assigned via a simple `count + 1` sequence, not
|
||||
gap-safe under true concurrent writers (acceptable for this single-tenant demo).
|
||||
- The five data-quality rules use a simplified idempotency key
|
||||
(`rule_type, entity_type, entity_id` while open) rather than the spec's literal
|
||||
evidence-fingerprint scheme — documented rationale in `PROJECT_STATE.md`'s M3 notes.
|
||||
- `npm audit` reports one residual moderate `esbuild`/Vite-8 dev-server-only advisory
|
||||
(fixable only by a Vite major version bump) and one high `react-router` RSC-mode
|
||||
advisory that does not apply to this app (it never uses React Router's RSC/SSR mode).
|
||||
|
||||
## Clean deployment instructions
|
||||
|
||||
```bash
|
||||
git clone <repo> && cd MobilityOps
|
||||
cp .env.example .env
|
||||
make demo # build, start, migrate (automatic), seed
|
||||
```
|
||||
|
||||
One-time n8n setup (only needed for the automation demo path; everything else works
|
||||
without it):
|
||||
|
||||
```bash
|
||||
# open http://localhost:5678/setup in a browser, create any owner account
|
||||
# (8+ chars, 1 number, 1 capital letter — no email verification required)
|
||||
make n8n-setup
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8128/health # {"status":"ok",...}
|
||||
curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200
|
||||
make test # 66 backend tests
|
||||
make lint # ruff + mypy, zero errors
|
||||
make e2e # 12 Playwright tests (stack must be running)
|
||||
```
|
||||
|
||||
Full detail, recovery expectations, and required operational checks: `docs/17-runbook.md`.
|
||||
|
||||
## Five-minute demonstration flow
|
||||
|
||||
1. Open `http://localhost:1228` → **Open as Operations Manager**.
|
||||
2. **Dashboard**: point out the metrics are live counts (available/rented/cleaning/
|
||||
maintenance/blocked vehicles, open quality issues, pending/failed workflows), and the
|
||||
Attention Required list linking straight to the underlying issues.
|
||||
3. **Vehicles → MO-024** → open the active booking `BK-DEMO-RETURN`, register a return
|
||||
with an odometer reading below MO-024's canonical value → the result panel shows the
|
||||
inspection, the derived vehicle status, the automatically-created data-quality issue,
|
||||
and the queued automation event — canonical odometer is confirmed unchanged.
|
||||
4. **Data Quality → DQ-DEMO-DUPLICATE**: the two-column CUS-0012/CUS-0178 comparison,
|
||||
merge with the inline confirmation step, issue flips to `resolved`.
|
||||
5. **Knowledge**: ask "What must I do when a vehicle returns with damage?" → grounded
|
||||
answer citing both the return and damage-handling procedures with real excerpts.
|
||||
6. **Automation**: filter to `failed`, retry the seeded delivery, watch it succeed within
|
||||
a few seconds via the live n8n instance.
|
||||
7. **Audit**: filter by `return_registered` or `customer_merged` to show every action from
|
||||
this walkthrough is recorded with actor, timestamp, and correlation ID.
|
||||
8. Resize the browser to 360px width to show the responsive layout (nav wraps, tables
|
||||
become cards) — or run `make e2e` and point at the passing responsive assertion.
|
||||
Fleet Ops remains a synthetic single-tenant PoC. It is not a production identity provider,
|
||||
payment system, accounting package or public reservation platform. External RAGcore, MCP
|
||||
Hub and n8n services remain independently operated dependencies and are accessed only
|
||||
through their documented adapters.
|
||||
|
||||
@@ -1,16 +1,33 @@
|
||||
FROM python:3.12-slim
|
||||
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.itworx.tech"
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||
WORKDIR /app
|
||||
COPY backend/requirements.lock ./
|
||||
RUN pip install --no-cache-dir -r requirements.lock
|
||||
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 backend/tests ./tests
|
||||
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
|
||||
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
|
||||
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"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
@@ -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,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,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,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")
|
||||
@@ -1,6 +1,9 @@
|
||||
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
|
||||
@@ -8,7 +11,9 @@ 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]
|
||||
@@ -22,13 +27,32 @@ def get_db() -> Generator[Session, None, None]:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_current_user(request: Request) -> CurrentUser:
|
||||
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
|
||||
if payload is None or payload.role not in _VALID_ROLES:
|
||||
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")
|
||||
role: Role = payload.role # type: ignore[assignment]
|
||||
return CurrentUser(public_ref=payload.public_ref, display_name=payload.display_name, role=role)
|
||||
# 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(
|
||||
@@ -41,12 +65,30 @@ def require_operations_manager(
|
||||
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(default="unknown-mcp-client", alias="X-Client-Id"),
|
||||
) -> str:
|
||||
if x_service_token != settings.mcp_hub_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"
|
||||
)
|
||||
return x_client_id
|
||||
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)
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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
|
||||
@@ -37,6 +44,81 @@ _ROUTE_TEMPLATES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
@@ -58,7 +140,7 @@ def list_audit_events(
|
||||
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: str | None = Query(default=None),
|
||||
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),
|
||||
@@ -77,14 +159,14 @@ def list_audit_events(
|
||||
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()
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -1,20 +1,35 @@
|
||||
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 select
|
||||
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.returns import preview_vehicle_return, register_vehicle_return
|
||||
|
||||
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
|
||||
@@ -35,25 +50,292 @@ def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOu
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[BookingOut])
|
||||
@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]:
|
||||
stmt = select(Booking).order_by(Booking.starts_at.desc())
|
||||
) -> 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:
|
||||
return []
|
||||
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)
|
||||
bookings = db.scalars(stmt).all()
|
||||
customers = {c.id: c for c in db.scalars(select(Customer)).all()}
|
||||
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
|
||||
return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
|
||||
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))
|
||||
if customer is None or customer.merged_into_customer_id 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_complete=body.requirements_complete,
|
||||
)
|
||||
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")
|
||||
|
||||
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 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,
|
||||
after={
|
||||
"inspection_ref": inspection.public_ref,
|
||||
"booking_status": booking.status,
|
||||
"vehicle_status": vehicle.operational_status,
|
||||
"attention_reasons": attention_reasons,
|
||||
},
|
||||
)
|
||||
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)
|
||||
@@ -72,6 +354,126 @@ def get_booking(
|
||||
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,
|
||||
@@ -101,7 +503,7 @@ def preview_return(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_ref}/return")
|
||||
@router.post("/{public_ref}/return", response_model=RegisterReturnResult)
|
||||
def register_return(
|
||||
public_ref: str,
|
||||
body: RegisterReturnRequest,
|
||||
@@ -109,7 +511,7 @@ def register_return(
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128),
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
) -> RegisterReturnResult:
|
||||
status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user)
|
||||
response.status_code = status_code
|
||||
return result
|
||||
return RegisterReturnResult(**result)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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),
|
||||
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
|
||||
]
|
||||
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import date, datetime
|
||||
from typing import Literal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
@@ -30,10 +31,20 @@ 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.
|
||||
return datetime.now(UTC).date()
|
||||
# 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)
|
||||
@@ -97,25 +108,27 @@ def get_dashboard(
|
||||
for b in bookings:
|
||||
vehicle = vehicles_by_id.get(b.vehicle_id)
|
||||
vehicle_ref = vehicle.public_ref if vehicle else ""
|
||||
if b.starts_at.date() == today and b.status in ("reserved", "active"):
|
||||
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,
|
||||
kind="departure",
|
||||
booking_ref=b.public_ref,
|
||||
vehicle_ref=vehicle_ref,
|
||||
scheduled_at=b.starts_at,
|
||||
)
|
||||
)
|
||||
if b.ends_at.date() == today and b.status in ("active", "returned"):
|
||||
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,
|
||||
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 = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all()
|
||||
recent_automation = [
|
||||
AutomationRunOut(
|
||||
event_id=str(r.event_id),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_operations_manager
|
||||
@@ -9,10 +11,13 @@ 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.user import User
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas import (
|
||||
ApplyRecommendedStatusRequest,
|
||||
ApplyRecommendedStatusResult,
|
||||
BulkDataQualityWorkRequest,
|
||||
BulkDataQualityWorkResult,
|
||||
CurrentUser,
|
||||
DataQualityIssueDetailOut,
|
||||
DataQualityIssueOut,
|
||||
@@ -26,6 +31,7 @@ from app.schemas import (
|
||||
StatusRecommendationOut,
|
||||
VehicleStatusFactsOut,
|
||||
)
|
||||
from app.services.audit import record_audit_event
|
||||
from app.services.data_quality import (
|
||||
apply_recommended_status,
|
||||
defer_issue,
|
||||
@@ -42,6 +48,7 @@ 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,
|
||||
@@ -51,6 +58,12 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -60,18 +73,45 @@ 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:
|
||||
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
|
||||
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(
|
||||
@@ -90,6 +130,90 @@ def list_issues(
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
@@ -239,9 +363,7 @@ def provide_fields(
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
|
||||
)
|
||||
@router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
|
||||
def resolve_odometer(
|
||||
public_ref: str,
|
||||
body: ResolveOdometerRegressionRequest,
|
||||
@@ -263,9 +385,7 @@ def resolve_overlap(
|
||||
return _to_out(issue)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
|
||||
)
|
||||
@router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
|
||||
def status_recommendation(
|
||||
public_ref: str,
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user, get_db, require_operations_manager
|
||||
@@ -15,13 +16,19 @@ 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()
|
||||
_last_reset_monotonic = 0.0
|
||||
_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.
|
||||
@@ -32,6 +39,8 @@ def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
|
||||
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:
|
||||
@@ -44,6 +53,7 @@ def demo_login(
|
||||
role=user.role,
|
||||
display_name=user.display_name,
|
||||
issued_at=int(time.time()),
|
||||
session_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
response.set_cookie(
|
||||
@@ -68,9 +78,7 @@ def demo_login(
|
||||
|
||||
|
||||
@router.get("/session", response_model=CurrentUser)
|
||||
def get_session(
|
||||
response: Response, user: CurrentUser = Depends(get_current_user)
|
||||
) -> 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"
|
||||
@@ -81,7 +89,8 @@ def get_session(
|
||||
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:
|
||||
if payload is not None and token is not None:
|
||||
revoke_session(db, token, payload)
|
||||
record_audit_event(
|
||||
db,
|
||||
actor_type="user",
|
||||
@@ -101,26 +110,48 @@ def demo_reset(
|
||||
db: Session = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_operations_manager),
|
||||
) -> dict:
|
||||
global _last_reset_monotonic
|
||||
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.",
|
||||
)
|
||||
result = reset_and_seed(db)
|
||||
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()
|
||||
if not _reset_guard.acquire(blocking=False):
|
||||
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||
try:
|
||||
elapsed = time.monotonic() - _last_reset_monotonic
|
||||
if _last_reset_monotonic 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)},
|
||||
)
|
||||
locked = db.scalar(select(func.pg_try_advisory_xact_lock(_RESET_ADVISORY_LOCK_ID)))
|
||||
if not locked:
|
||||
raise HTTPException(status_code=409, detail="A demo reset is already running.")
|
||||
result = reset_and_seed(db, preserve_integration_telemetry=True)
|
||||
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()
|
||||
_last_reset_monotonic = time.monotonic()
|
||||
finally:
|
||||
_reset_guard.release()
|
||||
response.delete_cookie(settings.session_cookie_name)
|
||||
return {
|
||||
"status": "reset",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy import select
|
||||
@@ -15,10 +15,13 @@ 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,
|
||||
@@ -30,16 +33,74 @@ 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)
|
||||
|
||||
|
||||
@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)
|
||||
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: dict[str, Any],
|
||||
body: ReturnCallbackIn,
|
||||
idempotency_key: str = Header(..., alias="Idempotency-Key"),
|
||||
service_token: str = Header(..., alias="X-Service-Token"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
try:
|
||||
event_id = uuid.UUID(idempotency_key)
|
||||
@@ -70,10 +131,8 @@ def return_callback(
|
||||
actor_label="n8n",
|
||||
action="n8n_return_followup_recorded",
|
||||
entity_type="booking",
|
||||
correlation_id=uuid.UUID(body.get("correlation_id"))
|
||||
if body.get("correlation_id")
|
||||
else None,
|
||||
after={"follow_up": body.get("follow_up"), "summary": body.get("summary")},
|
||||
correlation_id=body.correlation_id,
|
||||
after={"follow_up": body.follow_up, "summary": body.summary},
|
||||
metadata={"event_id": str(event_id)},
|
||||
)
|
||||
db.commit()
|
||||
@@ -94,8 +153,7 @@ def scheduled_scan(
|
||||
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."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
|
||||
return ScanResultOut(created=result.created)
|
||||
@@ -111,8 +169,7 @@ def workflow_error(
|
||||
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."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
@@ -164,8 +221,7 @@ def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -
|
||||
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."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
documents = [
|
||||
ProcedureDocumentOut(
|
||||
@@ -191,8 +247,7 @@ def procedures_sync_result(
|
||||
"""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."""
|
||||
if service_token != settings.n8n_callback_token:
|
||||
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
|
||||
_require_service_token(service_token)
|
||||
|
||||
already_recorded = (
|
||||
db.scalar(
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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"]
|
||||
|
||||
@@ -22,12 +36,37 @@ class AskQuestionRequest(BaseModel):
|
||||
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)
|
||||
@@ -51,9 +90,78 @@ def ask_question(
|
||||
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:
|
||||
return get_knowledge_provider().health(language)
|
||||
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"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,11 +3,11 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query
|
||||
from fastapi import APIRouter, Depends, Header, Query, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_db, require_mcp_service_token
|
||||
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
|
||||
@@ -42,52 +42,76 @@ def get_correlation_id(
|
||||
|
||||
|
||||
def _audit_service_request(
|
||||
db: Session, *, client_id: str, tool: str, status_label: str, correlation_id: str
|
||||
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",
|
||||
actor_label=client_id,
|
||||
# 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},
|
||||
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_id: str = Depends(require_mcp_service_token),
|
||||
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,
|
||||
client_id=client_id,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_get_operations_summary",
|
||||
status_label="ok",
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics)
|
||||
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_id: str = Depends(require_mcp_service_token),
|
||||
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,
|
||||
client_id=client_id,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_list_attention_vehicles",
|
||||
status_label="ok",
|
||||
correlation_id=correlation_id,
|
||||
@@ -98,15 +122,17 @@ def attention_vehicles(
|
||||
@router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut)
|
||||
def vehicle_details(
|
||||
vehicle_ref: str,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
client_id: str = Depends(require_mcp_service_token),
|
||||
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,
|
||||
client_id=client_id,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_get_vehicle_details",
|
||||
status_label="not_found",
|
||||
correlation_id=correlation_id,
|
||||
@@ -128,7 +154,7 @@ def vehicle_details(
|
||||
|
||||
_audit_service_request(
|
||||
db,
|
||||
client_id=client_id,
|
||||
reported_client_id=client.reported_client_id,
|
||||
tool="fleet_ops_get_vehicle_details",
|
||||
status_label="ok",
|
||||
correlation_id=correlation_id,
|
||||
@@ -150,18 +176,29 @@ def vehicle_details(
|
||||
@router.post("/search-knowledge", response_model=GroundedAnswer)
|
||||
def search_knowledge(
|
||||
body: McpKnowledgeSearchRequest,
|
||||
response: Response,
|
||||
db: Session = Depends(get_db),
|
||||
client_id: str = Depends(require_mcp_service_token),
|
||||
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,
|
||||
client_id=client_id,
|
||||
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",
|
||||
)
|
||||
@@ -89,6 +89,12 @@ _SECTIONS: list[dict] = [
|
||||
"terms": ["audit", "history", "geschiedenis", "historique"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
{
|
||||
"id": "privacy",
|
||||
"link": "/privacy",
|
||||
"terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"],
|
||||
"role": "operations_manager",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -143,7 +149,10 @@ def search(
|
||||
)
|
||||
|
||||
for b in db.scalars(
|
||||
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
|
||||
select(Booking)
|
||||
.where(Booking.public_ref.ilike(like))
|
||||
.order_by(Booking.starts_at.desc())
|
||||
.limit(5)
|
||||
).all():
|
||||
results.append(
|
||||
SearchResultItem(
|
||||
|
||||
@@ -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)
|
||||
@@ -1,10 +1,13 @@
|
||||
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
|
||||
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
|
||||
@@ -13,14 +16,17 @@ 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
|
||||
|
||||
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
|
||||
|
||||
@@ -39,6 +45,7 @@ def _attention_vehicle_ids(db: Session) -> set:
|
||||
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),
|
||||
@@ -48,6 +55,8 @@ def list_vehicles(
|
||||
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(
|
||||
@@ -62,13 +71,30 @@ def list_vehicles(
|
||||
attention_ids = _attention_vehicle_ids(db)
|
||||
if attention_only:
|
||||
stmt = stmt.where(
|
||||
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
|
||||
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,
|
||||
@@ -81,7 +107,23 @@ def list_vehicles(
|
||||
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",
|
||||
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
|
||||
]
|
||||
@@ -128,6 +170,14 @@ def get_vehicle(
|
||||
).all()
|
||||
|
||||
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(
|
||||
@@ -141,7 +191,23 @@ def get_vehicle(
|
||||
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",
|
||||
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,
|
||||
@@ -187,8 +253,121 @@ def get_vehicle(
|
||||
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")
|
||||
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)
|
||||
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,
|
||||
after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status},
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -23,6 +23,11 @@ class Settings(BaseSettings):
|
||||
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"
|
||||
@@ -48,8 +53,72 @@ class Settings(BaseSettings):
|
||||
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:
|
||||
return 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,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
|
||||
@@ -4,6 +4,7 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -19,6 +20,7 @@ class SessionPayload:
|
||||
role: str
|
||||
display_name: str
|
||||
issued_at: int
|
||||
session_id: str = ""
|
||||
|
||||
|
||||
def _sign(data: bytes) -> str:
|
||||
@@ -26,6 +28,11 @@ def _sign(data: bytes) -> str:
|
||||
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("=")
|
||||
@@ -50,3 +57,31 @@ def read_session_token(token: str) -> SessionPayload | None:
|
||||
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
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
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,
|
||||
@@ -15,25 +20,92 @@ from app.api.routers import (
|
||||
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()
|
||||
|
||||
|
||||
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
|
||||
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,
|
||||
@@ -45,23 +117,29 @@ app.add_middleware(
|
||||
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
|
||||
def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=error_body(exc.code, exc.message, exc.correlation_id, exc.details),
|
||||
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:
|
||||
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=str(uuid.uuid4()),
|
||||
correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())),
|
||||
details={},
|
||||
),
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +148,28 @@ 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 {
|
||||
@@ -77,13 +177,17 @@ def system_status() -> dict[str, object]:
|
||||
"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)
|
||||
@@ -92,3 +196,6 @@ 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)
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -20,6 +21,7 @@ __all__ = [
|
||||
"Inspection",
|
||||
"MaintenanceRecord",
|
||||
"OutboxEvent",
|
||||
"RevokedSession",
|
||||
"User",
|
||||
"Vehicle",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy import CheckConstraint, DateTime, Index, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -13,6 +13,11 @@ 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))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
|
||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -13,6 +13,22 @@ 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(
|
||||
@@ -26,4 +42,4 @@ class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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=True)
|
||||
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, String
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, String
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -23,3 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
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",
|
||||
@@ -21,6 +25,27 @@ 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)
|
||||
@@ -31,5 +56,10 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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))
|
||||
|
||||
@@ -15,5 +15,8 @@ class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
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)
|
||||
|
||||
@@ -7,9 +7,7 @@ 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
|
||||
)
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy import CheckConstraint, DateTime, Index, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -32,6 +32,14 @@ def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, String
|
||||
from sqlalchemy import Boolean, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
@@ -9,8 +9,15 @@ 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Boolean, Integer, String
|
||||
from sqlalchemy import Boolean, CheckConstraint, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
@@ -9,6 +9,16 @@ OPERATIONAL_STATUSES = ("available", "rented", "cleaning", "maintenance", "block
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
Role = Literal["operations_manager", "rental_employee"]
|
||||
|
||||
@@ -12,6 +13,33 @@ 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
|
||||
@@ -30,6 +58,10 @@ class VehicleOut(BaseModel):
|
||||
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):
|
||||
@@ -56,6 +88,70 @@ class BookingOut(BookingSummaryOut):
|
||||
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
|
||||
requirements_complete: bool = False
|
||||
|
||||
|
||||
class CompleteBookingRequirementsRequest(BaseModel):
|
||||
confirmation: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
class RescheduleBookingRequest(BaseModel):
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
reason: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
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: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
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)]
|
||||
@@ -79,6 +175,7 @@ class RegisterReturnResult(BaseModel):
|
||||
odometer_regression: bool
|
||||
quality_issue_ref: str | None
|
||||
workflow_event_id: str
|
||||
correlation_id: str
|
||||
next_booking_risk: NextBookingRisk | None
|
||||
|
||||
|
||||
@@ -118,6 +215,19 @@ class MaintenanceOut(BaseModel):
|
||||
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: str = Field(min_length=3, max_length=500)
|
||||
|
||||
|
||||
class DataQualityIssueOut(BaseModel):
|
||||
public_ref: str
|
||||
rule_type: str
|
||||
@@ -127,6 +237,10 @@ class DataQualityIssueOut(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
@@ -143,6 +257,18 @@ class DataQualityIssueDetailOut(DataQualityIssueOut):
|
||||
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
|
||||
@@ -265,10 +391,64 @@ class SearchResponse(BaseModel):
|
||||
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")
|
||||
|
||||
correlation_id: uuid.UUID | None = None
|
||||
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):
|
||||
|
||||
@@ -80,9 +80,20 @@ def _read_csv(name: str) -> list[dict[str, str]]:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def clear_all(db: Session) -> None:
|
||||
_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 (
|
||||
AuditEvent,
|
||||
OutboxEvent,
|
||||
IdempotencyRecord,
|
||||
DataQualityIssue,
|
||||
@@ -94,6 +105,12 @@ def clear_all(db: Session) -> None:
|
||||
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:
|
||||
@@ -101,9 +118,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
today = datetime.now(UTC).date()
|
||||
shift = _seed_anchor_shift(today)
|
||||
|
||||
user_rows = [
|
||||
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
|
||||
]
|
||||
user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS]
|
||||
db.execute(insert(User), user_rows)
|
||||
counts["users"] = len(user_rows)
|
||||
|
||||
@@ -339,6 +354,11 @@ def load_seed(db: Session) -> SeedResult:
|
||||
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 []
|
||||
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(),
|
||||
@@ -356,6 +376,7 @@ def load_seed(db: Session) -> SeedResult:
|
||||
},
|
||||
"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,
|
||||
}
|
||||
@@ -428,10 +449,10 @@ def load_seed(db: Session) -> SeedResult:
|
||||
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
|
||||
|
||||
|
||||
def reset_and_seed(db: Session) -> SeedResult:
|
||||
def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
|
||||
from app.services.data_quality import run_scan
|
||||
|
||||
clear_all(db)
|
||||
clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
|
||||
result = load_seed(db)
|
||||
db.commit()
|
||||
scan = run_scan(db)
|
||||
|
||||
@@ -2,10 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.errors import AppError
|
||||
@@ -26,6 +26,16 @@ from app.services.vehicle_status import (
|
||||
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
||||
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||
DUPLICATE_THRESHOLD = 70
|
||||
DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -54,15 +64,9 @@ def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uu
|
||||
)
|
||||
|
||||
|
||||
def _next_public_ref(db: Session, prefix: str) -> str:
|
||||
existing = db.execute(select(DataQualityIssue.public_ref)).scalars().all()
|
||||
numbers = [
|
||||
int(ref.rsplit("-", 1)[-1])
|
||||
for ref in existing
|
||||
if ref.startswith(f"{prefix}-") and ref.rsplit("-", 1)[-1].isdigit()
|
||||
]
|
||||
next_number = (max(numbers) + 1) if numbers else 1
|
||||
return f"{prefix}-{next_number:04d}"
|
||||
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 _open_issue(
|
||||
@@ -109,7 +113,7 @@ def _open_issue(
|
||||
evidence["previous_decision"] = previous.status
|
||||
|
||||
issue = DataQualityIssue(
|
||||
public_ref=_next_public_ref(db, "DQ-SCAN"),
|
||||
public_ref=_new_scan_ref("DQ-SCAN"),
|
||||
rule_type=rule_type,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
@@ -118,6 +122,7 @@ def _open_issue(
|
||||
evidence_json=evidence,
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
due_at=issue_due_at(now, severity),
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
@@ -126,55 +131,77 @@ def _open_issue(
|
||||
|
||||
def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
||||
customers = list(
|
||||
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
|
||||
db.scalars(
|
||||
select(Customer).where(
|
||||
Customer.merged_into_customer_id.is_(None),
|
||||
Customer.anonymized_at.is_(None),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
customers.sort(key=lambda c: c.public_ref)
|
||||
# The threshold cannot be reached without an exact email (60 points) or phone
|
||||
# (50 points). Block on those normalized identifiers first, so similarity scoring
|
||||
# scales with plausible candidates instead of comparing every customer pair.
|
||||
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 i, a in enumerate(customers):
|
||||
for b in customers[i + 1 :]:
|
||||
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")
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
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:
|
||||
@@ -286,9 +313,7 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
|
||||
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
|
||||
for booking in db.scalars(
|
||||
select(Booking).where(
|
||||
Booking.status == "returned", Booking.end_odometer_km.is_not(None)
|
||||
)
|
||||
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
|
||||
).all():
|
||||
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
|
||||
|
||||
@@ -332,6 +357,9 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||
def run_scan(
|
||||
db: Session, *, actor_label: str | None = None, actor_type: str = "user"
|
||||
) -> 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)
|
||||
_scan_missing_required_fields(db, scan)
|
||||
@@ -351,10 +379,13 @@ def run_scan(
|
||||
return scan
|
||||
|
||||
|
||||
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||
issue = db.scalar(
|
||||
select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
|
||||
)
|
||||
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()
|
||||
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":
|
||||
@@ -505,6 +536,30 @@ def resolve_odometer_regression(
|
||||
"This issue is not an odometer_regression issue.",
|
||||
status_code=409,
|
||||
)
|
||||
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
|
||||
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
|
||||
booking: Booking | None = None
|
||||
if body.decision != "retain_canonical":
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if body.booking_ref not in related_refs:
|
||||
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.entity_id).with_for_update())
|
||||
if vehicle is None:
|
||||
raise AppError(
|
||||
@@ -525,19 +580,7 @@ def resolve_odometer_regression(
|
||||
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
|
||||
)
|
||||
else:
|
||||
related_refs = issue.evidence_json.get("related_refs", [])
|
||||
if body.booking_ref not in related_refs:
|
||||
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,
|
||||
)
|
||||
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:
|
||||
@@ -549,13 +592,6 @@ def resolve_odometer_regression(
|
||||
),
|
||||
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
|
||||
)
|
||||
|
||||
before = {
|
||||
"booking_end_odometer_km": booking.end_odometer_km,
|
||||
@@ -680,8 +716,10 @@ def resolve_booking_overlap(
|
||||
return issue
|
||||
|
||||
|
||||
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||
issue = _load_open_issue(db, public_ref)
|
||||
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",
|
||||
@@ -697,7 +735,7 @@ def preview_vehicle_status_recommendation(
|
||||
"""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)
|
||||
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(
|
||||
@@ -756,9 +794,7 @@ def apply_recommended_status(
|
||||
# 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,
|
||||
):
|
||||
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.",
|
||||
@@ -798,6 +834,16 @@ def apply_recommended_status(
|
||||
|
||||
|
||||
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(
|
||||
@@ -827,12 +873,26 @@ def merge_customers(
|
||||
)
|
||||
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
|
||||
|
||||
survivor = db.scalar(select(Customer).where(Customer.public_ref == survivor_ref))
|
||||
loser = db.scalar(select(Customer).where(Customer.public_ref == loser_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},
|
||||
@@ -844,7 +904,15 @@ def merge_customers(
|
||||
raise AppError(
|
||||
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422
|
||||
)
|
||||
setattr(survivor, field_name, value)
|
||||
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)
|
||||
|
||||
@@ -39,18 +39,14 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
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)
|
||||
)
|
||||
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
|
||||
)
|
||||
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 failed_run.delivery_status == "failed")
|
||||
@@ -65,7 +61,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if return_ready
|
||||
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed"
|
||||
else "bookingNotFound"
|
||||
if booking is None
|
||||
else "bookingAlreadyProcessed"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -81,7 +79,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if duplicate_ready
|
||||
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved"
|
||||
else "duplicateIssueNotFound"
|
||||
if duplicate_issue is None
|
||||
else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -95,7 +95,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if overlap_ready
|
||||
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved"
|
||||
else "overlapIssueNotFound"
|
||||
if overlap_issue is None
|
||||
else "issueAlreadyResolved"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -107,7 +109,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
|
||||
blocked_reason_code=(
|
||||
None
|
||||
if automation_ready
|
||||
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered"
|
||||
else "failedEventNotFound"
|
||||
if failed_run is None
|
||||
else "eventAlreadyRecovered"
|
||||
),
|
||||
),
|
||||
DemoScenarioOut(
|
||||
@@ -139,14 +143,21 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||
),
|
||||
DemoIntegrationSummaryOut(
|
||||
key="ragcore",
|
||||
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode",
|
||||
detail_code="ragcoreDetail",
|
||||
status_code=(
|
||||
"operational"
|
||||
if knowledge_health.provider == "ragcore" and knowledge_health.available
|
||||
else "demoMode"
|
||||
if knowledge_health.provider == "demo" and knowledge_health.available
|
||||
else "unavailable"
|
||||
),
|
||||
detail_code=(
|
||||
"ragcoreDetail"
|
||||
if knowledge_health.document_count is not None
|
||||
else "ragcoreSourceDetail"
|
||||
),
|
||||
detail_params={
|
||||
"count": (
|
||||
knowledge_health.document_count
|
||||
if knowledge_health.document_count is not None
|
||||
else "unknown"
|
||||
),
|
||||
"count": knowledge_health.document_count or 0,
|
||||
"source_count": knowledge_health.source_document_count,
|
||||
"collection": knowledge_health.collection,
|
||||
},
|
||||
),
|
||||
@@ -158,7 +169,9 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
|
||||
# "operational" -- same evidence rule the integration status page uses.
|
||||
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
|
||||
detail_code=(
|
||||
"mcpDetailEnabled" if mcp_hub.state == "operational" else "mcpDetailNotConnected"
|
||||
"mcpDetailOperational"
|
||||
if mcp_hub.state == "operational"
|
||||
else "mcpDetailPrepared"
|
||||
),
|
||||
detail_params={},
|
||||
),
|
||||
@@ -172,9 +185,7 @@ def scenario_integrity_report(db: Session) -> dict:
|
||||
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
|
||||
{"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}
|
||||
|
||||
|
||||
@@ -141,8 +141,7 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
||||
# succeeded.
|
||||
success = False
|
||||
error = (
|
||||
"Unexpected non-JSON-object response from n8n "
|
||||
f"(status {response.status_code})"
|
||||
f"Unexpected non-JSON-object response from n8n (status {response.status_code})"
|
||||
)
|
||||
error_code = "malformedResponse"
|
||||
except httpx.HTTPError as exc:
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
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 func, select
|
||||
from sqlalchemy import Row, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -17,6 +21,9 @@ from app.schemas import (
|
||||
)
|
||||
|
||||
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).
|
||||
@@ -26,6 +33,26 @@ _CANONICAL_WORKFLOWS = (
|
||||
"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:
|
||||
@@ -102,9 +129,7 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
# 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_at = db.scalar(
|
||||
select(func.max(AuditEvent.occurred_at)).where(
|
||||
AuditEvent.action == "n8n_procedures_synced"
|
||||
)
|
||||
select(func.max(AuditEvent.occurred_at)).where(AuditEvent.action == "n8n_procedures_synced")
|
||||
)
|
||||
|
||||
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
|
||||
@@ -129,20 +154,74 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
|
||||
)
|
||||
|
||||
evidence_by_workflow = {
|
||||
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,
|
||||
}
|
||||
workflows = [
|
||||
N8nWorkflowEvidence(
|
||||
name=name,
|
||||
built=True,
|
||||
last_seen_at=evidence_by_workflow[name],
|
||||
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
|
||||
)
|
||||
for name in _CANONICAL_WORKFLOWS
|
||||
]
|
||||
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]
|
||||
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),
|
||||
@@ -173,9 +252,7 @@ def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
|
||||
`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")
|
||||
)
|
||||
db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request"))
|
||||
or 0
|
||||
)
|
||||
latest_call_row = db.execute(
|
||||
@@ -215,8 +292,15 @@ def _check_hub_reachable() -> bool | None:
|
||||
`None` means not configured / not checked, never a guess."""
|
||||
if not settings.mcp_hub_base_url:
|
||||
return None
|
||||
try:
|
||||
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
|
||||
return response.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
return False
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Literal, Protocol
|
||||
|
||||
@@ -8,6 +9,7 @@ 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):
|
||||
@@ -36,6 +38,11 @@ class KnowledgeHealth(BaseModel):
|
||||
# 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):
|
||||
|
||||
@@ -14,25 +14,146 @@ 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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -141,7 +262,7 @@ _LOW_CONFIDENCE_TEXT = {
|
||||
_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}',
|
||||
"fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +322,11 @@ class DemoKnowledgeProvider:
|
||||
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(
|
||||
|
||||
@@ -10,7 +10,9 @@ 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")
|
||||
_SOURCE_ID_NAMESPACE = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
|
||||
)
|
||||
|
||||
|
||||
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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"}
|
||||
|
||||
@@ -17,9 +24,84 @@ _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||
_LEAD_ANSWER_TEMPLATE = {
|
||||
"en-GB": 'Per "{title}": {excerpt}',
|
||||
"nl-BE": 'Volgens "{title}": {excerpt}',
|
||||
"fr-BE": 'Selon « {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"),
|
||||
}
|
||||
|
||||
|
||||
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: dict) -> float | 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:
|
||||
@@ -54,6 +136,24 @@ class RAGcoreKnowledgeProvider:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._settings = get_settings()
|
||||
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 _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 = {}
|
||||
@@ -66,16 +166,35 @@ class RAGcoreKnowledgeProvider:
|
||||
)
|
||||
|
||||
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}"
|
||||
@@ -86,11 +205,71 @@ class RAGcoreKnowledgeProvider:
|
||||
tenant=self._settings.ragcore_tenant,
|
||||
workspace=self._settings.ragcore_workspace,
|
||||
collection=self._settings.ragcore_collection,
|
||||
# RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit
|
||||
# so the UI never turns this into the misleading claim "0 procedures".
|
||||
document_count=None,
|
||||
# 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="",
|
||||
@@ -102,9 +281,12 @@ class RAGcoreKnowledgeProvider:
|
||||
if not self._settings.ragcore_space_id:
|
||||
return unavailable
|
||||
|
||||
answered = self._ask_via_answers(question, correlation_id)
|
||||
if answered is not None:
|
||||
return answered
|
||||
if self._answers_circuit_is_open():
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
|
||||
else:
|
||||
answered = self._ask_via_answers(question, correlation_id)
|
||||
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
|
||||
@@ -128,9 +310,13 @@ class RAGcoreKnowledgeProvider:
|
||||
},
|
||||
)
|
||||
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:
|
||||
@@ -145,9 +331,12 @@ class RAGcoreKnowledgeProvider:
|
||||
)
|
||||
for citation in citations.values()
|
||||
]
|
||||
sources = _deduplicate_sources(sources)
|
||||
answerability = body.get("answerability", "not_answerable")
|
||||
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
|
||||
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
|
||||
self._close_answers_circuit()
|
||||
return GroundedAnswer(
|
||||
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||
evidence_state=evidence_state,
|
||||
@@ -156,6 +345,8 @@ class RAGcoreKnowledgeProvider:
|
||||
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(
|
||||
@@ -179,13 +370,17 @@ class RAGcoreKnowledgeProvider:
|
||||
},
|
||||
)
|
||||
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:
|
||||
results = body.get("results", [])
|
||||
if not isinstance(results, list):
|
||||
raise TypeError("results must be a list")
|
||||
sources = [
|
||||
SourceCard(
|
||||
document_id=str(result["citation"]["document_id"]),
|
||||
@@ -197,9 +392,30 @@ class RAGcoreKnowledgeProvider:
|
||||
for result in results
|
||||
]
|
||||
except (TypeError, KeyError, ValueError):
|
||||
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
|
||||
return unavailable
|
||||
|
||||
if not sources:
|
||||
for result in results:
|
||||
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 = [
|
||||
SourceCard(
|
||||
document_id=str(result["citation"]["document_id"]),
|
||||
title=result["citation"]["title"],
|
||||
version=str(result["citation"]["document_version_id"]),
|
||||
section=result["citation"].get("section") or "",
|
||||
excerpt=result["citation"]["excerpt"],
|
||||
)
|
||||
for result in results
|
||||
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
|
||||
]
|
||||
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",
|
||||
@@ -208,9 +424,28 @@ class RAGcoreKnowledgeProvider:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
damage_evidence = any(
|
||||
term
|
||||
in (
|
||||
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
|
||||
).casefold()
|
||||
for source in sources
|
||||
for term in _DOMAIN_CONCEPTS["damage"]
|
||||
)
|
||||
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
|
||||
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",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -18,12 +20,14 @@ from app.models.vehicle import Vehicle
|
||||
from app.schemas import CurrentUser, RegisterReturnRequest
|
||||
from app.services.audit import record_audit_event
|
||||
|
||||
REF_PREFIX = "INSP"
|
||||
|
||||
def _new_inspection_ref() -> str:
|
||||
"""Generate a collision-resistant public reference without reading mutable counts.
|
||||
|
||||
def _next_public_ref(db: Session) -> str:
|
||||
existing = db.execute(select(Inspection.public_ref)).scalars().all()
|
||||
return f"{REF_PREFIX}-{len(existing) + 1:04d}"
|
||||
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(
|
||||
@@ -169,6 +173,33 @@ def preview_vehicle_return(
|
||||
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,
|
||||
@@ -176,18 +207,12 @@ def register_vehicle_return(
|
||||
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:
|
||||
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,
|
||||
)
|
||||
return existing.response_status, existing.response_body
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
|
||||
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
|
||||
|
||||
@@ -197,7 +222,7 @@ def register_vehicle_return(
|
||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing.response_status, existing.response_body
|
||||
return _replay_or_reject(db, existing, booking_ref, fingerprint)
|
||||
|
||||
if booking.status != "active":
|
||||
raise AppError(
|
||||
@@ -211,7 +236,7 @@ def register_vehicle_return(
|
||||
evaluation = evaluate_return(db, booking, vehicle, body, now=now)
|
||||
|
||||
inspection = Inspection(
|
||||
public_ref=_next_public_ref(db),
|
||||
public_ref=_new_inspection_ref(),
|
||||
booking_id=booking.id,
|
||||
vehicle_id=vehicle.id,
|
||||
type="return",
|
||||
@@ -253,6 +278,7 @@ def register_vehicle_return(
|
||||
},
|
||||
proposed_action_json={},
|
||||
detected_at=now,
|
||||
due_at=now + timedelta(days=1),
|
||||
)
|
||||
db.add(issue)
|
||||
db.flush()
|
||||
@@ -325,6 +351,7 @@ def register_vehicle_return(
|
||||
"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,
|
||||
}
|
||||
|
||||
@@ -332,6 +359,7 @@ def register_vehicle_return(
|
||||
IdempotencyRecord(
|
||||
idempotency_key=idempotency_key,
|
||||
booking_id=booking.id,
|
||||
request_fingerprint=fingerprint,
|
||||
response_status=201,
|
||||
response_body=response_body,
|
||||
)
|
||||
@@ -345,7 +373,7 @@ def register_vehicle_return(
|
||||
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
|
||||
)
|
||||
if existing is not None:
|
||||
return existing.response_status, existing.response_body
|
||||
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
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -175,9 +175,7 @@ def evaluate_vehicle_status(
|
||||
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
|
||||
)
|
||||
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
|
||||
@@ -190,24 +188,18 @@ def evaluate_vehicle_status(
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
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
|
||||
|
||||
@@ -14,13 +14,17 @@ dependencies = [
|
||||
"sqlalchemy>=2.0,<3",
|
||||
"psycopg[binary]>=3.2,<4",
|
||||
"alembic>=1.13,<2",
|
||||
"httpx>=0.27,<1"
|
||||
"httpx>=0.27,<1",
|
||||
"authlib>=1.6,<2",
|
||||
"itsdangerous>=2.2,<3",
|
||||
"prometheus-client>=0.24,<1"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8,<9",
|
||||
"pytest-asyncio>=0.24,<1",
|
||||
# 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"
|
||||
]
|
||||
@@ -30,7 +34,6 @@ packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
@@ -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
|
||||
@@ -13,14 +13,23 @@ annotated-types==0.8.0
|
||||
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
|
||||
@@ -28,19 +37,29 @@ greenlet==3.5.4
|
||||
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
|
||||
@@ -57,10 +76,14 @@ 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
|
||||
@@ -72,10 +95,6 @@ pydantic-settings==2.14.2
|
||||
pygments==2.20.0
|
||||
# via pytest
|
||||
pytest==8.4.2
|
||||
# via
|
||||
# mobilityops-api (pyproject.toml)
|
||||
# pytest-asyncio
|
||||
pytest-asyncio==0.26.0
|
||||
# via mobilityops-api (pyproject.toml)
|
||||
python-dotenv==1.2.2
|
||||
# via
|
||||
@@ -91,11 +110,16 @@ sqlalchemy==2.0.51
|
||||
# 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
|
||||
|
||||
@@ -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()
|
||||
@@ -66,9 +66,7 @@ def test_return_registered_audit_event_exposes_before_after_and_link(ops_client)
|
||||
)
|
||||
|
||||
key = "test-audit-before-after-001"
|
||||
events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "return_registered"}
|
||||
).json()
|
||||
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"
|
||||
|
||||
@@ -4,6 +4,71 @@ def test_unauthenticated_dashboard_is_rejected(client):
|
||||
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
|
||||
@@ -25,6 +90,28 @@ def test_operations_manager_can_reset_demo(ops_client):
|
||||
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_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)
|
||||
monkeypatch.setattr(demo_router, "_last_reset_monotonic", demo_router.time.monotonic())
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 429
|
||||
assert int(response.headers["retry-after"]) >= 1
|
||||
monkeypatch.setattr(demo_router, "_last_reset_monotonic", 0.0)
|
||||
|
||||
|
||||
def test_reset_is_rejected_when_demo_allow_reset_is_disabled(ops_client, monkeypatch):
|
||||
import app.api.routers.demo as demo_router
|
||||
|
||||
@@ -62,6 +149,55 @@ def test_logout_invalidates_session(ops_client):
|
||||
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
|
||||
)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
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
|
||||
@@ -6,6 +14,235 @@ def test_list_bookings_filters_by_vehicle(ops_client):
|
||||
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
|
||||
|
||||
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_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"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
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"])
|
||||
|
||||
|
||||
def test_get_booking_detail(ops_client):
|
||||
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -53,6 +53,49 @@ def test_list_issues_requires_operations_manager(employee_client):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_manager_can_assign_prioritised_quality_work_and_filter_it(ops_client):
|
||||
assignee = next(user for user in ops_client.get("/api/v1/users").json() if user["active"])
|
||||
open_issues = ops_client.get("/api/v1/data-quality/issues", params={"status": "open"}).json()
|
||||
refs = [issue["public_ref"] for issue in open_issues[:2]]
|
||||
due_at = "2030-01-15T12:00:00+00:00"
|
||||
updated = ops_client.post(
|
||||
"/api/v1/data-quality/issues/bulk-work",
|
||||
json={
|
||||
"issue_refs": refs,
|
||||
"assigned_to_ref": assignee["public_ref"],
|
||||
"due_at": due_at,
|
||||
},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert {issue["public_ref"] for issue in updated.json()["updated"]} == set(refs)
|
||||
assert all(
|
||||
issue["assigned_to_ref"] == assignee["public_ref"] for issue in updated.json()["updated"]
|
||||
)
|
||||
|
||||
filtered = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"status": "open", "assigned_to_ref": assignee["public_ref"]},
|
||||
).json()
|
||||
assert set(refs).issubset({issue["public_ref"] for issue in filtered})
|
||||
audits = ops_client.get("/api/v1/audit", params={"action": "data_quality_work_updated"}).json()
|
||||
assert len(audits) >= 2
|
||||
|
||||
cleared = ops_client.post(
|
||||
"/api/v1/data-quality/issues/bulk-work",
|
||||
json={"issue_refs": refs, "clear_assignment": True},
|
||||
)
|
||||
assert cleared.status_code == 200
|
||||
assert all(issue["assigned_to_ref"] is None for issue in cleared.json()["updated"])
|
||||
|
||||
|
||||
def test_employee_cannot_assign_quality_work(employee_client):
|
||||
response = employee_client.post(
|
||||
"/api/v1/data-quality/issues/bulk-work",
|
||||
json={"issue_refs": ["DQ-DEMO-OVERLAP"], "clear_assignment": True},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
@@ -148,9 +191,7 @@ def test_merge_customers_s2_scenario_rewires_and_audits(ops_client):
|
||||
issue = ops_client.get("/api/v1/data-quality/issues/DQ-DEMO-DUPLICATE").json()
|
||||
assert issue["status"] == "resolved"
|
||||
|
||||
audit_events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "customer_merged"}
|
||||
).json()
|
||||
audit_events = ops_client.get("/api/v1/audit", params={"action": "customer_merged"}).json()
|
||||
assert len(audit_events) >= 1
|
||||
|
||||
# Already-resolved issue cannot be merged again.
|
||||
@@ -313,10 +354,21 @@ def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
|
||||
|
||||
|
||||
def test_apply_recommended_status_resolves_conflict(ops_client):
|
||||
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||
preview = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
|
||||
targets = ops_client.get(
|
||||
"/api/v1/data-quality/issues",
|
||||
params={"rule_type": "vehicle_status_conflict", "status": "open"},
|
||||
).json()
|
||||
target = None
|
||||
preview = None
|
||||
for candidate in targets:
|
||||
candidate_preview = ops_client.post(
|
||||
f"/api/v1/data-quality/issues/{candidate['public_ref']}/status-recommendation"
|
||||
).json()
|
||||
if candidate_preview["safe_to_apply"]:
|
||||
target = candidate
|
||||
preview = candidate_preview
|
||||
break
|
||||
assert target is not None and preview is not None
|
||||
assert preview["safe_to_apply"] is True
|
||||
assert preview["manual_review_required"] is False
|
||||
|
||||
@@ -431,9 +483,7 @@ def test_manual_scan_records_audit_event(ops_client):
|
||||
scan = ops_client.post("/api/v1/data-quality/scan")
|
||||
assert scan.status_code == 200
|
||||
|
||||
events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "data_quality_scan_run"}
|
||||
).json()
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "data_quality_scan_run"}).json()
|
||||
assert len(events) >= 1
|
||||
assert "created" in events[0]["metadata"]
|
||||
|
||||
@@ -444,9 +494,7 @@ def _reset_demo(ops_client) -> None:
|
||||
# in before making any further authenticated call with the same client.
|
||||
response = ops_client.post("/api/v1/demo/reset")
|
||||
assert response.status_code == 200, response.text
|
||||
login_response = ops_client.post(
|
||||
"/api/v1/demo/login", json={"role": "operations_manager"}
|
||||
)
|
||||
login_response = ops_client.post("/api/v1/demo/login", json={"role": "operations_manager"})
|
||||
assert login_response.status_code == 200, login_response.text
|
||||
|
||||
|
||||
@@ -548,3 +596,11 @@ def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
|
||||
)
|
||||
assert match is not None, "expected a new issue linked back to the rejected one"
|
||||
assert match["evidence"]["previous_decision"] == "rejected"
|
||||
|
||||
|
||||
def test_scan_public_refs_are_collision_resistant() -> None:
|
||||
from app.services.data_quality import _new_scan_ref
|
||||
|
||||
refs = {_new_scan_ref("DQ-SCAN") for _ in range(1000)}
|
||||
assert len(refs) == 1000
|
||||
assert all(ref.startswith("DQ-SCAN-") and len(ref) == 18 for ref in refs)
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Regression tests for the hardening pass (security, robustness, data-quality edge cases)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import UTC, datetime
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from app.core.config import Settings, get_settings, insecure_default_secrets
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.errors import AppError
|
||||
from app.core.observability import UNMATCHED_ROUTE_LABEL
|
||||
from app.core.ratelimit import FailedAttemptLimiter, SlidingWindowLimiter
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
from app.models.data_quality import DataQualityIssue
|
||||
from app.schemas import CurrentUser
|
||||
from app.services.data_quality import defer_issue, run_scan
|
||||
from tests.test_return import _activate_booking, _return_body
|
||||
|
||||
|
||||
def test_insecure_defaults_include_mounted_mcp_boundary_even_when_status_check_is_disabled():
|
||||
defaults = Settings(_env_file=None)
|
||||
assert "app_secret" in insecure_default_secrets(defaults)
|
||||
assert "mcp_hub_service_token" in insecure_default_secrets(defaults)
|
||||
hardened = Settings(
|
||||
_env_file=None,
|
||||
app_secret="x" * 32,
|
||||
n8n_callback_token="c" * 32,
|
||||
mcp_hub_service_token="m" * 32,
|
||||
)
|
||||
assert insecure_default_secrets(hardened) == []
|
||||
|
||||
|
||||
def test_production_refuses_to_start_with_placeholder_secrets(monkeypatch):
|
||||
monkeypatch.setenv("MOBILITYOPS_ENV", "production")
|
||||
monkeypatch.setenv("APP_SECRET", "replace-in-production")
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="placeholder secrets"):
|
||||
get_settings()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
# The cached settings the running app relies on must be intact afterwards.
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("public_url", "secure_cookie", "message"),
|
||||
[
|
||||
("http://fleetops.example.test", "true", "must use HTTPS"),
|
||||
("https://fleetops.example.test", "false", "must be true"),
|
||||
],
|
||||
)
|
||||
def test_production_refuses_cleartext_or_insecure_session_cookie(
|
||||
monkeypatch, public_url, secure_cookie, message
|
||||
):
|
||||
values = {
|
||||
"MOBILITYOPS_ENV": "production",
|
||||
"APP_SECRET": "a" * 32,
|
||||
"N8N_CALLBACK_TOKEN": "c" * 32,
|
||||
"MCP_HUB_SERVICE_TOKEN": "m" * 32,
|
||||
"MOBILITYOPS_PUBLIC_URL": public_url,
|
||||
"SESSION_COOKIE_SECURE": secure_cookie,
|
||||
}
|
||||
for name, value in values.items():
|
||||
monkeypatch.setenv(name, value)
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
get_settings()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
monkeypatch.undo()
|
||||
get_settings.cache_clear()
|
||||
assert get_settings().mobilityops_env == "test"
|
||||
|
||||
|
||||
def test_failed_attempt_limiter_blocks_after_threshold_and_resets():
|
||||
limiter = FailedAttemptLimiter(max_failures=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||
limiter.record_failure("1.2.3.4")
|
||||
assert limiter.retry_after_seconds("1.2.3.4") > 0
|
||||
assert limiter.retry_after_seconds("5.6.7.8") == 0
|
||||
limiter.reset("1.2.3.4")
|
||||
assert limiter.retry_after_seconds("1.2.3.4") == 0
|
||||
|
||||
|
||||
def test_sliding_window_limiter_counts_successes_and_isolates_keys():
|
||||
limiter = SlidingWindowLimiter(max_requests=2, window_seconds=60)
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") == 0
|
||||
assert limiter.consume("session-a") > 0
|
||||
assert limiter.consume("session-b") == 0
|
||||
|
||||
|
||||
def test_login_client_key_uses_proxy_appended_peer_not_spoofed_leading_value():
|
||||
from starlette.requests import Request
|
||||
|
||||
from app.api.routers.auth import _client_key
|
||||
|
||||
request = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"headers": [(b"x-forwarded-for", b"203.0.113.99, 198.51.100.7")],
|
||||
"client": ("172.20.0.3", 12345),
|
||||
}
|
||||
)
|
||||
assert _client_key(request) == "198.51.100.7"
|
||||
|
||||
|
||||
def test_audit_export_accepts_naive_datetimes(ops_client):
|
||||
response = ops_client.get(
|
||||
"/api/v1/audit/export.csv",
|
||||
params={"occurred_from": "2026-01-01T00:00:00", "occurred_to": "2026-01-15T00:00:00"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_audit_list_rejects_non_uuid_correlation_id(ops_client):
|
||||
response = ops_client.get("/api/v1/audit", params={"correlation_id": "not-a-uuid"})
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_unmatched_routes_do_not_create_metric_series(client):
|
||||
probe = f"/api/v1/does-not-exist-{uuid.uuid4().hex}"
|
||||
assert client.get(probe).status_code == 404
|
||||
metrics = client.get("/metrics").text
|
||||
assert probe not in metrics
|
||||
assert UNMATCHED_ROUTE_LABEL in metrics
|
||||
|
||||
|
||||
def test_bookings_page_for_unknown_vehicle_keeps_page_shape(ops_client):
|
||||
response = ops_client.get("/api/v1/bookings", params={"vehicle_ref": "MO-NOPE", "page": 1})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"page_size": 25,
|
||||
"total": 0,
|
||||
"total_pages": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_return_idempotency_key_rejects_different_body(ops_client):
|
||||
booking_ref = _activate_booking("MO-011", start_odometer_km=30000)
|
||||
key = "test-return-fingerprint-001"
|
||||
first = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=_return_body(end_odometer_km=30500),
|
||||
headers={"Idempotency-Key": key},
|
||||
)
|
||||
assert first.status_code == 201
|
||||
replay = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=_return_body(end_odometer_km=30500),
|
||||
headers={"Idempotency-Key": key},
|
||||
)
|
||||
assert replay.status_code == 201
|
||||
mismatch = ops_client.post(
|
||||
f"/api/v1/bookings/{booking_ref}/return",
|
||||
json=_return_body(end_odometer_km=30999),
|
||||
headers={"Idempotency-Key": key},
|
||||
)
|
||||
assert mismatch.status_code == 409
|
||||
assert mismatch.json()["error"]["code"] == "IDEMPOTENCY_KEY_REUSED"
|
||||
|
||||
|
||||
def test_return_callback_rejects_malformed_correlation_id(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/return-callback",
|
||||
json={"follow_up": "cleaning", "correlation_id": "nope"},
|
||||
headers={
|
||||
"Idempotency-Key": str(uuid.uuid4()),
|
||||
"X-Service-Token": get_settings().n8n_callback_token,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_blocked_checkout_booking_can_be_cancelled(ops_client):
|
||||
window = {"starts_at": "2051-03-01T10:00:00Z", "ends_at": "2051-03-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-0002",
|
||||
"vehicle_ref": vehicle["public_ref"],
|
||||
"requirements_complete": True,
|
||||
**window,
|
||||
},
|
||||
).json()
|
||||
checkout = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/checkout",
|
||||
json={
|
||||
"start_odometer_km": vehicle["odometer_km"],
|
||||
"fuel_level_percent": 80,
|
||||
"cleanliness_ok": True,
|
||||
"damage_reported": True,
|
||||
"technical_warning": False,
|
||||
},
|
||||
)
|
||||
assert checkout.status_code == 200
|
||||
assert checkout.json()["booking_status"] == "blocked"
|
||||
cancelled = ops_client.post(
|
||||
f"/api/v1/bookings/{booking['public_ref']}/cancel",
|
||||
json={"reason": "Vehicle damaged at departure inspection"},
|
||||
)
|
||||
assert cancelled.status_code == 200
|
||||
assert cancelled.json()["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_scan_does_not_flag_anonymised_customers_as_missing_fields(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-ANON-SCAN",
|
||||
first_name="Anoniem",
|
||||
last_name="Klant",
|
||||
email=None,
|
||||
phone=None,
|
||||
postal_code=None,
|
||||
city=None,
|
||||
date_of_birth=None,
|
||||
anonymized_at=datetime.now(UTC),
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
customer_id = customer.id
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
run_scan(db)
|
||||
db.commit()
|
||||
flagged = db.scalar(
|
||||
select(DataQualityIssue.id).where(
|
||||
DataQualityIssue.entity_type == "customer",
|
||||
DataQualityIssue.entity_id == customer_id,
|
||||
)
|
||||
)
|
||||
assert flagged is None
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.entity_id == customer_id))
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_concurrent_quality_scans_leave_only_one_open_issue_per_condition():
|
||||
barrier = Barrier(2)
|
||||
|
||||
def scan() -> None:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
run_scan(db)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
list(executor.map(lambda _index: scan(), range(2)))
|
||||
|
||||
with SessionLocal() as db:
|
||||
duplicates = db.execute(
|
||||
select(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
func.count(DataQualityIssue.id),
|
||||
)
|
||||
.where(DataQualityIssue.status == "open")
|
||||
.group_by(
|
||||
DataQualityIssue.rule_type,
|
||||
DataQualityIssue.entity_type,
|
||||
DataQualityIssue.entity_id,
|
||||
)
|
||||
.having(func.count(DataQualityIssue.id) > 1)
|
||||
).all()
|
||||
assert duplicates == []
|
||||
|
||||
|
||||
def test_concurrent_issue_resolution_records_exactly_one_decision():
|
||||
issue_id = uuid.uuid4()
|
||||
issue_ref = f"DQ-RACE-{uuid.uuid4().hex[:8].upper()}"
|
||||
with SessionLocal() as db:
|
||||
db.add(
|
||||
DataQualityIssue(
|
||||
id=issue_id,
|
||||
public_ref=issue_ref,
|
||||
rule_type="missing_required_field",
|
||||
entity_type="customer",
|
||||
entity_id=uuid.uuid4(),
|
||||
severity="low",
|
||||
status="open",
|
||||
evidence_json={},
|
||||
proposed_action_json={},
|
||||
detected_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
actor = CurrentUser(
|
||||
public_ref="USR-RACE", display_name="Race Manager", role="operations_manager"
|
||||
)
|
||||
barrier = Barrier(2)
|
||||
|
||||
def resolve() -> str:
|
||||
with SessionLocal() as db:
|
||||
barrier.wait()
|
||||
try:
|
||||
return defer_issue(db, issue_ref, actor).status
|
||||
except AppError as exc:
|
||||
return exc.code
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(executor.map(lambda _index: resolve(), range(2)))
|
||||
assert sorted(outcomes) == ["ISSUE_NOT_OPEN", "deferred"]
|
||||
with SessionLocal() as db:
|
||||
decisions = db.scalar(
|
||||
select(func.count(AuditEvent.id)).where(
|
||||
AuditEvent.entity_id == issue_id,
|
||||
AuditEvent.action == "data_quality_issue_deferred",
|
||||
)
|
||||
)
|
||||
assert decisions == 1
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == issue_id))
|
||||
db.execute(delete(DataQualityIssue).where(DataQualityIssue.id == issue_id))
|
||||
db.commit()
|
||||
@@ -7,3 +7,35 @@ def test_health() -> None:
|
||||
response = TestClient(app).get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "service": "mobilityops-api"}
|
||||
|
||||
|
||||
def test_liveness_is_process_only() -> None:
|
||||
response = TestClient(app).get("/health/live")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
def test_readiness_checks_the_canonical_database() -> None:
|
||||
response = TestClient(app).get("/health/ready")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"status": "ready",
|
||||
"service": "mobilityops-api",
|
||||
"database": "up",
|
||||
}
|
||||
|
||||
|
||||
def test_readiness_degrades_when_database_is_unavailable(monkeypatch) -> None:
|
||||
import app.main as main_module
|
||||
|
||||
class BrokenSession:
|
||||
def __enter__(self):
|
||||
raise ConnectionError("database unavailable")
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(main_module, "SessionLocal", BrokenSession)
|
||||
response = TestClient(app).get("/health/ready")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["database"] == "down"
|
||||
|
||||
@@ -124,6 +124,7 @@ def test_integration_status_lists_all_four_canonical_workflows(ops_client):
|
||||
# their own first real signal -- there is no run evidence yet either.
|
||||
assert ragcore_sync["built"] is True
|
||||
assert ragcore_sync["last_seen_at"] is None
|
||||
assert ragcore_sync["state"] == "no_evidence"
|
||||
|
||||
|
||||
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
|
||||
|
||||
@@ -264,3 +264,40 @@ def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
|
||||
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
ragcore_sync = next(w for w in status["workflows"] if "RAGcore" in w["name"])
|
||||
assert ragcore_sync["last_seen_at"] is not None
|
||||
|
||||
|
||||
def test_workflow_heartbeat_is_idempotent_and_drives_live_status(client, ops_client):
|
||||
settings = get_settings()
|
||||
execution_id = str(uuid.uuid4())
|
||||
body = {
|
||||
"workflow_id": "mobilityops-scheduled-quality-scan",
|
||||
"workflow_name": "Fleet Ops — Scheduled Data Quality Scan",
|
||||
"execution_id": execution_id,
|
||||
"status": "succeeded",
|
||||
}
|
||||
headers = {"X-Service-Token": settings.n8n_callback_token}
|
||||
first = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers)
|
||||
second = client.post("/api/v1/integrations/n8n/heartbeat", json=body, headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert first.json()["status"] == "registered"
|
||||
assert second.json()["status"] == "already_registered"
|
||||
|
||||
status = ops_client.get("/api/v1/integrations/status").json()["n8n"]
|
||||
workflow = next(w for w in status["workflows"] if w["name"] == body["workflow_name"])
|
||||
assert workflow["state"] == "healthy"
|
||||
assert workflow["last_status"] == "succeeded"
|
||||
assert workflow["last_execution_id"] == execution_id
|
||||
|
||||
|
||||
def test_workflow_heartbeat_rejects_unknown_workflow(client):
|
||||
response = client.post(
|
||||
"/api/v1/integrations/n8n/heartbeat",
|
||||
json={
|
||||
"workflow_id": "unknown",
|
||||
"workflow_name": "Unknown workflow",
|
||||
"execution_id": "test-unknown-001",
|
||||
"status": "succeeded",
|
||||
},
|
||||
headers={"X-Service-Token": get_settings().n8n_callback_token},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -5,8 +5,12 @@ from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.api.routers import knowledge as knowledge_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.ratelimit import SlidingWindowLimiter
|
||||
from app.services.knowledge import KnowledgeHealth
|
||||
from app.services.knowledge.demo import DemoKnowledgeProvider
|
||||
from app.services.knowledge.procedures import iter_procedure_documents
|
||||
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
|
||||
|
||||
|
||||
@@ -73,6 +77,8 @@ def test_demo_provider_health_reports_document_count():
|
||||
assert health.provider == "demo"
|
||||
assert health.available is True
|
||||
assert health.document_count == 11
|
||||
assert health.source_document_count == 11
|
||||
assert health.statistics_state == "verified"
|
||||
|
||||
|
||||
def test_demo_provider_health_reports_document_count_per_language():
|
||||
@@ -107,9 +113,7 @@ def test_demo_provider_grounds_damage_question_in_french():
|
||||
|
||||
def test_demo_provider_insufficient_evidence_message_is_localized():
|
||||
provider = DemoKnowledgeProvider()
|
||||
nl_answer = provider.ask(
|
||||
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
|
||||
)
|
||||
nl_answer = provider.ask("Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE")
|
||||
fr_answer = provider.ask(
|
||||
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE"
|
||||
)
|
||||
@@ -137,14 +141,22 @@ def test_ask_question_requires_authentication(client):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_knowledge_questions_are_bounded_per_session_and_client(ops_client, monkeypatch):
|
||||
limiter = SlidingWindowLimiter(max_requests=1, window_seconds=60)
|
||||
monkeypatch.setattr(knowledge_router, "_question_limiter", limiter)
|
||||
body = {"question": "What is the vehicle return procedure?"}
|
||||
assert ops_client.post("/api/v1/knowledge/questions", json=body).status_code == 200
|
||||
blocked = ops_client.post("/api/v1/knowledge/questions", json=body)
|
||||
assert blocked.status_code == 429
|
||||
assert int(blocked.headers["retry-after"]) >= 1
|
||||
|
||||
|
||||
def test_ask_question_is_audited_without_leaking_full_text(ops_client):
|
||||
ops_client.post(
|
||||
"/api/v1/knowledge/questions",
|
||||
json={"question": "What must I do when a vehicle returns with damage?"},
|
||||
)
|
||||
events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "knowledge_question_asked"}
|
||||
).json()
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "knowledge_question_asked"}).json()
|
||||
assert len(events) >= 1
|
||||
metadata = events[0]["metadata"]
|
||||
assert "evidence_state" in metadata
|
||||
@@ -158,6 +170,79 @@ def test_knowledge_status_endpoint(ops_client):
|
||||
assert response.json()["provider"] == "demo"
|
||||
|
||||
|
||||
def test_ragcore_status_separates_sync_report_from_unverifiable_index(
|
||||
client, ops_client, monkeypatch
|
||||
):
|
||||
class FakeRagcoreProvider:
|
||||
def health(self, language="en-GB"):
|
||||
return KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=True,
|
||||
detail="ready",
|
||||
tenant="fleet-ops",
|
||||
workspace="operations",
|
||||
collection="internal-procedures",
|
||||
document_count=None,
|
||||
source_document_count=11,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="not_reported",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(knowledge_router, "get_knowledge_provider", FakeRagcoreProvider)
|
||||
settings = get_settings()
|
||||
sync = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||
json={"execution_id": "rag-statistics-test", "synced": 32, "failed": 1},
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert sync.status_code == 200
|
||||
|
||||
response = ops_client.get("/api/v1/knowledge/status?language=nl-BE")
|
||||
assert response.status_code == 200
|
||||
status = response.json()
|
||||
assert status["document_count"] is None
|
||||
assert status["source_document_count"] == 11
|
||||
assert status["reported_synced_document_count"] == 32
|
||||
assert status["reported_failed_document_count"] == 1
|
||||
assert status["last_sync_at"] is not None
|
||||
assert status["statistics_state"] == "sync_reported"
|
||||
|
||||
|
||||
def test_ragcore_status_preserves_stronger_verified_index_evidence(client, ops_client, monkeypatch):
|
||||
class VerifiedRagcoreProvider:
|
||||
def health(self, language="en-GB"):
|
||||
return KnowledgeHealth(
|
||||
provider="ragcore",
|
||||
available=True,
|
||||
detail="ready and verified",
|
||||
tenant="fleet-ops",
|
||||
workspace="operations",
|
||||
collection="internal-procedures",
|
||||
document_count=11,
|
||||
source_document_count=11,
|
||||
reported_synced_document_count=None,
|
||||
reported_failed_document_count=None,
|
||||
last_sync_at=None,
|
||||
statistics_state="verified",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(knowledge_router, "get_knowledge_provider", VerifiedRagcoreProvider)
|
||||
settings = get_settings()
|
||||
sync = client.post(
|
||||
"/api/v1/integrations/n8n/procedures-sync-result",
|
||||
json={"execution_id": "rag-verified-statistics-test", "synced": 33, "failed": 0},
|
||||
headers={"X-Service-Token": settings.n8n_callback_token},
|
||||
)
|
||||
assert sync.status_code == 200
|
||||
|
||||
status = ops_client.get("/api/v1/knowledge/status?language=nl-BE").json()
|
||||
assert status["document_count"] == 11
|
||||
assert status["reported_synced_document_count"] == 33
|
||||
assert status["statistics_state"] == "verified"
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict):
|
||||
self.status_code = status_code
|
||||
@@ -168,7 +253,14 @@ class _FakeResponse:
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, get_response=None, post_response=None, post_responses=None, raise_on=None):
|
||||
def __init__(
|
||||
self,
|
||||
get_response=None,
|
||||
post_response=None,
|
||||
post_responses=None,
|
||||
raise_on=None,
|
||||
get_handler=None,
|
||||
):
|
||||
self._get_response = get_response
|
||||
self._post_response = post_response
|
||||
# Maps a path (e.g. "/v1/search") to its own response, for tests that need
|
||||
@@ -177,6 +269,7 @@ class _FakeClient:
|
||||
# existing single-endpoint test keeps working unchanged.
|
||||
self._post_responses = post_responses or {}
|
||||
self._raise_on = raise_on
|
||||
self._get_handler = get_handler
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
@@ -184,9 +277,13 @@ class _FakeClient:
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, path):
|
||||
def get(self, path, params=None, timeout=None):
|
||||
if self._raise_on == "get":
|
||||
raise httpx.ConnectError("no ragcore in this environment")
|
||||
if self._get_handler is not None:
|
||||
return self._get_handler(path, params, timeout)
|
||||
if path == "/v1/documents":
|
||||
return _FakeResponse(503, {})
|
||||
return self._get_response
|
||||
|
||||
def post(self, path, json=None):
|
||||
@@ -228,6 +325,53 @@ def test_ragcore_provider_health_reports_ready_status(monkeypatch):
|
||||
health = provider.health()
|
||||
assert health.provider == "ragcore"
|
||||
assert health.available is True
|
||||
assert health.document_count is None
|
||||
assert health.source_document_count == 11
|
||||
assert health.statistics_state == "not_reported"
|
||||
|
||||
|
||||
def test_ragcore_provider_verifies_published_documents_and_caches_count(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
procedure_documents = [
|
||||
document
|
||||
for document in iter_procedure_documents(Path(provider._settings.knowledge_dir))
|
||||
if document.language == "en-GB"
|
||||
]
|
||||
documents = {document.source_id: document for document in procedure_documents}
|
||||
lookups: list[str] = []
|
||||
|
||||
def get_handler(path, params, _timeout):
|
||||
if path == "/health/ready":
|
||||
return _FakeResponse(200, {"status": "ok"})
|
||||
document = documents[params["source_id"]]
|
||||
lookups.append(document.source_id)
|
||||
return _FakeResponse(
|
||||
200,
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"space_id": "space-1",
|
||||
"source_id": document.source_id,
|
||||
"external_id": f"{document.document_id}.md",
|
||||
"status": "active",
|
||||
"active_version": {
|
||||
"status": "published",
|
||||
"content_sha256": "provider-canonical-hash",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(get_handler=get_handler))
|
||||
|
||||
first = provider.health("en-GB")
|
||||
second = provider.health("en-GB")
|
||||
assert first.document_count == 11
|
||||
assert first.statistics_state == "verified"
|
||||
assert second.document_count == 11
|
||||
assert len(lookups) == 11
|
||||
|
||||
|
||||
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
|
||||
@@ -288,6 +432,67 @@ def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch)
|
||||
assert source.excerpt
|
||||
|
||||
|
||||
def test_ragcore_sources_deduplicate_reuploaded_versions_and_cap_cards(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
citations = []
|
||||
for index in range(5):
|
||||
citations.append(
|
||||
{
|
||||
"id": f"cite-{index}",
|
||||
"document_id": f"doc-{index}",
|
||||
"document_version_id": f"version-{index}",
|
||||
"title": "Damage procedure" if index < 2 else f"Procedure {index}",
|
||||
"section": "Return",
|
||||
"excerpt": (
|
||||
f"Record visible damage before release, chunk {index}."
|
||||
if index < 2
|
||||
else f"Unique procedure evidence {index}."
|
||||
),
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(citations=citations))),
|
||||
)
|
||||
answer = provider.ask("What must I do about vehicle damage?", "dedupe-test")
|
||||
assert len(answer.sources) == 3
|
||||
assert sum(source.title == "Damage procedure" for source in answer.sources) == 1
|
||||
|
||||
|
||||
def test_knowledge_feedback_is_audited_and_can_be_changed(ops_client):
|
||||
answer = ops_client.post(
|
||||
"/api/v1/knowledge/questions",
|
||||
json={"question": "What must I do when a vehicle returns with damage?"},
|
||||
).json()
|
||||
payload = {"correlation_id": answer["correlation_id"], "helpful": True}
|
||||
assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200
|
||||
payload["helpful"] = False
|
||||
assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200
|
||||
|
||||
events = ops_client.get(
|
||||
"/api/v1/audit", params={"action": "knowledge_feedback_recorded"}
|
||||
).json()
|
||||
matching = [e for e in events if e["correlation_id"] == answer["correlation_id"]]
|
||||
assert len(matching) == 1
|
||||
assert matching[0]["metadata"]["helpful"] is False
|
||||
|
||||
|
||||
def test_knowledge_feedback_cannot_target_another_users_exchange(client):
|
||||
assert client.post("/api/v1/demo/login", json={"role": "rental_employee"}).status_code == 200
|
||||
answer = client.post(
|
||||
"/api/v1/knowledge/questions",
|
||||
json={"question": "How do I register a vehicle return?"},
|
||||
).json()
|
||||
assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200
|
||||
response = client.post(
|
||||
"/api/v1/knowledge/feedback",
|
||||
json={"correlation_id": answer["correlation_id"], "helpful": True},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
@@ -411,6 +616,35 @@ def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypa
|
||||
)
|
||||
|
||||
|
||||
def test_ragcore_answers_circuit_skips_repeated_generation_failure(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(provider._settings, "ragcore_answers_circuit_breaker_seconds", 60.0)
|
||||
search_response = _FakeResponse(200, _search_body())
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": search_response,
|
||||
}
|
||||
),
|
||||
)
|
||||
assert (
|
||||
provider.ask("What is the vehicle return procedure?", "first").evidence_state
|
||||
== "grounded"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_ask_via_answers",
|
||||
lambda *_args: (_ for _ in ()).throw(AssertionError("open circuit called answers")),
|
||||
)
|
||||
second = provider.ask("What is the vehicle return procedure?", "second")
|
||||
assert second.evidence_state == "grounded"
|
||||
|
||||
|
||||
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
@@ -450,3 +684,91 @@ def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkey
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_rejects_out_of_domain_question(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, _search_body()),
|
||||
}
|
||||
),
|
||||
)
|
||||
answer = provider.ask(
|
||||
"Who won the football world cup in 1998?",
|
||||
"test-correlation-out-of-domain",
|
||||
)
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_prefers_damage_procedure(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body()
|
||||
search["results"].append(
|
||||
{
|
||||
"citation": {
|
||||
"document_id": "damage-procedure",
|
||||
"document_version_id": "version-2",
|
||||
"title": "damage-procedure.md",
|
||||
"section": "Damage",
|
||||
"excerpt": "Record damage and keep the vehicle blocked.",
|
||||
},
|
||||
"rank": 2,
|
||||
"scores": {"fused": 0.01},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, search),
|
||||
}
|
||||
),
|
||||
)
|
||||
answer = provider.ask("Wat moet ik doen bij schade?", "test-correlation-damage", "nl-BE")
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert any(source.document_id == "damage-procedure" for source in answer.sources)
|
||||
|
||||
|
||||
def test_ragcore_search_fallback_accepts_top_ranked_ragcore_damage_evidence(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
search = _search_body()
|
||||
search["results"].append(
|
||||
{
|
||||
"citation": {
|
||||
"document_id": "damage-procedure",
|
||||
"document_version_id": "version-2",
|
||||
"title": "damage-procedure.md",
|
||||
"section": "Damage",
|
||||
"excerpt": "Record damage and keep the vehicle blocked.",
|
||||
},
|
||||
"rank": 1,
|
||||
# RAGcore uses reciprocal-rank fusion; a genuine rank-one result is about
|
||||
# 1 / (60 + 1), not a normalized 0..1 relevance score.
|
||||
"scores": {"fused": 0.01639344262295082},
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_responses={
|
||||
"/v1/answers": _FakeResponse(503, {}),
|
||||
"/v1/search": _FakeResponse(200, search),
|
||||
}
|
||||
),
|
||||
)
|
||||
answer = provider.ask("Wat moet ik doen bij schade?", "strong-damage", "nl-BE")
|
||||
assert answer.evidence_state == "grounded"
|
||||
assert answer.sources[0].document_id == "damage-procedure"
|
||||
|
||||
@@ -5,7 +5,7 @@ def _headers(token: str | None = None, client_id: str = "test-mcp-client"):
|
||||
settings = get_settings()
|
||||
return {
|
||||
"X-Service-Token": token if token is not None else settings.mcp_hub_service_token,
|
||||
"X-Client-Id": client_id,
|
||||
"X-Client-Id": f"itworx-mcp-hub:mobilityops:{client_id}",
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,31 @@ def test_operations_summary_requires_service_token(client):
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_operations_summary_rejects_spoofed_client_identity(client):
|
||||
settings = get_settings()
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary",
|
||||
headers={"X-Service-Token": settings.mcp_hub_service_token, "X-Client-Id": "spoofed"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_operations_summary_rejects_cross_tenant_request(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/operations-summary",
|
||||
headers={**_headers(), "X-Tenant-Id": "another-tenant"},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_operations_summary_returns_metrics(client):
|
||||
response = client.get("/api/v1/integrations/mcp/operations-summary", headers=_headers())
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "metrics" in body
|
||||
assert body["metrics"]["available"] >= 0
|
||||
assert response.headers["x-correlation-id"]
|
||||
assert response.headers["x-tenant-id"] == get_settings().ragcore_tenant
|
||||
|
||||
|
||||
def test_attention_vehicles_filters_by_severity(client):
|
||||
@@ -46,9 +65,7 @@ def test_attention_vehicles_respects_limit(client):
|
||||
|
||||
|
||||
def test_vehicle_details_known_ref(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers()
|
||||
)
|
||||
response = client.get("/api/v1/integrations/mcp/vehicles/MO-016", headers=_headers())
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["public_ref"] == "MO-016"
|
||||
@@ -56,9 +73,7 @@ def test_vehicle_details_known_ref(client):
|
||||
|
||||
|
||||
def test_vehicle_details_unknown_ref_is_404(client):
|
||||
response = client.get(
|
||||
"/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers()
|
||||
)
|
||||
response = client.get("/api/v1/integrations/mcp/vehicles/MO-999", headers=_headers())
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@@ -72,6 +87,8 @@ def test_search_knowledge_grounded_and_respects_max_sources(client):
|
||||
body = response.json()
|
||||
assert body["evidence_state"] == "grounded"
|
||||
assert len(body["sources"]) == 1
|
||||
assert int(response.headers["x-sources-available"]) >= 1
|
||||
assert response.headers["x-sources-returned"] == "1"
|
||||
|
||||
|
||||
def test_mcp_tool_requests_are_audited(client, ops_client):
|
||||
@@ -79,6 +96,8 @@ def test_mcp_tool_requests_are_audited(client, ops_client):
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
assert len(events) >= 1
|
||||
assert events[0]["actor_type"] == "service"
|
||||
assert events[0]["actor_label"] == "itworx-mcp-hub"
|
||||
assert events[0]["metadata"]["reported_client_id"].endswith(":probe-1")
|
||||
|
||||
|
||||
def test_search_knowledge_respects_requested_locale(client):
|
||||
@@ -118,7 +137,11 @@ def test_operations_summary_mints_correlation_id_when_none_supplied(client, ops_
|
||||
)
|
||||
assert response.status_code == 200
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "mcp_tool_request"}).json()
|
||||
matching = [e for e in events if e["actor_label"] == "no-correlation-probe"]
|
||||
matching = [
|
||||
e
|
||||
for e in events
|
||||
if e["metadata"].get("reported_client_id", "").endswith(":no-correlation-probe")
|
||||
]
|
||||
assert len(matching) >= 1
|
||||
assert matching[0]["correlation_id"] # a fresh UUID was minted, not left empty
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Guard against model/migration drift.
|
||||
|
||||
The functional suite builds its schema with ``Base.metadata.create_all`` for speed, so a
|
||||
column or index added to a model but never written into an Alembic migration would only
|
||||
surface on the first real deployment. This test runs the migration chain from an empty
|
||||
database and asserts that Alembic's autogenerate sees nothing left to do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic.autogenerate import compare_metadata
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
from alembic import command
|
||||
from app.core.config import get_settings
|
||||
from app.models import Base
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
SCRATCH_DB = "mobilityops_migration_check"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def migrated_database_url() -> str:
|
||||
settings = get_settings()
|
||||
base_url = make_url(settings.database_url)
|
||||
admin_engine = create_engine(
|
||||
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
|
||||
)
|
||||
with admin_engine.connect() as conn:
|
||||
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}"'))
|
||||
conn.execute(text(f'CREATE DATABASE "{SCRATCH_DB}"'))
|
||||
scratch_url = base_url.set(database=SCRATCH_DB).render_as_string(hide_password=False)
|
||||
try:
|
||||
yield scratch_url
|
||||
finally:
|
||||
admin_engine.dispose()
|
||||
admin_engine = create_engine(
|
||||
base_url.set(database="postgres"), isolation_level="AUTOCOMMIT", poolclass=None
|
||||
)
|
||||
with admin_engine.connect() as conn:
|
||||
conn.execute(text(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)'))
|
||||
admin_engine.dispose()
|
||||
|
||||
|
||||
def _alembic_config(database_url: str) -> Config:
|
||||
config = Config(str(BACKEND_DIR / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(BACKEND_DIR / "alembic"))
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
def test_migrations_upgrade_from_empty_and_match_models(migrated_database_url: str) -> None:
|
||||
config = _alembic_config(migrated_database_url)
|
||||
# env.py reads DATABASE_URL from settings; override it for the scratch database.
|
||||
import os
|
||||
|
||||
previous = os.environ.get("DATABASE_URL")
|
||||
os.environ["DATABASE_URL"] = migrated_database_url
|
||||
get_settings.cache_clear()
|
||||
try:
|
||||
command.upgrade(config, "head")
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
else:
|
||||
os.environ["DATABASE_URL"] = previous
|
||||
get_settings.cache_clear()
|
||||
|
||||
engine = create_engine(migrated_database_url)
|
||||
try:
|
||||
with engine.connect() as conn:
|
||||
context = MigrationContext.configure(
|
||||
conn, opts={"compare_type": True, "compare_server_default": False}
|
||||
)
|
||||
diff = compare_metadata(context, Base.metadata)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
assert diff == [], (
|
||||
"Models and Alembic migrations have drifted; write a migration for:\n"
|
||||
+ "\n".join(repr(entry) for entry in diff)
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.core.observability import JsonFormatter
|
||||
|
||||
|
||||
def test_request_correlation_id_is_echoed_and_used_in_errors(client):
|
||||
correlation_id = str(uuid.uuid4())
|
||||
response = client.get(
|
||||
"/api/v1/dashboard",
|
||||
headers={"X-Correlation-Id": correlation_id},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.headers["X-Correlation-Id"] == correlation_id
|
||||
assert response.json()["error"]["correlation_id"] == correlation_id
|
||||
|
||||
|
||||
def test_invalid_request_correlation_id_is_replaced(client):
|
||||
response = client.get("/health/live", headers={"X-Correlation-Id": "not-a-uuid"})
|
||||
assert response.status_code == 200
|
||||
assert uuid.UUID(response.headers["X-Correlation-Id"])
|
||||
|
||||
|
||||
def test_metrics_expose_http_database_and_outbox_state(client):
|
||||
client.get("/health/ready")
|
||||
response = client.get("/metrics")
|
||||
assert response.status_code == 200
|
||||
assert "mobilityops_http_requests_total" in response.text
|
||||
assert "mobilityops_database_ready 1.0" in response.text
|
||||
assert 'mobilityops_outbox_events{scenario="synthetic",status="failed"}' in response.text
|
||||
assert "mobilityops_knowledge_provider_requests_total" in response.text
|
||||
assert "mobilityops_knowledge_retrieval_score" in response.text
|
||||
|
||||
|
||||
def test_metrics_token_is_enforced_when_configured(client, monkeypatch):
|
||||
import app.api.routers.observability as router
|
||||
|
||||
monkeypatch.setattr(router.settings, "metrics_bearer_token", "metrics-secret")
|
||||
assert client.get("/metrics").status_code == 401
|
||||
assert (
|
||||
client.get("/metrics", headers={"Authorization": "Bearer metrics-secret"}).status_code
|
||||
== 200
|
||||
)
|
||||
|
||||
|
||||
def test_json_formatter_emits_machine_readable_fields():
|
||||
record = logging.LogRecord("mobilityops.test", logging.INFO, __file__, 1, "ready", (), None)
|
||||
record.status_code = 200
|
||||
payload = json.loads(JsonFormatter().format(record))
|
||||
assert payload["message"] == "ready"
|
||||
assert payload["status_code"] == 200
|
||||
assert payload["timestamp"].endswith("+00:00")
|
||||
@@ -0,0 +1,36 @@
|
||||
from app.api.routers import auth as auth_router
|
||||
from app.core.db import SessionLocal
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def test_password_login_is_available_only_outside_demo_mode(ops_client, monkeypatch):
|
||||
user = User(
|
||||
public_ref="USR-REAL",
|
||||
email="manager@example.test",
|
||||
password_hash=hash_password("correct-horse-battery-staple"),
|
||||
display_name="Real Manager",
|
||||
role="operations_manager",
|
||||
active=True,
|
||||
)
|
||||
with SessionLocal() as db:
|
||||
db.add(user)
|
||||
db.commit()
|
||||
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
|
||||
|
||||
response = ops_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "manager@example.test", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["public_ref"] == "USR-REAL"
|
||||
assert ops_client.get("/api/v1/auth/session").json()["display_name"] == "Real Manager"
|
||||
|
||||
|
||||
def test_password_login_rejects_invalid_credentials(ops_client, monkeypatch):
|
||||
monkeypatch.setattr(auth_router.settings, "mobilityops_demo_mode", False)
|
||||
response = ops_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "unknown@example.test", "password": "correct-horse-battery-staple"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
@@ -0,0 +1,96 @@
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.db import SessionLocal
|
||||
from app.models.audit import AuditEvent
|
||||
from app.models.customer import Customer
|
||||
|
||||
|
||||
def test_privacy_retention_uses_persisted_counts(ops_client):
|
||||
response = ops_client.get("/api/v1/privacy/retention")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["customers_total"] >= 180
|
||||
assert body["minimum_booking_retention_days"] == 30
|
||||
assert body["audit_retention_days"] == 2555
|
||||
|
||||
|
||||
def test_privacy_retention_requires_manager(employee_client):
|
||||
assert employee_client.get("/api/v1/privacy/retention").status_code == 403
|
||||
|
||||
|
||||
def test_customer_export_is_downloadable_and_audited_without_mutation(ops_client):
|
||||
response = ops_client.get("/api/v1/privacy/customers/CUS-0001/export")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-disposition"] == 'attachment; filename="CUS-0001-privacy.json"'
|
||||
assert response.json()["customer"]["public_ref"] == "CUS-0001"
|
||||
assert isinstance(response.json()["bookings"], list)
|
||||
events = ops_client.get("/api/v1/audit", params={"action": "privacy_customer_exported"}).json()
|
||||
assert any(event["metadata"]["customer_ref"] == "CUS-0001" for event in events)
|
||||
|
||||
|
||||
def test_active_customer_cannot_be_anonymized(ops_client):
|
||||
response = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-0042/anonymize",
|
||||
json={"confirmation": "CUS-0042", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_eligible_customer_is_irreversibly_anonymized_without_pii_in_audit(ops_client):
|
||||
with SessionLocal() as db:
|
||||
customer = Customer(
|
||||
public_ref="CUS-PRIVACY",
|
||||
first_name="Private",
|
||||
last_name="Person",
|
||||
email="private.person@example.test",
|
||||
phone="+32000000000",
|
||||
postal_code="1000",
|
||||
city="Brussels",
|
||||
date_of_birth=None,
|
||||
)
|
||||
db.add(customer)
|
||||
db.commit()
|
||||
customer_id = customer.id
|
||||
try:
|
||||
mismatch = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
|
||||
json={"confirmation": "CUS-WRONG", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert mismatch.status_code == 422
|
||||
response = ops_client.post(
|
||||
"/api/v1/privacy/customers/CUS-PRIVACY/anonymize",
|
||||
json={"confirmation": "CUS-PRIVACY", "reason": "Verified erasure request"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "anonymized"
|
||||
with SessionLocal() as db:
|
||||
customer = db.scalar(select(Customer).where(Customer.id == customer_id))
|
||||
assert customer is not None
|
||||
assert customer.email is None
|
||||
assert customer.phone is None
|
||||
assert customer.first_name == "Anoniem"
|
||||
event = db.scalar(
|
||||
select(AuditEvent)
|
||||
.where(AuditEvent.action == "privacy_customer_anonymized")
|
||||
.order_by(AuditEvent.occurred_at.desc())
|
||||
)
|
||||
assert event is not None
|
||||
serialized = f"{event.before_json}{event.after_json}{event.metadata_json}"
|
||||
assert "private.person@example.test" not in serialized
|
||||
finally:
|
||||
with SessionLocal() as db:
|
||||
db.execute(delete(AuditEvent).where(AuditEvent.entity_id == customer_id))
|
||||
db.execute(delete(Customer).where(Customer.id == customer_id))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_audit_csv_export_is_bounded_and_audited(ops_client):
|
||||
response = ops_client.get("/api/v1/audit/export.csv")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"].startswith("text/csv")
|
||||
assert response.text.startswith("id,occurred_at,actor_type")
|
||||
invalid = ops_client.get(
|
||||
"/api/v1/audit/export.csv",
|
||||
params={"occurred_from": "2025-01-01T00:00:00Z", "occurred_to": "2026-01-01T00:00:00Z"},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
@@ -120,6 +120,8 @@ def test_preview_performs_no_writes_and_matches_commit(ops_client):
|
||||
assert commit_body["resulting_vehicle_status"] == preview_body["resulting_vehicle_status"]
|
||||
assert commit_body["odometer_regression"] == preview_body["odometer_regression"]
|
||||
assert commit_body["next_booking_risk"] == preview_body["next_booking_risk"]
|
||||
assert commit_body["correlation_id"]
|
||||
assert len(commit_body["correlation_id"]) == 36
|
||||
|
||||
|
||||
def test_preview_detects_odometer_regression(ops_client):
|
||||
@@ -305,3 +307,11 @@ def test_concurrent_returns_only_one_succeeds():
|
||||
|
||||
assert results.count(201) == 1
|
||||
assert results.count(409) == 2
|
||||
|
||||
|
||||
def test_return_public_refs_are_collision_resistant() -> None:
|
||||
from app.services.returns import _new_inspection_ref
|
||||
|
||||
refs = {_new_inspection_ref() for _ in range(1000)}
|
||||
assert len(refs) == 1000
|
||||
assert all(ref.startswith("INSP-") and len(ref) == 15 for ref in refs)
|
||||
|
||||
@@ -56,9 +56,7 @@ def test_seed_demo_scenarios_present():
|
||||
assert duplicate_issue is not None
|
||||
assert duplicate_issue.rule_type == "possible_duplicate_customer"
|
||||
|
||||
failed_run = db.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.delivery_status == "failed"))
|
||||
assert failed_run is not None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
def test_manager_can_create_and_update_user(ops_client):
|
||||
created = ops_client.post(
|
||||
"/api/v1/users",
|
||||
json={
|
||||
"email": "planner@example.test",
|
||||
"display_name": "Fleet Planner",
|
||||
"role": "rental_employee",
|
||||
"password": "a-secure-demo-password",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
public_ref = created.json()["public_ref"]
|
||||
assert created.json()["active"] is True
|
||||
updated = ops_client.patch(
|
||||
f"/api/v1/users/{public_ref}",
|
||||
json={"display_name": "Senior Fleet Planner", "active": False},
|
||||
)
|
||||
assert updated.status_code == 200
|
||||
assert updated.json()["display_name"] == "Senior Fleet Planner"
|
||||
assert updated.json()["active"] is False
|
||||
assert any(user["public_ref"] == public_ref for user in ops_client.get("/api/v1/users").json())
|
||||
|
||||
|
||||
def test_employee_cannot_manage_users(employee_client):
|
||||
assert employee_client.get("/api/v1/users").status_code == 403
|
||||
@@ -36,9 +36,7 @@ def _facts(**overrides) -> VehicleStatusFacts:
|
||||
|
||||
|
||||
def test_available_with_active_rental_recommends_rented():
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"), _facts(active_booking_refs=["BK-0001"])
|
||||
)
|
||||
result = evaluate_vehicle_status(_vehicle("available"), _facts(active_booking_refs=["BK-0001"]))
|
||||
assert result.recommended_status == "rented"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
|
||||
assert result.safe_to_apply is True
|
||||
@@ -83,9 +81,7 @@ def test_rented_with_no_active_booking_recommends_available():
|
||||
|
||||
|
||||
def test_service_threshold_reached_recommends_maintenance():
|
||||
result = evaluate_vehicle_status(
|
||||
_vehicle("available"), _facts(service_threshold_reached=True)
|
||||
)
|
||||
result = evaluate_vehicle_status(_vehicle("available"), _facts(service_threshold_reached=True))
|
||||
assert result.recommended_status == "maintenance"
|
||||
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
|
||||
|
||||
|
||||