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
+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()