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

@ -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(

View file

@ -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(