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:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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