M1: implement operational core

Demo auth, seed import/reset, dashboard, vehicle/booking list+detail, audit trail. Backend: 19 tests passing, ruff clean. Frontend: React Router shell, typed API client, responsive pages. Verified end-to-end via curl and browser.
This commit is contained in:
NuklearRabbit
2026-08-01 21:20:53 +02:00
parent 04d26f1f2e
commit 03c5b60235
47 changed files with 2518 additions and 70 deletions
View File
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.db import SessionLocal
from app.core.security import SessionPayload, read_session_token
from app.schemas import CurrentUser
settings = get_settings()
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(request: Request) -> CurrentUser:
token = request.cookies.get(settings.session_cookie_name)
payload: SessionPayload | None = read_session_token(token) if token else None
if payload is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return CurrentUser(
public_ref=payload.public_ref, display_name=payload.display_name, role=payload.role
)
def require_operations_manager(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if user.role != "operations_manager":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Operations Manager role required"
)
return user
View File
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.audit import AuditEvent
from app.schemas import AuditEventOut, CurrentUser
router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
@router.get("", response_model=list[AuditEventOut])
def list_audit_events(
actor_label: str | None = Query(default=None),
action: str | None = Query(default=None),
entity_type: str | None = Query(default=None),
correlation_id: str | None = Query(default=None),
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[AuditEventOut]:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit)
if actor_label:
stmt = stmt.where(AuditEvent.actor_label == actor_label)
if action:
stmt = stmt.where(AuditEvent.action == action)
if entity_type:
stmt = stmt.where(AuditEvent.entity_type == entity_type)
if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
events = db.scalars(stmt).all()
return [
AuditEventOut(
id=str(e.id),
actor_type=e.actor_type,
actor_label=e.actor_label,
action=e.action,
entity_type=e.entity_type,
entity_id=str(e.entity_id) if e.entity_id else None,
correlation_id=str(e.correlation_id),
occurred_at=e.occurred_at,
metadata=e.metadata_json,
)
for e in events
]
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.vehicle import Vehicle
from app.schemas import BookingOut, CurrentUser
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOut:
return BookingOut(
public_ref=booking.public_ref,
customer_ref=customer.public_ref,
vehicle_ref=vehicle.public_ref,
starts_at=booking.starts_at,
ends_at=booking.ends_at,
status=booking.status,
start_odometer_km=booking.start_odometer_km,
end_odometer_km=booking.end_odometer_km,
requirements_complete=booking.requirements_complete,
customer_name=f"{customer.first_name} {customer.last_name}",
)
@router.get("", response_model=list[BookingOut])
def list_bookings(
status: str | None = Query(default=None),
vehicle_ref: str | None = Query(default=None),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[BookingOut]:
stmt = select(Booking).order_by(Booking.starts_at.desc())
if status:
stmt = stmt.where(Booking.status == status)
if vehicle_ref:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None:
return []
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
bookings = db.scalars(stmt).all()
customers = {c.id: c for c in db.scalars(select(Customer)).all()}
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
@router.get("/{public_ref}", response_model=BookingOut)
def get_booking(
public_ref: str,
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref))
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
return _to_out(booking, customer, vehicle)
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import date, datetime
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
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.vehicle import Vehicle
from app.schemas import (
AttentionItem,
AutomationRunOut,
CurrentUser,
DashboardMetrics,
DashboardOut,
TodayItem,
)
router = APIRouter(prefix="/api/v1/dashboard", tags=["dashboard"])
settings = get_settings()
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _today() -> date:
return datetime.fromisoformat(settings.demo_today).date()
@router.get("", response_model=DashboardOut)
def get_dashboard(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> DashboardOut:
status_counts = dict(
db.execute(
select(Vehicle.operational_status, func.count()).group_by(Vehicle.operational_status)
).all()
)
open_issues = db.scalar(
select(func.count()).select_from(DataQualityIssue).where(DataQualityIssue.status == "open")
)
pending_or_failed = db.scalar(
select(func.count())
.select_from(OutboxEvent)
.where(OutboxEvent.delivery_status.in_(["pending", "failed"]))
)
metrics = DashboardMetrics(
available=status_counts.get("available", 0),
rented=status_counts.get("rented", 0),
cleaning=status_counts.get("cleaning", 0),
maintenance=status_counts.get("maintenance", 0),
blocked=status_counts.get("blocked", 0),
open_quality_issues=open_issues or 0,
pending_or_failed_workflows=pending_or_failed or 0,
)
vehicles_by_id = {v.id: v for v in db.scalars(select(Vehicle)).all()}
customers_by_id = {c.id: c for c in db.scalars(select(Customer)).all()}
issues = db.scalars(
select(DataQualityIssue)
.where(DataQualityIssue.status == "open")
.order_by(DataQualityIssue.detected_at.asc())
).all()
attention_items = []
for issue in issues:
if issue.entity_type == "vehicle":
entity = vehicles_by_id.get(issue.entity_id)
link_type = "vehicle"
else:
entity = customers_by_id.get(issue.entity_id)
link_type = "customer"
link_ref = entity.public_ref if entity else ""
title = f"{issue.rule_type.replace('_', ' ').title()}{link_ref}"
attention_items.append(
AttentionItem(
kind="quality_issue",
severity=issue.severity,
title=title,
detail=issue.evidence_json.get("summary", ""),
link_type=link_type,
link_ref=link_ref,
)
)
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3))
today = _today()
bookings = db.scalars(select(Booking)).all()
today_items: list[TodayItem] = []
for b in bookings:
vehicle = vehicles_by_id.get(b.vehicle_id)
vehicle_ref = vehicle.public_ref if vehicle else ""
if b.starts_at.date() == today and b.status in ("reserved", "active"):
today_items.append(
TodayItem(
kind="departure", booking_ref=b.public_ref, vehicle_ref=vehicle_ref,
scheduled_at=b.starts_at,
)
)
if b.ends_at.date() == today and b.status in ("active", "returned"):
today_items.append(
TodayItem(
kind="return", booking_ref=b.public_ref, vehicle_ref=vehicle_ref,
scheduled_at=b.ends_at,
)
)
today_items.sort(key=lambda item: item.scheduled_at)
recent = db.scalars(
select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)
).all()
recent_automation = [
AutomationRunOut(
event_id=str(r.event_id),
event_type=r.event_type,
aggregate_ref=r.payload_json.get("aggregate_ref", ""),
status=r.delivery_status,
attempts=r.attempts,
last_error=r.last_error,
occurred_at=r.occurred_at,
)
for r in recent
]
return DashboardOut(
metrics=metrics,
attention_items=attention_items,
today=today_items,
recent_automation=recent_automation,
)
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, Response
from sqlalchemy import 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.core.security import SessionPayload, create_session_token
from app.models.user import User
from app.schemas import CurrentUser, DemoLoginRequest
from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings()
@router.post("/login", response_model=CurrentUser)
def demo_login(
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
) -> CurrentUser:
public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP"
user = db.scalar(select(User).where(User.public_ref == public_ref))
if user is None:
raise LookupError("Demo users are missing; run the seed loader first.")
token = create_session_token(
SessionPayload(
user_id=str(user.id),
public_ref=user.public_ref,
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
)
)
response.set_cookie(
settings.session_cookie_name,
token,
httponly=True,
samesite="lax",
max_age=settings.session_ttl_seconds,
)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="demo_login",
entity_type="user",
entity_id=user.id,
)
db.commit()
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=user.role)
@router.post("/reset")
def demo_reset(
response: Response,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> dict:
result = reset_and_seed(db)
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="demo_reset",
entity_type="system",
metadata={"counts": result.counts},
)
db.commit()
response.delete_cookie(settings.session_cookie_name)
return {"status": "reset", "counts": result.counts}
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.vehicle import Vehicle
from app.schemas import (
BookingSummaryOut,
CurrentUser,
DataQualityIssueOut,
InspectionOut,
MaintenanceOut,
VehicleDetailOut,
VehicleOut,
)
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
def _attention_vehicle_ids(db: Session) -> set:
rows = db.scalars(
select(DataQualityIssue.entity_id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.status == "open",
)
).all()
return set(rows)
@router.get("", response_model=list[VehicleOut])
def list_vehicles(
status: str | None = Query(default=None),
attention_only: bool = Query(default=False),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[VehicleOut]:
stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status:
stmt = stmt.where(Vehicle.operational_status == status)
vehicles = db.scalars(stmt).all()
attention_ids = _attention_vehicle_ids(db)
out = [
VehicleOut(
public_ref=v.public_ref,
make=v.make,
model=v.model,
model_year=v.model_year,
registration_number=v.registration_number,
location=v.location,
operational_status=v.operational_status,
odometer_km=v.odometer_km,
next_service_km=v.next_service_km,
active=v.active,
attention=v.id in attention_ids or v.operational_status == "blocked",
)
for v in vehicles
]
if attention_only:
out = [v for v in out if v.attention]
return out
@router.get("/{public_ref}", response_model=VehicleDetailOut)
def get_vehicle(
public_ref: str,
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> VehicleDetailOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref))
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
bookings = db.scalars(
select(Booking).where(Booking.vehicle_id == vehicle.id).order_by(Booking.starts_at.desc())
).all()
customer_ref_by_id = {c.id: c.public_ref for c in db.scalars(select(Customer)).all()}
inspections = db.scalars(
select(Inspection)
.where(Inspection.vehicle_id == vehicle.id)
.order_by(Inspection.completed_at.desc())
).all()
maintenance = db.scalars(
select(MaintenanceRecord)
.where(MaintenanceRecord.vehicle_id == vehicle.id)
.order_by(MaintenanceRecord.occurred_at.desc())
).all()
issues = db.scalars(
select(DataQualityIssue)
.where(DataQualityIssue.entity_type == "vehicle", DataQualityIssue.entity_id == vehicle.id)
.order_by(DataQualityIssue.detected_at.desc())
).all()
booking_by_id = {b.id: b.public_ref for b in bookings}
attention_ids = _attention_vehicle_ids(db)
return VehicleDetailOut(
public_ref=vehicle.public_ref,
make=vehicle.make,
model=vehicle.model,
model_year=vehicle.model_year,
registration_number=vehicle.registration_number,
location=vehicle.location,
operational_status=vehicle.operational_status,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
active=vehicle.active,
attention=vehicle.id in attention_ids or vehicle.operational_status == "blocked",
bookings=[
BookingSummaryOut(
public_ref=b.public_ref,
customer_ref=customer_ref_by_id.get(b.customer_id, ""),
vehicle_ref=vehicle.public_ref,
starts_at=b.starts_at,
ends_at=b.ends_at,
status=b.status,
)
for b in bookings
],
inspections=[
InspectionOut(
public_ref=i.public_ref,
booking_ref=booking_by_id.get(i.booking_id, ""),
type=i.type,
fuel_level_percent=i.fuel_level_percent,
cleanliness_ok=i.cleanliness_ok,
damage_reported=i.damage_reported,
technical_warning=i.technical_warning,
odometer_km=i.odometer_km,
completed_at=i.completed_at,
)
for i in inspections
],
maintenance=[
MaintenanceOut(
public_ref=m.public_ref,
occurred_at=m.occurred_at,
odometer_km=m.odometer_km,
category=m.category,
summary=m.summary,
)
for m in maintenance
],
quality_issues=[
DataQualityIssueOut(
public_ref=q.public_ref,
rule_type=q.rule_type,
entity_type=q.entity_type,
entity_ref=vehicle.public_ref,
severity=q.severity,
status=q.status,
evidence=q.evidence_json,
detected_at=q.detected_at,
resolved_at=q.resolved_at,
)
for q in issues
],
)
+35
View File
@@ -0,0 +1,35 @@
from __future__ import annotations
import argparse
from app.core.db import SessionLocal
from app.seed_loader import reset_and_seed
def main() -> None:
parser = argparse.ArgumentParser(prog="app.cli")
subparsers = parser.add_subparsers(dest="command", required=True)
seed_parser = subparsers.add_parser("seed", help="Load the deterministic demo dataset")
seed_parser.add_argument(
"--reset", action="store_true", help="Clear existing data before loading"
)
args = parser.parse_args()
if args.command == "seed":
if not args.reset:
raise SystemExit(
"Only 'seed --reset' is supported: seeding always rebuilds the demo dataset."
)
db = SessionLocal()
try:
result = reset_and_seed(db)
for name, count in result.counts.items():
print(f"{name}: {count}")
finally:
db.close()
if __name__ == "__main__":
main()
+6
View File
@@ -15,6 +15,12 @@ class Settings(BaseSettings):
ragcore_workspace: str = "mobilityops"
ragcore_collection: str = "internal-procedures"
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
app_secret: str = "replace-in-production"
session_cookie_name: str = "mobilityops_session"
session_ttl_seconds: int = 60 * 60 * 8
seed_dir: str = "/app/seed"
cors_allow_origins: str = "http://localhost:1228"
demo_today: str = "2026-08-01"
@lru_cache
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import uuid
from typing import Any
class AppError(Exception):
def __init__(
self,
code: str,
message: str,
status_code: int = 400,
details: dict[str, Any] | None = None,
) -> None:
super().__init__(message)
self.code = code
self.message = message
self.status_code = status_code
self.details = details or {}
self.correlation_id = str(uuid.uuid4())
def error_body(code: str, message: str, correlation_id: str, details: dict[str, Any]) -> dict:
return {
"error": {
"code": code,
"message": message,
"correlation_id": correlation_id,
"details": details,
}
}
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from app.core.config import get_settings
settings = get_settings()
@dataclass(frozen=True)
class SessionPayload:
user_id: str
public_ref: str
role: str
display_name: str
issued_at: int
def _sign(data: bytes) -> str:
digest = hmac.new(settings.app_secret.encode(), data, hashlib.sha256).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def create_session_token(payload: SessionPayload) -> str:
body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
signature = _sign(encoded_body.encode())
return f"{encoded_body}.{signature}"
def read_session_token(token: str) -> SessionPayload | None:
try:
encoded_body, signature = token.split(".", 1)
except ValueError:
return None
expected = _sign(encoded_body.encode())
if not hmac.compare_digest(expected, signature):
return None
padding = "=" * (-len(encoded_body) % 4)
try:
body = json.loads(base64.urlsafe_b64decode(encoded_body + padding))
except (ValueError, json.JSONDecodeError):
return None
payload = SessionPayload(**body)
if time.time() - payload.issued_at > settings.session_ttl_seconds:
return None
return payload
+44 -3
View File
@@ -1,9 +1,44 @@
from fastapi import FastAPI
import uuid
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.routers import audit, bookings, dashboard, demo, vehicles
from app.core.config import get_settings
from app.core.errors import AppError, error_body
settings = get_settings()
app = FastAPI(title="MobilityOps API", version="0.0.1")
app = FastAPI(title="MobilityOps API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.cors_allow_origins.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.exception_handler(AppError)
def handle_app_error(_request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=error_body(exc.code, exc.message, exc.correlation_id, exc.details),
)
@app.exception_handler(HTTPException)
def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=error_body(
code=str(exc.status_code),
message=str(exc.detail),
correlation_id=str(uuid.uuid4()),
details={},
),
)
@app.get("/health")
@@ -18,5 +53,11 @@ def system_status() -> dict[str, object]:
"environment": settings.mobilityops_env,
"demo_mode": settings.mobilityops_demo_mode,
"knowledge_provider": settings.knowledge_provider,
"scaffold": True,
}
app.include_router(demo.router)
app.include_router(dashboard.router)
app.include_router(vehicles.router)
app.include_router(bookings.router)
app.include_router(audit.router)
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
Role = Literal["operations_manager", "rental_employee"]
class DemoLoginRequest(BaseModel):
role: Role
class CurrentUser(BaseModel):
public_ref: str
display_name: str
role: Role
class VehicleOut(BaseModel):
public_ref: str
make: str
model: str
model_year: int
registration_number: str
location: str
operational_status: str
odometer_km: int
next_service_km: int
active: bool
attention: bool = False
class BookingSummaryOut(BaseModel):
public_ref: str
customer_ref: str
vehicle_ref: str
starts_at: datetime
ends_at: datetime
status: str
class BookingOut(BookingSummaryOut):
start_odometer_km: int | None
end_odometer_km: int | None
requirements_complete: bool
customer_name: str
class InspectionOut(BaseModel):
public_ref: str
booking_ref: str
type: str
fuel_level_percent: int
cleanliness_ok: bool
damage_reported: bool
technical_warning: bool
odometer_km: int
completed_at: datetime
class MaintenanceOut(BaseModel):
public_ref: str
occurred_at: datetime
odometer_km: int
category: str
summary: str
class DataQualityIssueOut(BaseModel):
public_ref: str
rule_type: str
entity_type: str
entity_ref: str
severity: str
status: str
evidence: dict[str, Any]
detected_at: datetime
resolved_at: datetime | None = None
class VehicleDetailOut(VehicleOut):
bookings: list[BookingSummaryOut] = Field(default_factory=list)
inspections: list[InspectionOut] = Field(default_factory=list)
maintenance: list[MaintenanceOut] = Field(default_factory=list)
quality_issues: list[DataQualityIssueOut] = Field(default_factory=list)
class DashboardMetrics(BaseModel):
available: int
rented: int
cleaning: int
maintenance: int
blocked: int
open_quality_issues: int
pending_or_failed_workflows: int
class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"]
severity: str
title: str
detail: str
link_type: Literal["vehicle", "booking", "customer"]
link_ref: str
class TodayItem(BaseModel):
kind: Literal["departure", "return"]
booking_ref: str
vehicle_ref: str
scheduled_at: datetime
class AutomationRunOut(BaseModel):
event_id: str
event_type: str
aggregate_ref: str
status: str
attempts: int
last_error: str | None
occurred_at: datetime
class DashboardOut(BaseModel):
metrics: DashboardMetrics
attention_items: list[AttentionItem]
today: list[TodayItem]
recent_automation: list[AutomationRunOut]
class AuditEventOut(BaseModel):
id: str
actor_type: str
actor_label: str
action: str
entity_type: str
entity_id: str | None
correlation_id: str
occurred_at: datetime
metadata: dict[str, Any] | None = None
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
import csv
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy import delete, insert
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.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
settings = get_settings()
DEMO_USERS = [
{
"public_ref": "USR-OPS",
"display_name": "Amelie De Ridder",
"role": "operations_manager",
},
{
"public_ref": "USR-EMP",
"display_name": "Karim Boujaddaine",
"role": "rental_employee",
},
]
def _parse_dt(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
def _parse_bool(value: str) -> bool:
return value.strip().lower() == "true"
def _parse_optional_int(value: str) -> int | None:
value = value.strip()
return int(value) if value else None
@dataclass
class SeedResult:
counts: dict[str, int]
def _seed_dir() -> Path:
return Path(settings.seed_dir)
def _read_csv(name: str) -> list[dict[str, str]]:
path = _seed_dir() / name
with path.open(newline="", encoding="utf-8") as handle:
return list(csv.DictReader(handle))
def clear_all(db: Session) -> None:
for model in (
AuditEvent,
OutboxEvent,
DataQualityIssue,
Inspection,
MaintenanceRecord,
Booking,
Vehicle,
Customer,
User,
):
db.execute(delete(model))
def load_seed(db: Session) -> SeedResult:
counts: dict[str, int] = {}
user_rows = [
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
]
db.execute(insert(User), user_rows)
counts["users"] = len(user_rows)
customer_id_by_ref: dict[str, uuid.UUID] = {}
customer_rows = []
for row in _read_csv("customers.csv"):
cid = uuid.uuid4()
customer_id_by_ref[row["public_ref"]] = cid
customer_rows.append(
{
"id": cid,
"public_ref": row["public_ref"],
"first_name": row["first_name"],
"last_name": row["last_name"],
"email": row["email"] or None,
"phone": row["phone"] or None,
"postal_code": row["postal_code"] or None,
"city": row["city"] or None,
}
)
db.execute(insert(Customer), customer_rows)
counts["customers"] = len(customer_rows)
# Second pass for merged_into (self-referencing FK) since target must exist first.
for row in _read_csv("customers.csv"):
merged_ref = row.get("merged_into") or ""
if merged_ref:
db.execute(
Customer.__table__.update()
.where(Customer.id == customer_id_by_ref[row["public_ref"]])
.values(merged_into_customer_id=customer_id_by_ref[merged_ref])
)
vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = []
for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append(
{
"id": vid,
"public_ref": row["public_ref"],
"make": row["make"],
"model": row["model"],
"model_year": int(row["model_year"]),
"registration_number": row["registration_number"],
"location": row["location"],
"operational_status": row["operational_status"],
"odometer_km": int(row["odometer_km"]),
"next_service_km": int(row["next_service_km"]),
"active": _parse_bool(row["active"]),
"version": 1,
}
)
db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = []
for row in _read_csv("bookings.csv"):
bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append(
{
"id": bid,
"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"]),
"status": row["status"],
"start_odometer_km": _parse_optional_int(row["start_odometer_km"]),
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"requirements_complete": _parse_bool(row["requirements_complete"]),
}
)
db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows)
inspection_rows = []
for row in _read_csv("inspections.csv"):
inspection_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"booking_id": booking_id_by_ref[row["booking_ref"]],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"type": row["type"],
"fuel_level_percent": int(row["fuel_level_percent"]),
"cleanliness_ok": _parse_bool(row["cleanliness_ok"]),
"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_by": None,
}
)
db.execute(insert(Inspection), inspection_rows)
counts["inspections"] = len(inspection_rows)
maintenance_rows = []
for row in _read_csv("maintenance.csv"):
maintenance_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"vehicle_id": vehicle_id_by_ref[row["vehicle_ref"]],
"occurred_at": _parse_dt(row["occurred_at"]),
"odometer_km": int(row["odometer_km"]),
"category": row["category"],
"summary": row["summary"],
}
)
db.execute(insert(MaintenanceRecord), maintenance_rows)
counts["maintenance"] = len(maintenance_rows)
def resolve_entity(entity_ref: str) -> tuple[str, uuid.UUID]:
if entity_ref.startswith("CUS-"):
return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref]
dq_rows = []
now = datetime.now(UTC)
for row in _read_csv("data_quality_issues.csv"):
entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or ""
dq_rows.append(
{
"id": uuid.uuid4(),
"public_ref": row["public_ref"],
"rule_type": row["rule_type"],
"entity_type": entity_type,
"entity_id": entity_id,
"severity": row["severity"],
"status": row["status"],
"evidence_json": {
"summary": row["evidence"],
"entity_ref": row["entity_ref"],
"related_refs": related_ref.split("|") if related_ref else [],
},
"proposed_action_json": {},
"detected_at": now,
"resolved_at": now if row["status"] == "resolved" else None,
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
}
)
db.execute(insert(DataQualityIssue), dq_rows)
counts["data_quality_issues"] = len(dq_rows)
outbox_rows = []
for row in _read_csv("workflow_runs.csv"):
booking_id = booking_id_by_ref.get(row["aggregate_ref"])
outbox_rows.append(
{
"event_id": uuid.UUID(row["event_id"]),
"event_type": row["event_type"],
"aggregate_type": "booking",
"aggregate_id": booking_id or uuid.uuid4(),
"payload_json": {"aggregate_ref": row["aggregate_ref"]},
"occurred_at": _parse_dt(row["occurred_at"]),
"delivery_status": row["status"],
"attempts": int(row["attempts"]),
"next_attempt_at": None,
"last_error": row["last_error"] or None,
"external_run_id": None,
}
)
db.execute(insert(OutboxEvent), outbox_rows)
counts["workflow_runs"] = len(outbox_rows)
return SeedResult(counts=counts)
def reset_and_seed(db: Session) -> SeedResult:
clear_all(db)
result = load_seed(db)
db.commit()
return result
View File
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.models.audit import AuditEvent
def record_audit_event(
db: Session,
*,
actor_type: str,
actor_label: str,
action: str,
entity_type: str,
actor_id: uuid.UUID | None = None,
entity_id: uuid.UUID | None = None,
correlation_id: uuid.UUID | None = None,
before: dict[str, Any] | None = None,
after: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> AuditEvent:
event = AuditEvent(
actor_type=actor_type,
actor_id=actor_id,
actor_label=actor_label,
action=action,
entity_type=entity_type,
entity_id=entity_id,
correlation_id=correlation_id or uuid.uuid4(),
before_json=before,
after_json=after,
metadata_json=metadata,
occurred_at=datetime.now(UTC),
)
db.add(event)
return event