diff --git a/backend/date_validation.py b/backend/date_validation.py new file mode 100644 index 0000000..828ca6f --- /dev/null +++ b/backend/date_validation.py @@ -0,0 +1,52 @@ +"""Date range validation utilities for booking and availability.""" + +from datetime import date, timedelta + +from fastapi import HTTPException + +from shared_constants import MAX_ADVANCE_DAYS, MIN_ADVANCE_DAYS + + +def get_bookable_date_range() -> tuple[date, date]: + """ + Get the valid date range for booking/availability. + + Returns: + Tuple of (min_date, max_date) where: + - min_date is MIN_ADVANCE_DAYS from today + - max_date is MAX_ADVANCE_DAYS from today + """ + today = date.today() + min_date = today + timedelta(days=MIN_ADVANCE_DAYS) + max_date = today + timedelta(days=MAX_ADVANCE_DAYS) + return min_date, max_date + + +def validate_date_in_range( + d: date, + *, + context: str = "action", +) -> None: + """ + Validate a date is within the bookable range. + + Args: + d: The date to validate + context: Description for error messages (e.g., "book", "set availability") + + Raises: + HTTPException: If the date is outside the valid range + """ + min_date, max_date = get_bookable_date_range() + + if d < min_date: + raise HTTPException( + status_code=400, + detail=f"Cannot {context} for past dates. Earliest allowed: {min_date}", + ) + if d > max_date: + raise HTTPException( + status_code=400, + detail=f"Cannot {context} more than {MAX_ADVANCE_DAYS} days ahead. " + f"Latest allowed: {max_date}", + ) diff --git a/backend/routes/availability.py b/backend/routes/availability.py index a55ae46..cd21d56 100644 --- a/backend/routes/availability.py +++ b/backend/routes/availability.py @@ -1,6 +1,6 @@ """Availability routes for admin to manage booking availability.""" -from datetime import date, timedelta +from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import and_, delete, select @@ -8,6 +8,7 @@ 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 schemas import ( AvailabilityDay, @@ -16,35 +17,10 @@ from schemas import ( SetAvailabilityRequest, TimeSlot, ) -from shared_constants import MAX_ADVANCE_DAYS, MIN_ADVANCE_DAYS router = APIRouter(prefix="/api/admin/availability", tags=["availability"]) -def _get_date_range_bounds() -> tuple[date, date]: - """Get valid date range (MIN_ADVANCE_DAYS to MAX_ADVANCE_DAYS).""" - today = date.today() - min_date = today + timedelta(days=MIN_ADVANCE_DAYS) - 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. " - f"Earliest allowed: {min_date}", - ) - if d > max_date: - raise HTTPException( - status_code=400, - detail=f"Cannot set more than {MAX_ADVANCE_DAYS} days ahead. " - f"Latest allowed: {max_date}", - ) - - @router.get("", response_model=AvailabilityResponse) async def get_availability( from_date: date = Query(..., alias="from", description="Start date (inclusive)"), @@ -94,8 +70,7 @@ async def set_availability( _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_date_in_range(request.date, context="set availability") # Validate slots don't overlap sorted_slots = sorted(request.slots, key=lambda s: s.start_time) @@ -142,14 +117,12 @@ async def copy_availability( _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 - _validate_date_in_range(request.source_date, min_date, max_date) + 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, min_date, max_date) + validate_date_in_range(target_date, context="copy to") # Get source availability result = await db.execute( diff --git a/backend/routes/booking.py b/backend/routes/booking.py index b91178d..b6185ee 100644 --- a/backend/routes/booking.py +++ b/backend/routes/booking.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import joinedload from auth import require_permission from database import get_db +from date_validation import validate_date_in_range from models import Appointment, AppointmentStatus, Availability, Permission, User from pagination import calculate_offset, create_paginated_response from schemas import ( @@ -20,7 +21,7 @@ from schemas import ( BookingRequest, PaginatedAppointments, ) -from shared_constants import MAX_ADVANCE_DAYS, MIN_ADVANCE_DAYS, SLOT_DURATION_MINUTES +from shared_constants import SLOT_DURATION_MINUTES router = APIRouter(prefix="/api/booking", tags=["booking"]) @@ -62,29 +63,9 @@ def _get_valid_minute_boundaries() -> tuple[int, ...]: return tuple(boundaries) -def _get_bookable_date_range() -> tuple[date, date]: - """Get the valid date range for booking (tomorrow to +30 days).""" - today = date.today() - min_date = today + timedelta(days=MIN_ADVANCE_DAYS) - max_date = today + timedelta(days=MAX_ADVANCE_DAYS) - return min_date, max_date - - def _validate_booking_date(d: date) -> None: """Validate a date is within the bookable range.""" - min_date, max_date = _get_bookable_date_range() - if d < min_date: - raise HTTPException( - status_code=400, - detail=f"Cannot book for today or past dates. " - f"Earliest bookable: {min_date}", - ) - if d > max_date: - raise HTTPException( - status_code=400, - detail=f"Cannot book more than {MAX_ADVANCE_DAYS} days ahead. " - f"Latest bookable: {max_date}", - ) + validate_date_in_range(d, context="book") def _expand_availability_to_slots(