2025-12-25 18:31:13 +01:00
|
|
|
"""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
|
2025-12-25 18:51:55 +01:00
|
|
|
availabilities = [
|
|
|
|
|
Availability(
|
2025-12-25 18:31:13 +01:00
|
|
|
date=target_date,
|
|
|
|
|
start_time=slot.start_time,
|
|
|
|
|
end_time=slot.end_time,
|
|
|
|
|
)
|
2025-12-25 18:51:55 +01:00
|
|
|
for slot in slots
|
|
|
|
|
]
|
|
|
|
|
await self.availability_repo.create_multiple(availabilities)
|
2025-12-25 18:31:13 +01:00
|
|
|
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] = []
|
2025-12-25 18:51:55 +01:00
|
|
|
new_availabilities = [
|
|
|
|
|
Availability(
|
2025-12-25 18:31:13 +01:00
|
|
|
date=target_date,
|
|
|
|
|
start_time=source_slot.start_time,
|
|
|
|
|
end_time=source_slot.end_time,
|
|
|
|
|
)
|
2025-12-25 18:51:55 +01:00
|
|
|
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,
|
2025-12-25 18:31:13 +01:00
|
|
|
)
|
2025-12-25 18:51:55 +01:00
|
|
|
for slot in source_slots
|
|
|
|
|
]
|
2025-12-25 18:31:13 +01:00
|
|
|
|
|
|
|
|
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)
|