diff --git a/.env.example b/.env.example index 7203756..9c0a478 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,6 @@ POSTGRES_DB=mobilityops POSTGRES_USER=mobilityops POSTGRES_PASSWORD=mobilityops APP_SECRET=replace-in-production -DEMO_TODAY=2026-08-01 TZ=Europe/Brussels # Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the # current Unraid review environment); set true only once MobilityOps is served over HTTPS, diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 341b408..68fcb7f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -507,3 +507,38 @@ any change at `docs/functional-completion/server-baseline.md`. re-verified against the live server after every batch. See `artifacts/functional-completion/final-summary.md` for the definitive acceptance evidence. + +## Demo productization (in progress, same branch `feat/mobilityops-functional-completion`) + +Follows the functional-completion work above; turns the now feature-complete PoC into a +guided, honestly-labelled demo (fictional org "Northstar Mobility", guided tour, 5 named +scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-demo-gap-audit.md`. + +### Batch 1 — seed date anchoring (complete) + +- **Real bug fixed**: `seed/bookings.csv` etc. store absolute ISO timestamps authored + around a fixed anchor (`2026-08-01`). Nothing previously re-anchored them at seed/reset + time, so scenario bookings (e.g. `BK-DEMO-RETURN`) silently drifted into the past every + day the environment wasn't reset. `dashboard.py::_today()` compounded this by filtering + "today's movements" against the same frozen `demo_today` setting instead of real time. +- Fix: `seed_loader.py` now computes `shift = today - SEED_AUTHORED_ANCHOR` once per + `load_seed()` call and applies it to every seeded booking/inspection/maintenance/outbox + datetime column, so scenarios stay "today"/"near-future" relative to the actual reset + moment. `SeedResult` now also carries `anchor_date`/`seeded_at`; `POST /api/v1/demo/reset` + returns them; a `demo_data_seeded` audit event records the anchor for traceability. + `dashboard.py::_today()` switched from the frozen `demo_today` setting to real wall-clock + UTC date. The now-dead `demo_today` setting/env var was removed from `config.py`, + `compose.yaml`, `.env`, `.env.example` (nothing else referenced it). +- Added seed-validation tests (`backend/tests/test_seed.py`) proving S1 (`BK-DEMO-RETURN`/ + `MO-024`), S2 (`CUS-0012`/`CUS-0178`/`DQ-DEMO-DUPLICATE`), S4 (`MO-016`/ + `BK-DEMO-OVERLAP-A`/`-B`/`DQ-DEMO-OVERLAP`) and S5 (seeded failed outbox event + `00000000-0000-4000-8000-000000000020`, confirmed genuinely `failed` immediately after a + fresh reset, not silently auto-healed by the background dispatcher since it only claims + `pending` rows) are fully present after every reset, plus a dedicated anchoring test + asserting the shift and the audit marker. +- Live-verified locally: reseeded and confirmed via `psql` that `BK-DEMO-RETURN` now ends + today and `BK-DEMO-NEXT`/overlap bookings sit in the near future (today = 2026-08-03). +- Evidence: `pytest` **122 passed** (117 + 5 new/expanded seed tests), `ruff check .` + clean, `mypy app` clean (46 files, canonical `make` scope). +- Exact next action: `GET /api/v1/demo/manifest` + Dutch demo entry screen + permanent + demo badge (task #30), then the Demo Guide + scenario overview (task #31). diff --git a/backend/app/api/routers/dashboard.py b/backend/app/api/routers/dashboard.py index 7042107..6253019 100644 --- a/backend/app/api/routers/dashboard.py +++ b/backend/app/api/routers/dashboard.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import UTC, date, datetime from typing import Literal from fastapi import APIRouter, Depends @@ -30,7 +30,9 @@ _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} def _today() -> date: - return datetime.fromisoformat(settings.demo_today).date() + # Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor + # shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting. + return datetime.now(UTC).date() @router.get("", response_model=DashboardOut) diff --git a/backend/app/api/routers/demo.py b/backend/app/api/routers/demo.py index 85c3b6f..0ba66f7 100644 --- a/backend/app/api/routers/demo.py +++ b/backend/app/api/routers/demo.py @@ -99,8 +99,16 @@ def demo_reset( actor_label=user.display_name, action="demo_reset", entity_type="system", - metadata={"counts": result.counts}, + metadata={ + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + }, ) db.commit() response.delete_cookie(settings.session_cookie_name) - return {"status": "reset", "counts": result.counts} + return { + "status": "reset", + "counts": result.counts, + "anchor_date": result.anchor_date.isoformat(), + "seeded_at": result.seeded_at.isoformat(), + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 14470d6..8f7a49d 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -32,7 +32,6 @@ 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_today: str = "2026-08-01" @lru_cache diff --git a/backend/app/seed_loader.py b/backend/app/seed_loader.py index 041cc05..938d219 100644 --- a/backend/app/seed_loader.py +++ b/backend/app/seed_loader.py @@ -3,7 +3,7 @@ from __future__ import annotations import csv import uuid from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import UTC, date, datetime, timedelta from pathlib import Path from sqlalchemy import delete, insert, update @@ -20,6 +20,7 @@ from app.models.maintenance import MaintenanceRecord from app.models.outbox import OutboxEvent from app.models.user import User from app.models.vehicle import Vehicle +from app.services.audit import record_audit_event settings = get_settings() @@ -36,6 +37,17 @@ DEMO_USERS = [ }, ] +# seed/generate_seed.py authored the committed CSVs relative to this fixed date +# (`--anchor 2026-08-01`, matching Settings.demo_today). Every reset shifts every +# seeded date by (today - SEED_AUTHORED_ANCHOR) so "today" / "near-future" / "overlaps +# right now" scenarios stay true to the actual reset moment instead of decaying as real +# time passes between resets -- a fixed anchor with no shift goes stale within days. +SEED_AUTHORED_ANCHOR = date(2026, 8, 1) + + +def _seed_anchor_shift(today: date) -> timedelta: + return today - SEED_AUTHORED_ANCHOR + def _parse_dt(value: str) -> datetime: return datetime.fromisoformat(value.replace("Z", "+00:00")) @@ -53,6 +65,8 @@ def _parse_optional_int(value: str) -> int | None: @dataclass class SeedResult: counts: dict[str, int] + anchor_date: date + seeded_at: datetime def _seed_dir() -> Path: @@ -83,6 +97,8 @@ def clear_all(db: Session) -> None: def load_seed(db: Session) -> SeedResult: counts: dict[str, int] = {} + today = datetime.now(UTC).date() + shift = _seed_anchor_shift(today) user_rows = [ {"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS @@ -154,8 +170,8 @@ def load_seed(db: Session) -> SeedResult: "public_ref": row["public_ref"], "customer_id": customer_id_by_ref[row["customer_ref"]], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], - "starts_at": _parse_dt(row["starts_at"]), - "ends_at": _parse_dt(row["ends_at"]), + "starts_at": _parse_dt(row["starts_at"]) + shift, + "ends_at": _parse_dt(row["ends_at"]) + shift, "status": row["status"], "start_odometer_km": _parse_optional_int(row["start_odometer_km"]), "end_odometer_km": _parse_optional_int(row["end_odometer_km"]), @@ -179,7 +195,7 @@ def load_seed(db: Session) -> SeedResult: "damage_reported": _parse_bool(row["damage_reported"]), "technical_warning": _parse_bool(row["technical_warning"]), "odometer_km": int(row["odometer_km"]), - "completed_at": _parse_dt(row["completed_at"]), + "completed_at": _parse_dt(row["completed_at"]) + shift, "completed_by": None, } ) @@ -193,7 +209,7 @@ def load_seed(db: Session) -> SeedResult: "id": uuid.uuid4(), "public_ref": row["public_ref"], "vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]], - "occurred_at": _parse_dt(row["occurred_at"]), + "occurred_at": _parse_dt(row["occurred_at"]) + shift, "odometer_km": int(row["odometer_km"]), "category": row["category"], "summary": row["summary"], @@ -266,7 +282,7 @@ def load_seed(db: Session) -> SeedResult: }, "aggregate_ref": row["aggregate_ref"], }, - "occurred_at": _parse_dt(row["occurred_at"]), + "occurred_at": _parse_dt(row["occurred_at"]) + shift, "delivery_status": row["status"], "attempts": int(row["attempts"]), "next_attempt_at": None, @@ -277,7 +293,21 @@ def load_seed(db: Session) -> SeedResult: db.execute(insert(OutboxEvent), outbox_rows) counts["workflow_runs"] = len(outbox_rows) - return SeedResult(counts=counts) + seeded_at = datetime.now(UTC) + record_audit_event( + db, + actor_type="system", + actor_label="seed loader", + action="demo_data_seeded", + entity_type="system", + metadata={ + "anchor_date": today.isoformat(), + "seed_authored_anchor": SEED_AUTHORED_ANCHOR.isoformat(), + "counts": counts, + }, + ) + + return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at) def reset_and_seed(db: Session) -> SeedResult: diff --git a/backend/tests/test_seed.py b/backend/tests/test_seed.py index 84ea3bd..076cd1a 100644 --- a/backend/tests/test_seed.py +++ b/backend/tests/test_seed.py @@ -1,13 +1,16 @@ +from datetime import UTC, datetime + from sqlalchemy import func, select from app.core.db import SessionLocal +from app.models.audit import AuditEvent from app.models.booking import Booking from app.models.customer import Customer from app.models.data_quality import DataQualityIssue from app.models.outbox import OutboxEvent from app.models.user import User from app.models.vehicle import Vehicle -from app.seed_loader import reset_and_seed +from app.seed_loader import SEED_AUTHORED_ANCHOR, reset_and_seed def test_seed_counts_match_deterministic_dataset(): @@ -52,3 +55,117 @@ def test_seed_demo_scenarios_present(): assert failed_run is not None finally: db.close() + + +def _by_ref(db, model, ref): + return db.scalar(select(model).where(model.public_ref == ref)) + + +def test_seed_scenario_s1_odometer_regression_return(): + """S1: BK-DEMO-RETURN on MO-024 is an active booking ready for a return with a + below-canonical odometer reading, using the vehicle's own current odometer.""" + db = SessionLocal() + try: + reset_and_seed(db) + booking = _by_ref(db, Booking, "BK-DEMO-RETURN") + vehicle = _by_ref(db, Vehicle, "MO-024") + assert booking is not None and vehicle is not None + assert booking.vehicle_id == vehicle.id + assert booking.status == "active" + assert booking.end_odometer_km is None + # A demo return reading must sit below the vehicle's canonical odometer to + # reproduce the odometer-regression anomaly deterministically. + assert vehicle.odometer_km > 0 + finally: + db.close() + + +def test_seed_scenario_s2_duplicate_customer_pair(): + """S2: CUS-0012/CUS-0178 form a possible-duplicate pair with a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + primary = _by_ref(db, Customer, "CUS-0012") + duplicate = _by_ref(db, Customer, "CUS-0178") + assert primary is not None and duplicate is not None + assert primary.email == duplicate.email + assert duplicate.merged_into_customer_id is None + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-DUPLICATE") + assert issue is not None + assert issue.rule_type == "possible_duplicate_customer" + assert issue.status == "open" + related = issue.evidence_json.get("related_refs", []) + assert "CUS-0012" in related or "CUS-0178" in related + finally: + db.close() + + +def test_seed_scenario_s4_booking_overlap(): + """S4: MO-016 carries two overlapping reservations plus a matching open issue.""" + db = SessionLocal() + try: + reset_and_seed(db) + vehicle = _by_ref(db, Vehicle, "MO-016") + booking_a = _by_ref(db, Booking, "BK-DEMO-OVERLAP-A") + booking_b = _by_ref(db, Booking, "BK-DEMO-OVERLAP-B") + assert vehicle is not None and booking_a is not None and booking_b is not None + assert booking_a.vehicle_id == vehicle.id + assert booking_b.vehicle_id == vehicle.id + assert booking_a.starts_at < booking_b.ends_at + assert booking_b.starts_at < booking_a.ends_at + + issue = _by_ref(db, DataQualityIssue, "DQ-DEMO-OVERLAP") + assert issue is not None + assert issue.rule_type == "booking_overlap" + assert issue.status == "open" + finally: + db.close() + + +def test_seed_scenario_s5_failed_workflow_run(): + """S5: one seeded outbox event is durably 'failed' (terminal, retryable), not merely + pending, so the background dispatcher never silently auto-heals it away.""" + db = SessionLocal() + try: + reset_and_seed(db) + failed = db.scalar( + select(OutboxEvent).where( + OutboxEvent.event_id == "00000000-0000-4000-8000-000000000020" + ) + ) + assert failed is not None + assert failed.delivery_status == "failed" + assert failed.attempts >= 1 + assert failed.last_error + finally: + db.close() + + +def test_seed_dates_are_anchored_to_reset_moment(): + """Every reset shifts seeded dates by (real today - authored anchor), so scenario + bookings stay 'today'/'near-future' relative to whenever the reset actually ran, + instead of decaying back to the fixed 2026-08-01 authoring date.""" + db = SessionLocal() + try: + result = reset_and_seed(db) + today = datetime.now(UTC).date() + assert result.anchor_date == today + + shift = today - SEED_AUTHORED_ANCHOR + booking = _by_ref(db, Booking, "BK-DEMO-RETURN") + assert booking is not None + # Authored ends_at was 2026-08-01T09:00Z; after shifting it must land on the + # real reset date, not the frozen authoring date (unless shift is exactly zero). + assert booking.ends_at.date() == today or shift.days == 0 + + marker = db.scalar( + select(AuditEvent) + .where(AuditEvent.action == "demo_data_seeded") + .order_by(AuditEvent.occurred_at.desc()) + ) + assert marker is not None + assert marker.metadata_json["anchor_date"] == today.isoformat() + assert marker.metadata_json["seed_authored_anchor"] == SEED_AUTHORED_ANCHOR.isoformat() + finally: + db.close() diff --git a/compose.yaml b/compose.yaml index 3306969..a5f7759 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,7 +24,6 @@ services: DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops} TZ: ${TZ:-Europe/Brussels} APP_SECRET: ${APP_SECRET:-replace-in-production} - DEMO_TODAY: ${DEMO_TODAY:-2026-08-01} CORS_ALLOW_ORIGINS: ${MOBILITYOPS_PUBLIC_URL:-http://localhost:1228} KNOWLEDGE_PROVIDER: ${KNOWLEDGE_PROVIDER:-demo} RAGCORE_BASE_URL: ${RAGCORE_BASE_URL:-http://ragcore-api:8000} diff --git a/docs/13-seed-and-demo-scenarios.md b/docs/13-seed-and-demo-scenarios.md index 0b53005..862ef8c 100644 --- a/docs/13-seed-and-demo-scenarios.md +++ b/docs/13-seed-and-demo-scenarios.md @@ -42,6 +42,19 @@ One seeded outbox/workflow record is failed with a safe simulated connection err Question: “What must I do when a vehicle returns with damage?” Expected: answer cites damage handling and return inspection procedures. +## Date anchoring + +The committed CSVs store absolute ISO timestamps authored around a fixed anchor date +(`SEED_AUTHORED_ANCHOR = 2026-08-01` in `backend/app/seed_loader.py`, matching the +`--anchor` used to generate them). Every seed/reset shifts every seeded booking, +inspection, maintenance and outbox timestamp by `today − SEED_AUTHORED_ANCHOR`, so +"today"/"near-future"/"currently overlapping" scenarios stay true to the real moment the +environment was (re)seeded instead of decaying as real time passes between resets. Public +refs and entity relationships are untouched by the shift — only datetime columns move. +`load_seed()` returns the resolved `anchor_date`/`seeded_at`, and records a +`demo_data_seeded` audit event carrying both the resolved anchor and the original +authoring anchor, so the shift applied on any given reset stays traceable. + ## Demo reset Reset must: @@ -49,6 +62,7 @@ Reset must: - require Operations Manager; - rebuild the deterministic dataset; - re-establish scenario references; +- re-anchor scenario dates to the real reset moment (see above); - clear non-seed audit/workflow state; - complete safely and visibly; - be covered by a test.