refactor(backend): extract date range validation utilities

Issue #5: Date validation logic was duplicated across availability
and booking routes.

Changes:
- Add date_validation.py with shared utilities:
  - get_bookable_date_range: returns (min_date, max_date) tuple
  - validate_date_in_range: validates date with contextual errors
- Update routes/availability.py to use shared utilities
- Update routes/booking.py to use shared utilities
- Remove redundant _get_date_range_bounds and _get_bookable_date_range
- Error messages now include context (book, set availability, etc.)
This commit is contained in:
counterweight 2025-12-22 00:02:41 +01:00
parent 0dd84e90a5
commit db7a0dbe28
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
3 changed files with 60 additions and 54 deletions

View file

@ -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}",
)

View file

@ -1,6 +1,6 @@
"""Availability routes for admin to manage booking availability.""" """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 fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, delete, select from sqlalchemy import and_, delete, select
@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from date_validation import validate_date_in_range
from models import Availability, Permission, User from models import Availability, Permission, User
from schemas import ( from schemas import (
AvailabilityDay, AvailabilityDay,
@ -16,35 +17,10 @@ from schemas import (
SetAvailabilityRequest, SetAvailabilityRequest,
TimeSlot, TimeSlot,
) )
from shared_constants import MAX_ADVANCE_DAYS, MIN_ADVANCE_DAYS
router = APIRouter(prefix="/api/admin/availability", tags=["availability"]) 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) @router.get("", response_model=AvailabilityResponse)
async def get_availability( async def get_availability(
from_date: date = Query(..., alias="from", description="Start date (inclusive)"), 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)), _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
) -> AvailabilityDay: ) -> AvailabilityDay:
"""Set availability for a specific date. Replaces any existing availability.""" """Set availability for a specific date. Replaces any existing availability."""
min_date, max_date = _get_date_range_bounds() validate_date_in_range(request.date, context="set availability")
_validate_date_in_range(request.date, min_date, max_date)
# Validate slots don't overlap # Validate slots don't overlap
sorted_slots = sorted(request.slots, key=lambda s: s.start_time) 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)), _current_user: User = Depends(require_permission(Permission.MANAGE_AVAILABILITY)),
) -> AvailabilityResponse: ) -> AvailabilityResponse:
"""Copy availability from one day to multiple target days.""" """Copy availability from one day to multiple target days."""
min_date, max_date = _get_date_range_bounds()
# Validate source date is in range # 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 # Validate target dates
for target_date in request.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 # Get source availability
result = await db.execute( result = await db.execute(

View file

@ -11,6 +11,7 @@ from sqlalchemy.orm import joinedload
from auth import require_permission from auth import require_permission
from database import get_db from database import get_db
from date_validation import validate_date_in_range
from models import Appointment, AppointmentStatus, Availability, Permission, User from models import Appointment, AppointmentStatus, Availability, Permission, User
from pagination import calculate_offset, create_paginated_response from pagination import calculate_offset, create_paginated_response
from schemas import ( from schemas import (
@ -20,7 +21,7 @@ from schemas import (
BookingRequest, BookingRequest,
PaginatedAppointments, 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"]) router = APIRouter(prefix="/api/booking", tags=["booking"])
@ -62,29 +63,9 @@ def _get_valid_minute_boundaries() -> tuple[int, ...]:
return tuple(boundaries) 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: def _validate_booking_date(d: date) -> None:
"""Validate a date is within the bookable range.""" """Validate a date is within the bookable range."""
min_date, max_date = _get_bookable_date_range() validate_date_in_range(d, context="book")
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}",
)
def _expand_availability_to_slots( def _expand_availability_to_slots(