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.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
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),
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models.outbox import OutboxEvent
|
||||
from app.schemas import N8nIntegrationStatus
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
|
||||
counts: dict[str, int] = dict(
|
||||
db.execute(
|
||||
select(OutboxEvent.delivery_status, func.count()).group_by(OutboxEvent.delivery_status)
|
||||
).all() # type: ignore[arg-type]
|
||||
)
|
||||
pending = counts.get("pending", 0)
|
||||
delivering = counts.get("delivering", 0)
|
||||
failed = counts.get("failed", 0)
|
||||
succeeded = counts.get("succeeded", 0)
|
||||
|
||||
latest_success_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
|
||||
)
|
||||
latest_failure_at = db.scalar(
|
||||
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed")
|
||||
)
|
||||
|
||||
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
|
||||
if not settings.n8n_dispatch_enabled:
|
||||
state = "disabled"
|
||||
elif failed > 0 and succeeded == 0:
|
||||
state = "unavailable"
|
||||
elif failed > 0:
|
||||
state = "degraded"
|
||||
elif succeeded > 0 or pending > 0 or delivering > 0:
|
||||
state = "operational"
|
||||
else:
|
||||
state = "no_evidence"
|
||||
|
||||
return N8nIntegrationStatus(
|
||||
configured=bool(settings.n8n_webhook_url),
|
||||
dispatch_enabled=settings.n8n_dispatch_enabled,
|
||||
state=state,
|
||||
pending=pending,
|
||||
delivering=delivering,
|
||||
failed=failed,
|
||||
succeeded=succeeded,
|
||||
latest_success_at=latest_success_at,
|
||||
latest_failure_at=latest_failure_at,
|
||||
)
|
||||
Reference in New Issue
Block a user