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:
NuklearRabbit
2026-08-03 13:45:55 +02:00
parent 728e380d63
commit ac427f4427
25 changed files with 919 additions and 108 deletions
+16 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import time
import uuid
from fastapi import APIRouter, Depends, Request, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -11,14 +11,23 @@ from app.api.deps import get_current_user, get_db, require_operations_manager
from app.core.config import get_settings
from app.core.security import SessionPayload, create_session_token, read_session_token
from app.models.user import User
from app.schemas import CurrentUser, DemoLoginRequest
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
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
@router.get("/manifest", response_model=DemoManifestOut)
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
# 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.
return build_demo_manifest(db)
@router.post("/login", response_model=CurrentUser)
def demo_login(
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
@@ -92,6 +101,11 @@ def demo_reset(
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> dict:
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)
record_audit_event(
db,
+2 -49
View File
@@ -1,75 +1,28 @@
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends
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.outbox import OutboxEvent
from app.schemas import (
CurrentUser,
IntegrationStatusOut,
McpHubIntegrationStatus,
N8nIntegrationStatus,
)
from app.services.integration_status import derive_n8n_status
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
settings = get_settings()
def _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,
)
@router.get("/status", response_model=IntegrationStatusOut)
def integration_status(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> IntegrationStatusOut:
return IntegrationStatusOut(
n8n=_n8n_status(db),
n8n=derive_n8n_status(db),
mcp_hub=McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
+3
View File
@@ -32,6 +32,9 @@ class Settings(BaseSettings):
mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False
cors_allow_origins: str = "http://localhost:1228"
demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels"
demo_allow_reset: bool = True
@lru_cache
+34
View File
@@ -197,6 +197,40 @@ class IntegrationStatusOut(BaseModel):
mcp_hub: McpHubIntegrationStatus
class DemoScenarioOut(BaseModel):
id: str
title: str
operational_problem: str
estimated_minutes: int
required_roles: list[Role]
start_path: str
demonstrates: str
ready: bool
blocked_reason: str | None = None
class DemoIntegrationSummaryOut(BaseModel):
key: Literal["n8n", "ragcore", "mcp_hub"]
label: str
status_label: str
detail: str
class DemoManifestOut(BaseModel):
demo_mode: bool
organization_name: str
organization_description: str
timezone: str
synthetic_data: bool
allow_reset: bool
last_reset_at: datetime | None
anchor_date: str | None
guide_available: bool
required_roles: list[Role]
scenarios: list[DemoScenarioOut]
integrations: list[DemoIntegrationSummaryOut]
class VehicleDetailOut(VehicleOut):
bookings: list[BookingSummaryOut] = Field(default_factory=list)
inspections: list[InspectionOut] = Field(default_factory=list)
+241
View File
@@ -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,
)