M9: create validated internal bookings

This commit is contained in:
NuklearRabbit
2026-08-10 02:20:28 +02:00
parent c194c18ca9
commit e577c16db5
3 changed files with 88 additions and 0 deletions
+58
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -11,12 +13,14 @@ from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
BookingOut, BookingOut,
BookingPageOut, BookingPageOut,
CreateBookingRequest,
CurrentUser, CurrentUser,
NextBookingRisk, NextBookingRisk,
RegisterReturnRequest, RegisterReturnRequest,
RegisterReturnResult, RegisterReturnResult,
ReturnPreviewResult, ReturnPreviewResult,
) )
from app.services.audit import record_audit_event
from app.services.returns import preview_vehicle_return, register_vehicle_return from app.services.returns import preview_vehicle_return, register_vehicle_return
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"]) router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
@@ -89,6 +93,60 @@ def list_bookings(
) )
@router.post("", response_model=BookingOut, status_code=201)
def create_booking(
body: CreateBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
if body.ends_at <= body.starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref))
if customer is None or customer.merged_into_customer_id is not None:
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref))
if (
vehicle is None
or not vehicle.active
or vehicle.operational_status in {"maintenance", "blocked"}
):
raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking")
overlap = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == vehicle.id,
Booking.status.in_(("reserved", "active")),
Booking.starts_at < body.ends_at,
Booking.ends_at > body.starts_at,
)
)
if overlap is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
booking = Booking(
public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=body.starts_at,
ends_at=body.ends_at,
status="reserved",
start_odometer_km=None,
end_odometer_km=None,
requirements_complete=body.requirements_complete,
)
db.add(booking)
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_created",
entity_type="booking",
entity_id=booking.id,
after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.get("/{public_ref}", response_model=BookingOut) @router.get("/{public_ref}", response_model=BookingOut)
def get_booking( def get_booking(
public_ref: str, public_ref: str,
+8
View File
@@ -61,6 +61,14 @@ class BookingOut(BookingSummaryOut):
customer_name: str customer_name: str
class CreateBookingRequest(BaseModel):
customer_ref: str = Field(min_length=3, max_length=20)
vehicle_ref: str = Field(min_length=3, max_length=20)
starts_at: datetime
ends_at: datetime
requirements_complete: bool = True
class BookingPageOut(BaseModel): class BookingPageOut(BaseModel):
items: list[BookingOut] items: list[BookingOut]
page: int page: int
+22
View File
@@ -20,6 +20,28 @@ def test_list_bookings_supports_bounded_search_pages(ops_client):
assert len(body["items"]) == 25 assert len(body["items"]) == 25
def test_create_booking_rejects_overlap_and_audits_valid_booking(ops_client):
conflict = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001", "vehicle_ref": "MO-024",
"starts_at": "2026-08-10T10:00:00Z", "ends_at": "2026-08-10T12:00:00Z",
},
)
assert conflict.status_code == 409
created = ops_client.post(
"/api/v1/bookings",
json={
"customer_ref": "CUS-0001", "vehicle_ref": "MO-001",
"starts_at": "2026-09-01T10:00:00Z", "ends_at": "2026-09-02T12:00:00Z",
},
)
assert created.status_code == 201
body = created.json()
assert body["status"] == "reserved"
assert body["vehicle_ref"] == "MO-001"
def test_get_booking_detail(ops_client): def test_get_booking_detail(ops_client):
response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN")
assert response.status_code == 200 assert response.status_code == 200