From e577c16db588d6ff09296fe50294698f0c1025ff Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:20:28 +0200 Subject: [PATCH] M9: create validated internal bookings --- backend/app/api/routers/bookings.py | 58 +++++++++++++++++++++++++++++ backend/app/schemas.py | 8 ++++ backend/tests/test_bookings.py | 22 +++++++++++ 3 files changed, 88 insertions(+) diff --git a/backend/app/api/routers/bookings.py b/backend/app/api/routers/bookings.py index e1bc619..a577459 100644 --- a/backend/app/api/routers/bookings.py +++ b/backend/app/api/routers/bookings.py @@ -1,5 +1,7 @@ from __future__ import annotations +import uuid + from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from sqlalchemy import func, or_, select from sqlalchemy.orm import Session @@ -11,12 +13,14 @@ from app.models.vehicle import Vehicle from app.schemas import ( BookingOut, BookingPageOut, + CreateBookingRequest, CurrentUser, NextBookingRisk, RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult, ) +from app.services.audit import record_audit_event from app.services.returns import preview_vehicle_return, register_vehicle_return 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) def get_booking( public_ref: str, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 55d3e61..a65763d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -61,6 +61,14 @@ class BookingOut(BookingSummaryOut): 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): items: list[BookingOut] page: int diff --git a/backend/tests/test_bookings.py b/backend/tests/test_bookings.py index 86ed0f3..fdd15fa 100644 --- a/backend/tests/test_bookings.py +++ b/backend/tests/test_bookings.py @@ -20,6 +20,28 @@ def test_list_bookings_supports_bounded_search_pages(ops_client): 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): response = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN") assert response.status_code == 200