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.
256 lines
10 KiB
Python
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),
|
|
)
|