Files
MobilityOps/backend/app/services/demo_manifest.py
T
NuklearRabbit ac427f4427 feat(demo): add demo manifest, Dutch demo entry, permanent badge and About page
Adds GET /api/v1/demo/manifest as a single source of truth for the demo's
fictional org identity (Northstar Mobility -- surfacing the project's
already-locked tenant name), synthetic-data/reset state, and live scenario
readiness. Rewrites the login screen in Dutch with an honest, no-password
demo entry and a guided-demo entry point, replaces the loud full-width
demo banner with a subtle badge + popover, and adds a compact About page
explaining what's real vs. synthetic vs. not yet connected.
2026-08-03 13:45:55 +02:00

242 lines
9.6 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 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),
)