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:
parent
0dd84e90a5
commit
db7a0dbe28
3 changed files with 60 additions and 54 deletions
52
backend/date_validation.py
Normal file
52
backend/date_validation.py
Normal 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}",
|
||||||
|
)
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue