fix(demo): anchor seeded dates to the real reset moment
Booking/inspection/maintenance/outbox dates were authored as absolute timestamps around a fixed 2026-08-01 anchor and never re-anchored at seed/reset time, so demo scenarios (e.g. BK-DEMO-RETURN) silently drifted into the past. Every reset now shifts seeded dates by (today - authored anchor); dashboard's "today" filter uses real wall-clock time instead of the now-removed frozen demo_today setting. Adds seed-validation tests proving scenarios S1/S2/S4/S5 are present and internally consistent after every reset.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+118
-1
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user