"""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)