M18: implement operational workspaces

This commit is contained in:
NuklearRabbit
2026-08-10 12:41:28 +02:00
parent 8030753dbc
commit fe06ff75a1
33 changed files with 1082 additions and 95 deletions
@@ -0,0 +1,65 @@
"""add ownership and SLA deadlines to data quality issues
Revision ID: f43d829ab610
Revises: d1f83bc64170
"""
import sqlalchemy as sa
from alembic import op
revision = "f43d829ab610"
down_revision = "d1f83bc64170"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"data_quality_issues",
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"data_quality_issues",
sa.Column("assigned_to_user_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
"users",
["assigned_to_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_data_quality_issues_due_at", "data_quality_issues", ["due_at"])
op.create_index(
"ix_data_quality_issues_assigned_to_user_id",
"data_quality_issues",
["assigned_to_user_id"],
)
op.execute(
"""
UPDATE data_quality_issues
SET due_at = detected_at + CASE severity
WHEN 'high' THEN interval '4 hours'
WHEN 'low' THEN interval '3 days'
ELSE interval '1 day'
END
WHERE status = 'open' AND due_at IS NULL
"""
)
def downgrade() -> None:
op.drop_index(
"ix_data_quality_issues_assigned_to_user_id",
table_name="data_quality_issues",
)
op.drop_index("ix_data_quality_issues_due_at", table_name="data_quality_issues")
op.drop_constraint(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
type_="foreignkey",
)
op.drop_column("data_quality_issues", "assigned_to_user_id")
op.drop_column("data_quality_issues", "due_at")
+41 -18
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import func, or_, select
from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
@@ -52,12 +53,16 @@ def list_bookings(
status: str | None = Query(default=None),
vehicle_ref: str | None = Query(default=None),
query: str | None = Query(default=None, min_length=1, max_length=100),
starts_from: datetime | None = Query(default=None),
starts_to: datetime | None = Query(default=None),
location: str | None = Query(default=None, min_length=1, max_length=120),
sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[BookingOut] | BookingPageOut:
stmt = select(Booking).order_by(Booking.starts_at.desc())
stmt = select(Booking)
if status:
stmt = stmt.where(Booking.status == status)
if vehicle_ref:
@@ -65,20 +70,42 @@ def list_bookings(
if vehicle is None:
return []
stmt = stmt.where(Booking.vehicle_id == vehicle.id)
if starts_from:
stmt = stmt.where(Booking.ends_at >= starts_from)
if starts_to:
stmt = stmt.where(Booking.starts_at < starts_to)
if query or location:
stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join(
Vehicle, Booking.vehicle_id == Vehicle.id
)
if location:
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
if query:
term = f"%{query.strip()}%"
stmt = (
stmt.join(Customer, Booking.customer_id == Customer.id)
.join(Vehicle, Booking.vehicle_id == Vehicle.id)
.where(
or_(
Booking.public_ref.ilike(term),
Customer.first_name.ilike(term),
Customer.last_name.ilike(term),
Vehicle.public_ref.ilike(term),
)
stmt = stmt.where(
or_(
Booking.public_ref.ilike(term),
Customer.first_name.ilike(term),
Customer.last_name.ilike(term),
Vehicle.public_ref.ilike(term),
)
)
if sort == "starts_asc":
stmt = stmt.order_by(Booking.starts_at.asc())
elif sort == "starts_desc":
stmt = stmt.order_by(Booking.starts_at.desc())
else:
now = datetime.now(UTC)
operational_bucket = case(
(Booking.status == "active", 0),
(Booking.starts_at >= now, 1),
else_=2,
)
stmt = stmt.order_by(
operational_bucket,
case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(),
Booking.starts_at.desc(),
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
bookings = db.scalars(
@@ -164,9 +191,7 @@ def checkout_booking(
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> CheckoutBookingResult:
booking = db.scalar(
select(Booking).where(Booking.public_ref == public_ref).with_for_update()
)
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
@@ -324,9 +349,7 @@ def cancel_booking(
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
booking = db.scalar(
select(Booking).where(Booking.public_ref == public_ref).with_for_update()
)
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
+123 -8
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
@@ -9,10 +11,13 @@ 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.user import User
from app.models.vehicle import Vehicle
from app.schemas import (
ApplyRecommendedStatusRequest,
ApplyRecommendedStatusResult,
BulkDataQualityWorkRequest,
BulkDataQualityWorkResult,
CurrentUser,
DataQualityIssueDetailOut,
DataQualityIssueOut,
@@ -26,6 +31,7 @@ from app.schemas import (
StatusRecommendationOut,
VehicleStatusFactsOut,
)
from app.services.audit import record_audit_event
from app.services.data_quality import (
apply_recommended_status,
defer_issue,
@@ -42,6 +48,7 @@ router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
assignee = issue.assigned_to_user
return DataQualityIssueOut(
public_ref=issue.public_ref,
rule_type=issue.rule_type,
@@ -51,6 +58,12 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
status=issue.status,
evidence=issue.evidence_json,
detected_at=issue.detected_at,
due_at=issue.due_at,
assigned_to_ref=assignee.public_ref if assignee else None,
assigned_to_name=assignee.display_name if assignee else None,
overdue=(
issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC)
),
resolved_at=issue.resolved_at,
)
@@ -60,18 +73,40 @@ def list_issues(
status: str | None = Query(default=None),
rule_type: str | None = Query(default=None),
severity: str | None = Query(default=None),
assigned_to_ref: str | None = Query(default=None),
overdue: bool | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc())
severity_order = case(
(DataQualityIssue.severity == "high", 0),
(DataQualityIssue.severity == "medium", 1),
else_=2,
)
stmt = select(DataQualityIssue).order_by(
DataQualityIssue.due_at.asc().nulls_last(),
severity_order,
DataQualityIssue.detected_at.desc(),
)
if status:
stmt = stmt.where(DataQualityIssue.status == status)
if rule_type:
stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
if severity:
stmt = stmt.where(DataQualityIssue.severity == severity)
if assigned_to_ref == "unassigned":
stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
elif assigned_to_ref:
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
User.public_ref == assigned_to_ref
)
if overdue is True:
stmt = stmt.where(
DataQualityIssue.status == "open",
DataQualityIssue.due_at < datetime.now(UTC),
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
issues = db.scalars(
@@ -90,6 +125,90 @@ def list_issues(
)
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
def update_issue_work_queue(
body: BulkDataQualityWorkRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> BulkDataQualityWorkResult:
refs = list(dict.fromkeys(body.issue_refs))
if (
body.assigned_to_ref is None
and not body.clear_assignment
and body.due_at is None
and not body.clear_due_at
):
raise HTTPException(status_code=422, detail="No work queue change was requested")
if body.assigned_to_ref is not None and body.clear_assignment:
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
if body.due_at is not None and body.clear_due_at:
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
if body.due_at is not None and body.due_at.tzinfo is None:
raise HTTPException(status_code=422, detail="Due date must include a timezone")
assignee = None
if body.assigned_to_ref is not None:
assignee = db.scalar(
select(User).where(
User.public_ref == body.assigned_to_ref,
User.active.is_(True),
)
)
if assignee is None:
raise HTTPException(status_code=422, detail="Active assignee not found")
issues = list(
db.scalars(
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
).all()
)
if len(issues) != len(refs):
found = {issue.public_ref for issue in issues}
missing = next(ref for ref in refs if ref not in found)
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
for issue in issues:
if issue.status != "open":
raise HTTPException(
status_code=409,
detail=f"Data quality issue {issue.public_ref} is not open",
)
before = {
"assigned_to_ref": issue.assigned_to_user.public_ref
if issue.assigned_to_user
else None,
"due_at": issue.due_at.isoformat() if issue.due_at else None,
}
if body.assigned_to_ref is not None:
issue.assigned_to_user = assignee
elif body.clear_assignment:
issue.assigned_to_user = None
if body.due_at is not None:
issue.due_at = body.due_at
elif body.clear_due_at:
issue.due_at = None
after = {
"assigned_to_ref": assignee.public_ref
if body.assigned_to_ref is not None and assignee
else (None if body.clear_assignment else before["assigned_to_ref"]),
"due_at": issue.due_at.isoformat() if issue.due_at else None,
}
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="data_quality_work_updated",
entity_type="data_quality_issue",
entity_id=issue.id,
before=before,
after=after,
)
db.commit()
for issue in issues:
db.refresh(issue)
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
# Every public reference in this system carries its entity type in its own prefix
# (CUS-/MO-/BK-/INSP-/DQ-). Related-entity typing is resolved from the reference itself,
# not guessed from the issue's rule_type -- a booking_overlap issue's related refs are
@@ -239,9 +358,7 @@ def provide_fields(
return _to_out(issue)
@router.post(
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
)
@router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
def resolve_odometer(
public_ref: str,
body: ResolveOdometerRegressionRequest,
@@ -263,9 +380,7 @@ def resolve_overlap(
return _to_out(issue)
@router.post(
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
)
@router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
def status_recommendation(
public_ref: str,
db: Session = Depends(get_db),
+77 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
@@ -44,6 +45,7 @@ def _attention_vehicle_ids(db: Session) -> set:
def list_vehicles(
status: str | None = Query(default=None),
attention_only: bool = Query(default=False),
location: str | None = Query(default=None, min_length=1, max_length=120),
query: str | None = Query(default=None, min_length=1, max_length=100),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
@@ -53,6 +55,8 @@ def list_vehicles(
stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status:
stmt = stmt.where(Vehicle.operational_status == status)
if location:
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
if query:
term = f"%{query.strip()}%"
stmt = stmt.where(
@@ -67,13 +71,30 @@ def list_vehicles(
attention_ids = _attention_vehicle_ids(db)
if attention_only:
stmt = stmt.where(
or_(Vehicle.id.in_(attention_ids), Vehicle.operational_status == "blocked")
or_(
Vehicle.id.in_(attention_ids),
Vehicle.operational_status == "blocked",
Vehicle.next_service_km <= Vehicle.odometer_km,
)
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
vehicles = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
vehicle_ids = [vehicle.id for vehicle in vehicles]
next_bookings: dict[uuid.UUID, Booking] = {}
if vehicle_ids:
for booking in db.scalars(
select(Booking)
.where(
Booking.vehicle_id.in_(vehicle_ids),
Booking.status == "reserved",
Booking.starts_at >= datetime.now(UTC),
)
.order_by(Booking.starts_at.asc())
).all():
next_bookings.setdefault(booking.vehicle_id, booking)
items = [
VehicleOut(
public_ref=v.public_ref,
@@ -86,7 +107,23 @@ def list_vehicles(
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",
attention=(
v.id in attention_ids
or v.operational_status == "blocked"
or v.next_service_km <= v.odometer_km
),
attention_reason=(
"blocked_status"
if v.operational_status == "blocked"
else "service_due"
if v.next_service_km <= v.odometer_km
else "data_quality"
if v.id in attention_ids
else None
),
service_remaining_km=v.next_service_km - v.odometer_km,
next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None),
next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None),
)
for v in vehicles
]
@@ -133,6 +170,14 @@ def get_vehicle(
).all()
booking_by_id = {b.id: b.public_ref for b in bookings}
next_booking = next(
(
booking
for booking in sorted(bookings, key=lambda item: item.starts_at)
if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC)
),
None,
)
attention_ids = _attention_vehicle_ids(db)
return VehicleDetailOut(
@@ -146,7 +191,23 @@ def get_vehicle(
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",
attention=(
vehicle.id in attention_ids
or vehicle.operational_status == "blocked"
or vehicle.next_service_km <= vehicle.odometer_km
),
attention_reason=(
"blocked_status"
if vehicle.operational_status == "blocked"
else "service_due"
if vehicle.next_service_km <= vehicle.odometer_km
else "data_quality"
if vehicle.id in attention_ids
else None
),
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
next_booking_ref=next_booking.public_ref if next_booking else None,
next_booking_at=next_booking.starts_at if next_booking else None,
bookings=[
BookingSummaryOut(
public_ref=b.public_ref,
@@ -192,6 +253,12 @@ def get_vehicle(
status=q.status,
evidence=q.evidence_json,
detected_at=q.detected_at,
due_at=q.due_at,
assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None),
assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None),
overdue=(
q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC)
),
resolved_at=q.resolved_at,
)
for q in issues
@@ -296,5 +363,11 @@ def release_vehicle(
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
active=vehicle.active,
attention=False,
attention=vehicle.next_service_km <= vehicle.odometer_km,
attention_reason=(
"service_due" if vehicle.next_service_km <= vehicle.odometer_km else None
),
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
next_booking_ref=None,
next_booking_at=None,
)
+11 -2
View File
@@ -1,13 +1,17 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from app.models.user import User
RULE_TYPES = (
"possible_duplicate_customer",
"missing_required_field",
@@ -31,5 +35,10 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
assigned_to_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True
)
assigned_to_user: Mapped["User | None"] = relationship(lazy="selectin")
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_by: Mapped[str | None] = mapped_column(String(120))
+20
View File
@@ -57,6 +57,10 @@ class VehicleOut(BaseModel):
next_service_km: int
active: bool
attention: bool = False
attention_reason: str | None = None
service_remaining_km: int
next_booking_ref: str | None = None
next_booking_at: datetime | None = None
class VehiclePageOut(BaseModel):
@@ -221,6 +225,10 @@ class DataQualityIssueOut(BaseModel):
status: str
evidence: dict[str, Any]
detected_at: datetime
due_at: datetime | None = None
assigned_to_ref: str | None = None
assigned_to_name: str | None = None
overdue: bool = False
resolved_at: datetime | None = None
@@ -237,6 +245,18 @@ class DataQualityIssueDetailOut(DataQualityIssueOut):
related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
class BulkDataQualityWorkRequest(BaseModel):
issue_refs: list[str] = Field(min_length=1, max_length=25)
assigned_to_ref: str | None = Field(default=None, min_length=3, max_length=20)
clear_assignment: bool = False
due_at: datetime | None = None
clear_due_at: bool = False
class BulkDataQualityWorkResult(BaseModel):
updated: list[DataQualityIssueOut]
class MergeCustomersRequest(BaseModel):
survivor_ref: str
field_overrides: dict[str, str] | None = None
+11 -7
View File
@@ -106,7 +106,9 @@ def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> N
):
db.execute(delete(model))
if preserve_integration_telemetry:
db.execute(delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS)))
db.execute(
delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS))
)
else:
db.execute(delete(AuditEvent))
@@ -116,9 +118,7 @@ def load_seed(db: Session) -> SeedResult:
today = datetime.now(UTC).date()
shift = _seed_anchor_shift(today)
user_rows = [
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
]
user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS]
db.execute(insert(User), user_rows)
counts["users"] = len(user_rows)
@@ -354,6 +354,11 @@ def load_seed(db: Session) -> SeedResult:
entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else []
severity_due_delta = {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(row["severity"], timedelta(days=1))
dq_rows.append(
{
"id": uuid.uuid4(),
@@ -371,6 +376,7 @@ def load_seed(db: Session) -> SeedResult:
},
"proposed_action_json": {},
"detected_at": now,
"due_at": now + severity_due_delta if row["status"] == "open" else None,
"resolved_at": now if row["status"] == "resolved" else None,
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
}
@@ -443,9 +449,7 @@ def load_seed(db: Session) -> SeedResult:
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
def reset_and_seed(
db: Session, *, preserve_integration_telemetry: bool = False
) -> SeedResult:
def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
from app.services.data_quality import run_scan
clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
+14 -10
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from difflib import SequenceMatcher
from sqlalchemy import select, update
@@ -28,6 +28,15 @@ REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DUPLICATE_THRESHOLD = 70
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
"""Return the local operational SLA deadline for a newly detected issue."""
return detected_at + {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(severity, timedelta(days=1))
@dataclass
class ScanResult:
created: dict[str, int] = field(default_factory=dict)
@@ -112,6 +121,7 @@ def _open_issue(
evidence_json=evidence,
proposed_action_json={},
detected_at=now,
due_at=issue_due_at(now, severity),
)
db.add(issue)
db.flush()
@@ -280,9 +290,7 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars(
select(Booking).where(
Booking.status == "returned", Booking.end_odometer_km.is_not(None)
)
select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
).all():
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
@@ -346,9 +354,7 @@ def run_scan(
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue:
issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
)
issue = db.scalar(select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref))
if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open":
@@ -750,9 +756,7 @@ def apply_recommended_status(
# recommendation alone for the post-condition.
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
post_check = evaluate_vehicle_status(vehicle, post_facts)
if post_check.recommendation_code not in (
RECOMMENDATION_CODE_NO_CONFLICT,
):
if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,):
raise AppError(
"CONFLICT_STILL_PRESENT",
"Applying the recommended status did not resolve the conflict.",
+2 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@@ -255,6 +255,7 @@ def register_vehicle_return(
},
proposed_action_json={},
detected_at=now,
due_at=now + timedelta(days=1),
)
db.add(issue)
db.flush()
+26 -8
View File
@@ -1,4 +1,5 @@
import threading
from datetime import datetime, timedelta
from fastapi.testclient import TestClient
@@ -27,6 +28,23 @@ def test_list_bookings_supports_bounded_search_pages(ops_client):
assert len(body["items"]) == 25
def test_list_bookings_filters_operational_window_and_location(ops_client):
booking = ops_client.get("/api/v1/bookings").json()[0]
vehicle = ops_client.get(f"/api/v1/vehicles/{booking['vehicle_ref']}").json()
starts_at = datetime.fromisoformat(booking["starts_at"])
response = ops_client.get(
"/api/v1/bookings",
params={
"starts_from": (starts_at - timedelta(minutes=1)).isoformat(),
"starts_to": (starts_at + timedelta(minutes=1)).isoformat(),
"location": vehicle["location"],
"sort": "starts_asc",
},
)
assert response.status_code == 200
assert booking["public_ref"] in {item["public_ref"] for item in response.json()}
def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
conflict = ops_client.post(
@@ -39,9 +57,9 @@ def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
},
)
assert conflict.status_code == 409
available_vehicle = ops_client.get(
"/api/v1/vehicles", params={"status": "available"}
).json()[0]["public_ref"]
available_vehicle = ops_client.get("/api/v1/vehicles", params={"status": "available"}).json()[
0
]["public_ref"]
created = ops_client.post(
"/api/v1/bookings",
json={
@@ -115,11 +133,11 @@ def test_concurrent_bookings_only_reserve_vehicle_once():
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
response = client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001",
"vehicle_ref": vehicle_ref,
**window,
},
json={
"customer_ref": "CUS-0001",
"vehicle_ref": vehicle_ref,
**window,
},
)
results.append(response.status_code)
+43
View File
@@ -53,6 +53,49 @@ def test_list_issues_requires_operations_manager(employee_client):
assert response.status_code == 403
def test_manager_can_assign_prioritised_quality_work_and_filter_it(ops_client):
assignee = next(user for user in ops_client.get("/api/v1/users").json() if user["active"])
open_issues = ops_client.get("/api/v1/data-quality/issues", params={"status": "open"}).json()
refs = [issue["public_ref"] for issue in open_issues[:2]]
due_at = "2030-01-15T12:00:00+00:00"
updated = ops_client.post(
"/api/v1/data-quality/issues/bulk-work",
json={
"issue_refs": refs,
"assigned_to_ref": assignee["public_ref"],
"due_at": due_at,
},
)
assert updated.status_code == 200
assert {issue["public_ref"] for issue in updated.json()["updated"]} == set(refs)
assert all(
issue["assigned_to_ref"] == assignee["public_ref"] for issue in updated.json()["updated"]
)
filtered = ops_client.get(
"/api/v1/data-quality/issues",
params={"status": "open", "assigned_to_ref": assignee["public_ref"]},
).json()
assert set(refs).issubset({issue["public_ref"] for issue in filtered})
audits = ops_client.get("/api/v1/audit", params={"action": "data_quality_work_updated"}).json()
assert len(audits) >= 2
cleared = ops_client.post(
"/api/v1/data-quality/issues/bulk-work",
json={"issue_refs": refs, "clear_assignment": True},
)
assert cleared.status_code == 200
assert all(issue["assigned_to_ref"] is None for issue in cleared.json()["updated"])
def test_employee_cannot_assign_quality_work(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/bulk-work",
json={"issue_refs": ["DQ-DEMO-OVERLAP"], "clear_assignment": True},
)
assert response.status_code == 403
def test_issue_page_preserves_severity_filter_and_limits_results(ops_client):
response = ops_client.get(
"/api/v1/data-quality/issues",
+13 -2
View File
@@ -12,6 +12,18 @@ def test_attention_only_filters_flagged_vehicles(ops_client):
vehicles = response.json()
assert len(vehicles) > 0
assert all(v["attention"] for v in vehicles)
assert all(v["attention_reason"] for v in vehicles)
def test_vehicle_list_exposes_planning_and_service_context(ops_client):
vehicles = ops_client.get("/api/v1/vehicles").json()
assert vehicles
assert all("service_remaining_km" in vehicle for vehicle in vehicles)
assert all("next_booking_ref" in vehicle for vehicle in vehicles)
location = vehicles[0]["location"]
filtered = ops_client.get("/api/v1/vehicles", params={"location": location}).json()
assert filtered
assert all(vehicle["location"].casefold() == location.casefold() for vehicle in filtered)
def test_vehicle_page_preserves_filters_and_limits_rendered_records(ops_client):
@@ -56,8 +68,7 @@ def test_manager_can_record_maintenance_and_release_vehicle(ops_client):
detail = ops_client.get(f"/api/v1/vehicles/{vehicle['public_ref']}").json()
assert detail["operational_status"] == "maintenance"
assert any(
item["public_ref"] == response.json()["public_ref"]
for item in detail["maintenance"]
item["public_ref"] == response.json()["public_ref"] for item in detail["maintenance"]
)
released = ops_client.post(
f"/api/v1/vehicles/{vehicle['public_ref']}/release",