arbret/backend/routes/availability.py
counterweight 1cd60b4bbc
Fix: Load booking constants from shared/constants.json
Created shared_constants.py module that loads constants from the
shared JSON file. Updated availability.py and booking.py to import
from this module instead of hardcoding values.

This ensures backend and frontend stay in sync with the same source
of truth for booking configuration.
2025-12-21 17:29:39 +01:00

193 lines
6.6 KiB
Python

"""Availability routes for admin to manage booking availability."""
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, delete, and_
from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission
from database import get_db
from models import User, Availability, Permission
from schemas import (
TimeSlot,
AvailabilityDay,
AvailabilityResponse,
SetAvailabilityRequest,
CopyAvailabilityRequest,
)
from shared_constants import MAX_ADVANCE_DAYS
router = APIRouter(prefix="/api/admin/availability", tags=["availability"])
def _get_date_range_bounds() -> tuple[date, date]:
"""Get the valid date range for availability (tomorrow to +30 days)."""
today = date.today()
min_date = today + timedelta(days=1) # Tomorrow
max_date = today + timedelta(days=MAX_ADVANCE_DAYS)
return min_date, max_date
def _validate_date_in_range(d: date, min_date: date, max_date: date) -> None:
"""Validate a date is within the allowed range."""
if d < min_date:
raise HTTPException(
status_code=400,
detail=f"Cannot set availability for past dates. Earliest allowed: {min_date}",
)
if d > max_date:
raise HTTPException(
status_code=400,
detail=f"Cannot set availability more than {MAX_ADVANCE_DAYS} days ahead. Latest allowed: {max_date}",
)
@router.get("", response_model=AvailabilityResponse)
async def get_availability(
from_date: date = Query(..., alias="from", description="Start date (inclusive)"),
to_date: date = Query(..., alias="to", description="End date (inclusive)"),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
) -> AvailabilityResponse:
"""Get availability slots for a date range."""
if from_date > to_date:
raise HTTPException(
status_code=400,
detail="'from' date must be before or equal to 'to' date",
)
# Query availability in range
result = await db.execute(
select(Availability)
.where(and_(Availability.date >= from_date, Availability.date <= to_date))
.order_by(Availability.date, Availability.start_time)
)
slots = result.scalars().all()
# Group by date
days_dict: dict[date, list[TimeSlot]] = {}
for slot in slots:
if slot.date not in days_dict:
days_dict[slot.date] = []
days_dict[slot.date].append(TimeSlot(
start_time=slot.start_time,
end_time=slot.end_time,
))
# Convert to response format
days = [
AvailabilityDay(date=d, slots=days_dict[d])
for d in sorted(days_dict.keys())
]
return AvailabilityResponse(days=days)
@router.put("", response_model=AvailabilityDay)
async def set_availability(
request: SetAvailabilityRequest,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
) -> AvailabilityDay:
"""Set availability for a specific date. Replaces any existing availability."""
min_date, max_date = _get_date_range_bounds()
_validate_date_in_range(request.date, min_date, max_date)
# Validate slots don't overlap
sorted_slots = sorted(request.slots, key=lambda s: s.start_time)
for i in range(len(sorted_slots) - 1):
if sorted_slots[i].end_time > sorted_slots[i + 1].start_time:
raise HTTPException(
status_code=400,
detail=f"Time slots overlap: {sorted_slots[i].end_time} > {sorted_slots[i + 1].start_time}",
)
# Validate each slot's end_time > start_time
for slot in request.slots:
if slot.end_time <= slot.start_time:
raise HTTPException(
status_code=400,
detail=f"Slot end time must be after start time: {slot.start_time} - {slot.end_time}",
)
# Delete existing availability for this date
await db.execute(
delete(Availability).where(Availability.date == request.date)
)
# Create new availability slots
for slot in request.slots:
availability = Availability(
date=request.date,
start_time=slot.start_time,
end_time=slot.end_time,
)
db.add(availability)
await db.commit()
return AvailabilityDay(date=request.date, slots=request.slots)
@router.post("/copy", response_model=AvailabilityResponse)
async def copy_availability(
request: CopyAvailabilityRequest,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
) -> AvailabilityResponse:
"""Copy availability from one day to multiple target days."""
min_date, max_date = _get_date_range_bounds()
# Validate source date is in range (for consistency, though DB query would fail anyway)
_validate_date_in_range(request.source_date, min_date, max_date)
# Validate target dates
for target_date in request.target_dates:
_validate_date_in_range(target_date, min_date, max_date)
# Get source availability
result = await db.execute(
select(Availability)
.where(Availability.date == request.source_date)
.order_by(Availability.start_time)
)
source_slots = result.scalars().all()
if not source_slots:
raise HTTPException(
status_code=400,
detail=f"No availability found for source date {request.source_date}",
)
# Copy to each target date
copied_days: list[AvailabilityDay] = []
for target_date in request.target_dates:
if target_date == request.source_date:
continue # Skip copying to self
# Delete existing availability for target date
await db.execute(
delete(Availability).where(Availability.date == target_date)
)
# Copy slots
target_slots: list[TimeSlot] = []
for source_slot in source_slots:
new_availability = Availability(
date=target_date,
start_time=source_slot.start_time,
end_time=source_slot.end_time,
)
db.add(new_availability)
target_slots.append(TimeSlot(
start_time=source_slot.start_time,
end_time=source_slot.end_time,
))
copied_days.append(AvailabilityDay(date=target_date, slots=target_slots))
await db.commit()
return AvailabilityResponse(days=copied_days)