"""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 availabilities = [ Availability( date=target_date, start_time=slot.start_time, end_time=slot.end_time, ) for slot in slots ] await self.availability_repo.create_multiple(availabilities) await self.availability_repo.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] = [] new_availabilities = [ Availability( date=target_date, start_time=source_slot.start_time, end_time=source_slot.end_time, ) for source_slot in source_slots ] await self.availability_repo.create_multiple(new_availabilities) target_slots = [ TimeSlot( start_time=slot.start_time, end_time=slot.end_time, ) for slot in source_slots ] copied_days.append( AvailabilityDay(date=target_date, slots=target_slots) ) # Commit all changes atomically await self.availability_repo.commit() except Exception: # Rollback on any error to maintain atomicity await self.db.rollback() raise return AvailabilityResponse(days=copied_days)