Files
MobilityOps/backend/app/services/demo_manifest.py
T
NuklearRabbit 5fa4fe0811 feat(demo): plain-language integration status, richer audit, reset integrity
Integration status badges across Dashboard/Automation now show honest
plain-language labels instead of raw backend state strings (and fix a
few states that had no matching CSS colour class at all). Audit trail
gets a "view related events" action reusing the existing correlation_id
filter. About page gains scope/architecture/security/testing sections
and a guided-demo entry point. POST /api/v1/demo/reset now runs and
records a server-side scenario-integrity check. Also fixes a second real
race condition (caught by the return-review e2e test): the odometer
scenario pre-fill now resolves before ReturnForm mounts instead of
patching its value in after the fact.
2026-08-03 15:00:11 +02:00

256 lines
10 KiB
Python

from __future__ import annotations
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
from app.services.integration_status import derive_n8n_status
from app.services.knowledge import get_knowledge_provider
settings = get_settings()
# The default name matches the project's locked fictitious tenant (see PROJECT_STATE.md
# "Locked decisions"; the same slug already backs `ragcore_tenant`) — this surfaces that
# existing decision in the UI rather than inventing a new one. Configurable via
# DEMO_ORGANIZATION_NAME so a redeployment can rebrand the fictional org without a code change.
ORGANIZATION_DESCRIPTION = (
"MobilityOps brengt voertuig-, boekings- en operationele gegevens samen, "
"ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en "
"automatiseert gecontroleerde vervolgstappen."
)
_FAILED_DEMO_EVENT_ID = "00000000-0000-4000-8000-000000000020"
_N8N_STATE_LABELS = {
"disabled": "Niet gekoppeld",
"unavailable": "Verwerking mislukt",
"degraded": "Opnieuw proberen mogelijk",
"operational": "Operationeel",
"no_evidence": "Voorbereid",
}
def _last_reset(db: Session) -> tuple[datetime | None, str | None]:
marker = db.scalar(
select(AuditEvent)
.where(AuditEvent.action == "demo_data_seeded")
.order_by(AuditEvent.occurred_at.desc())
)
if marker is None:
return None, None
metadata = marker.metadata_json or {}
return marker.occurred_at, metadata.get("anchor_date")
def _scenarios(db: Session) -> list[DemoScenarioOut]:
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
duplicate_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-DUPLICATE")
)
overlap_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
)
failed_run = db.scalar(
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
)
knowledge_health = get_knowledge_provider().health()
reset_hint = "Reset de demo-data om dit scenario opnieuw beschikbaar te maken."
return [
DemoScenarioOut(
id="return-anomaly",
title="Retour met afwijkende kilometerstand",
operational_problem=(
"Een voertuig komt terug met een kilometerstand die lager ligt dan de "
"laatst geregistreerde stand — een teken van een foutieve invoer of een "
"verwisseld voertuig."
),
estimated_minutes=3,
required_roles=["rental_employee", "operations_manager"],
start_path=f"/bookings/{booking.public_ref}" if booking else "/bookings",
demonstrates=(
"Retourverwerking, automatische detectie van datakwaliteitsproblemen en de "
"audit trail die daaruit ontstaat."
),
ready=bool(
booking and booking.status == "active" and booking.end_odometer_km is None
),
blocked_reason=(
None
if booking and booking.status == "active" and booking.end_odometer_km is None
else (
f"Demoboeking BK-DEMO-RETURN niet gevonden. {reset_hint}"
if booking is None
else f"Deze boeking is al verwerkt sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="duplicate-customer",
title="Mogelijke dubbele klant samenvoegen",
operational_problem=(
"Twee klantprofielen delen hetzelfde e-mailadres en telefoonnummer — "
"waarschijnlijk dezelfde persoon, twee keer geregistreerd."
),
estimated_minutes=3,
required_roles=["operations_manager"],
start_path=(
f"/data-quality/{duplicate_issue.public_ref}"
if duplicate_issue
else "/data-quality"
),
demonstrates=(
"Samenvoegen van klanten met behoud van boekingsgeschiedenis en audit trail."
),
ready=bool(duplicate_issue and duplicate_issue.status == "open"),
blocked_reason=(
None
if duplicate_issue and duplicate_issue.status == "open"
else (
f"Demo-issue DQ-DEMO-DUPLICATE niet gevonden. {reset_hint}"
if duplicate_issue is None
else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="booking-overlap",
title="Overlappende boekingen herstellen",
operational_problem=(
"Eén voertuig staat dubbel gereserveerd voor overlappende periodes — een "
"planningsfout die vóór vertrek moet worden opgelost."
),
estimated_minutes=2,
required_roles=["operations_manager"],
start_path=(
f"/data-quality/{overlap_issue.public_ref}" if overlap_issue else "/data-quality"
),
demonstrates="Detectie en gecontroleerde oplossing van planningsconflicten.",
ready=bool(overlap_issue and overlap_issue.status == "open"),
blocked_reason=(
None
if overlap_issue and overlap_issue.status == "open"
else (
f"Demo-issue DQ-DEMO-OVERLAP niet gevonden. {reset_hint}"
if overlap_issue is None
else f"Dit issue is al opgelost sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="automation-retry",
title="Mislukte automatisering opnieuw proberen",
operational_problem=(
"Eén eerdere gebeurtenis kon niet worden afgeleverd aan de automatisering "
"door een gesimuleerde verbindingsfout."
),
estimated_minutes=2,
required_roles=["operations_manager"],
start_path="/automation",
demonstrates=(
"Betrouwbare aflevering met begrensde herpogingen en zichtbare foutstatus."
),
ready=bool(failed_run and failed_run.delivery_status == "failed"),
blocked_reason=(
None
if failed_run and failed_run.delivery_status == "failed"
else (
f"Gesimuleerde mislukte gebeurtenis niet gevonden. {reset_hint}"
if failed_run is None
else f"Deze gebeurtenis is al hersteld sinds de laatste reset. {reset_hint}"
)
),
),
DemoScenarioOut(
id="knowledge-question",
title="Een procedurevraag stellen",
operational_problem=(
"Een medewerker weet niet zeker welke procedure van toepassing is bij een "
"specifieke operationele situatie."
),
estimated_minutes=2,
required_roles=["rental_employee", "operations_manager"],
start_path="/knowledge",
demonstrates=(
"Antwoorden met brongebaseerde onderbouwing uit een afgebakende demokennisbank."
),
ready=knowledge_health.available,
blocked_reason=(
None
if knowledge_health.available
else "De demokennisbank is momenteel niet beschikbaar."
),
),
]
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
n8n = derive_n8n_status(db)
knowledge_health = get_knowledge_provider().health()
return [
DemoIntegrationSummaryOut(
key="n8n",
label="Automatisering (n8n)",
status_label=_N8N_STATE_LABELS.get(n8n.state, n8n.state),
detail=f"{n8n.succeeded} geslaagd, {n8n.failed} mislukt, {n8n.pending} in wachtrij.",
),
DemoIntegrationSummaryOut(
key="ragcore",
label="Kennisassistent (RAGcore)",
status_label=(
"Demomodus — lokale kennisprovider"
if knowledge_health.provider != "ragcore"
else "Operationeel"
),
detail=knowledge_health.detail,
),
DemoIntegrationSummaryOut(
key="mcp_hub",
label="ITWorx MCP Hub",
status_label=(
"Operationeel" if settings.mcp_hub_registration_enabled else "Niet gekoppeld"
),
detail="Voorbereid voor toekomstige, gecontroleerde tool-aanroepen vanuit de Hub.",
),
]
def scenario_integrity_report(db: Session) -> dict:
"""Server-side scenario-integrity check run after every reset (section 15): confirms
each of the 5 named scenarios is actually present and ready, rather than trusting the
seed loader silently. Reuses the same readiness derivation the manifest/scenario
overview already use, so this can never drift from what a visitor actually sees."""
scenarios = _scenarios(db)
not_ready = [
{"id": s.id, "title": s.title, "reason": s.blocked_reason}
for s in scenarios
if not s.ready
]
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
def build_demo_manifest(db: Session) -> DemoManifestOut:
last_reset_at, anchor_date = _last_reset(db)
return DemoManifestOut(
demo_mode=settings.mobilityops_demo_mode,
organization_name=settings.demo_organization_name,
organization_description=ORGANIZATION_DESCRIPTION,
timezone=settings.demo_timezone,
synthetic_data=True,
allow_reset=settings.demo_allow_reset,
last_reset_at=last_reset_at,
anchor_date=anchor_date,
guide_available=True,
required_roles=["operations_manager", "rental_employee"],
scenarios=_scenarios(db),
integrations=_integrations(db),
)