From c3a501e3b20dc90261c12686b7360d037b6709dc Mon Sep 17 00:00:00 2001 From: counterweight Date: Thu, 25 Dec 2025 18:31:13 +0100 Subject: [PATCH] Extract availability logic to AvailabilityService - Create AvailabilityService with get_availability_for_range(), set_availability_for_date(), and copy_availability() - Move slot validation logic to service - Update routes/availability.py to use AvailabilityService - Remove all direct database queries from routes --- backend/routes/availability.py | 143 ++--------------------- backend/services/availability.py | 193 +++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 134 deletions(-) create mode 100644 backend/services/availability.py diff --git a/backend/routes/availability.py b/backend/routes/availability.py index cd21d56..84b8c97 100644 --- a/backend/routes/availability.py +++ b/backend/routes/availability.py @@ -2,21 +2,19 @@ from datetime import date -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import and_, delete, select +from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from auth import require_permission from database import get_db -from date_validation import validate_date_in_range -from models import Availability, Permission, User +from models import Permission, User from schemas import ( AvailabilityDay, AvailabilityResponse, CopyAvailabilityRequest, SetAvailabilityRequest, - TimeSlot, ) +from services.availability import AvailabilityService router = APIRouter(prefix="/api/admin/availability", tags=["availability"]) @@ -29,38 +27,8 @@ async def get_availability( _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) + service = AvailabilityService(db) + return await service.get_availability_for_range(from_date, to_date) @router.put("", response_model=AvailabilityDay) @@ -70,44 +38,8 @@ async def set_availability( _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), ) -> AvailabilityDay: """Set availability for a specific date. Replaces any existing availability.""" - validate_date_in_range(request.date, context="set availability") - - # 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: - end = sorted_slots[i].end_time - start = sorted_slots[i + 1].start_time - raise HTTPException( - status_code=400, - detail=f"Time slots overlap: slot ending at {end} " - f"overlaps with slot starting at {start}", - ) - - # 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"Invalid time slot: end time {slot.end_time} " - f"must be after start time {slot.start_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) + service = AvailabilityService(db) + return await service.set_availability_for_date(request.date, request.slots) @router.post("/copy", response_model=AvailabilityResponse) @@ -117,62 +49,5 @@ async def copy_availability( _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)), ) -> AvailabilityResponse: """Copy availability from one day to multiple target days.""" - # Validate source date is in range - validate_date_in_range(request.source_date, context="copy from") - - # Validate target dates - for target_date in request.target_dates: - validate_date_in_range(target_date, context="copy to") - - # 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 within a single atomic transaction - # All deletes and inserts happen before commit, ensuring atomicity - copied_days: list[AvailabilityDay] = [] - try: - for target_date in request.target_dates: - if target_date == request.source_date: - continue # Skip copying to self - - # Delete existing availability for target date - del_query = delete(Availability).where(Availability.date == target_date) - await db.execute(del_query) - - # 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)) - - # Commit all changes atomically - await db.commit() - except Exception: - # Rollback on any error to maintain atomicity - await db.rollback() - raise - - return AvailabilityResponse(days=copied_days) + service = AvailabilityService(db) + return await service.copy_availability(request.source_date, request.target_dates) diff --git a/backend/services/availability.py b/backend/services/availability.py new file mode 100644 index 0000000..c5dd876 --- /dev/null +++ b/backend/services/availability.py @@ -0,0 +1,193 @@ +"""Availability service for managing booking availability.""" + +from datetime import date + +from sqlalchemy.ext.asyncio import AsyncSession + +# Import for validation +from date_validation import validate_date_in_range +from exceptions import BadRequestError +from models import Availability +from repositories.availability import AvailabilityRepository +from schemas import AvailabilityDay, AvailabilityResponse, TimeSlot + + +class AvailabilityService: + """Service for availability-related business logic.""" + + def __init__(self, db: AsyncSession): + self.db = db + self.availability_repo = AvailabilityRepository(db) + + async def get_availability_for_range( + self, from_date: date, to_date: date + ) -> AvailabilityResponse: + """ + Get availability slots for a date range, grouped by date. + + Args: + from_date: Start date (inclusive) + to_date: End date (inclusive) + + Returns: + AvailabilityResponse with days grouped and sorted + + Raises: + BadRequestError: If from_date > to_date + """ + if from_date > to_date: + raise BadRequestError("'from' date must be before or equal to 'to' date") + + # Query availability in range + slots = await self.availability_repo.get_by_date_range(from_date, to_date) + + # 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) + + def _validate_slots(self, slots: list[TimeSlot]) -> None: + """ + Validate that slots don't overlap and have valid time ordering. + + Raises: + BadRequestError: If validation fails + """ + # Validate slots don't overlap + sorted_slots = sorted(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: + end = sorted_slots[i].end_time + start = sorted_slots[i + 1].start_time + raise BadRequestError( + f"Time slots overlap: slot ending at {end} " + f"overlaps with slot starting at {start}" + ) + + # Validate each slot's end_time > start_time + for slot in slots: + if slot.end_time <= slot.start_time: + raise BadRequestError( + f"Invalid time slot: end time {slot.end_time} " + f"must be after start time {slot.start_time}" + ) + + async def set_availability_for_date( + self, target_date: date, slots: list[TimeSlot] + ) -> AvailabilityDay: + """ + Set availability for a specific date. Replaces any existing availability. + + Args: + target_date: Date to set availability for + slots: List of time slots for the date + + Returns: + AvailabilityDay with the set slots + + Raises: + BadRequestError: If date is invalid or slots are invalid + """ + validate_date_in_range(target_date, context="set availability") + + # Validate slots + self._validate_slots(slots) + + # Delete existing availability for this date + await self.availability_repo.delete_by_date(target_date) + + # Create new availability slots + for slot in slots: + availability = Availability( + date=target_date, + start_time=slot.start_time, + end_time=slot.end_time, + ) + self.db.add(availability) + + await self.db.commit() + + return AvailabilityDay(date=target_date, slots=slots) + + async def copy_availability( + self, source_date: date, target_dates: list[date] + ) -> AvailabilityResponse: + """ + Copy availability from one day to multiple target days. + + Args: + source_date: Date to copy availability from + target_dates: List of dates to copy availability to + + Returns: + AvailabilityResponse with copied days + + Raises: + BadRequestError: If source date has no availability or dates are invalid + """ + # Validate source date is in range + validate_date_in_range(source_date, context="copy from") + + # Validate target dates + for target_date in target_dates: + validate_date_in_range(target_date, context="copy to") + + # Get source availability + source_slots = await self.availability_repo.get_by_date(source_date) + + if not source_slots: + raise BadRequestError( + f"No availability found for source date {source_date}" + ) + + # Copy to each target date within a single atomic transaction + # All deletes and inserts happen before commit, ensuring atomicity + copied_days: list[AvailabilityDay] = [] + try: + for target_date in target_dates: + if target_date == source_date: + continue # Skip copying to self + + # Delete existing availability for target date + await self.availability_repo.delete_by_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, + ) + self.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) + ) + + # Commit all changes atomically + await self.db.commit() + except Exception: + # Rollback on any error to maintain atomicity + await self.db.rollback() + raise + + return AvailabilityResponse(days=copied_days)