Phase 7: Final cleanup - Remove deprecated booking/appointment code

Deleted deprecated files:
- backend/routes/booking.py
- frontend/app/admin/appointments/, booking/, appointments/, sum/, audit/
- frontend/app/utils/appointment.ts
- frontend/e2e/booking.spec.ts, appointments.spec.ts

Updated references:
- exchange/page.tsx: Use /api/exchange/slots instead of /api/booking/slots
- useRequireAuth.ts: Redirect to /admin/trades and /exchange
- profile.tsx, invites.tsx: Update fallback redirect
- E2E tests: Update all /audit references to /admin/trades
- profile.test.tsx: Update admin redirect test
This commit is contained in:
counterweight 2025-12-22 20:18:33 +01:00
parent 9e8d0af435
commit bbd9fae763
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
16 changed files with 29 additions and 2103 deletions

View file

@ -1,383 +0,0 @@
"""Booking routes for users to book appointments."""
from collections.abc import Sequence
from datetime import UTC, date, datetime, time, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
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 (
AppointmentResponse,
AvailableSlotsResponse,
BookableSlot,
BookingRequest,
PaginatedAppointments,
)
from shared_constants import SLOT_DURATION_MINUTES
router = APIRouter(prefix="/api/booking", tags=["booking"])
def _to_appointment_response(
appointment: Appointment,
user_email: str | None = None,
) -> AppointmentResponse:
"""Convert an Appointment model to AppointmentResponse schema.
Args:
appointment: The appointment model instance
user_email: Optional user email. If not provided, uses appointment.user.email
"""
email = user_email if user_email is not None else appointment.user.email
return AppointmentResponse(
id=appointment.id,
user_id=appointment.user_id,
user_email=email,
slot_start=appointment.slot_start,
slot_end=appointment.slot_end,
note=appointment.note,
status=appointment.status.value,
created_at=appointment.created_at,
cancelled_at=appointment.cancelled_at,
)
def _get_valid_minute_boundaries() -> tuple[int, ...]:
"""Get valid minute boundaries based on SLOT_DURATION_MINUTES.
Assumes SLOT_DURATION_MINUTES divides 60 evenly (e.g., 15 minutes = 0, 15, 30, 45).
"""
boundaries: list[int] = []
minute = 0
while minute < 60:
boundaries.append(minute)
minute += SLOT_DURATION_MINUTES
return tuple(boundaries)
def _validate_booking_date(d: date) -> None:
"""Validate a date is within the bookable range."""
validate_date_in_range(d, context="book")
def _expand_availability_to_slots(
availability_slots: Sequence[Availability],
target_date: date,
) -> list[BookableSlot]:
"""Expand availability time ranges into 15-minute bookable slots."""
result: list[BookableSlot] = []
for avail in availability_slots:
# Create datetime objects for start and end
current = datetime.combine(target_date, avail.start_time, tzinfo=UTC)
end = datetime.combine(target_date, avail.end_time, tzinfo=UTC)
# Generate 15-minute slots
while current + timedelta(minutes=SLOT_DURATION_MINUTES) <= end:
slot_end = current + timedelta(minutes=SLOT_DURATION_MINUTES)
result.append(BookableSlot(start_time=current, end_time=slot_end))
current = slot_end
return result
@router.get("/slots", response_model=AvailableSlotsResponse)
async def get_available_slots(
target_date: date = Query(..., alias="date", description="Date to get slots for"),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)),
) -> AvailableSlotsResponse:
"""Get available booking slots for a specific date."""
_validate_booking_date(target_date)
# Get availability for this date
result = await db.execute(
select(Availability)
.where(Availability.date == target_date)
.order_by(Availability.start_time)
)
availability_slots = result.scalars().all()
if not availability_slots:
return AvailableSlotsResponse(date=target_date, slots=[])
# Expand to 15-minute slots
all_slots = _expand_availability_to_slots(availability_slots, target_date)
# Get existing booked appointments for this date
day_start = datetime.combine(target_date, time.min, tzinfo=UTC)
day_end = datetime.combine(target_date, time.max, tzinfo=UTC)
result = await db.execute(
select(Appointment.slot_start).where(
and_(
Appointment.slot_start >= day_start,
Appointment.slot_start <= day_end,
Appointment.status == AppointmentStatus.BOOKED,
)
)
)
booked_starts = {row[0] for row in result.fetchall()}
# Filter out already booked slots
available_slots = [
slot for slot in all_slots if slot.start_time not in booked_starts
]
return AvailableSlotsResponse(date=target_date, slots=available_slots)
@router.post("", response_model=AppointmentResponse)
async def create_booking(
request: BookingRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.BOOK_APPOINTMENT)),
) -> AppointmentResponse:
"""Book an appointment slot."""
slot_date = request.slot_start.date()
_validate_booking_date(slot_date)
# Validate slot is on the correct minute boundary
valid_minutes = _get_valid_minute_boundaries()
if request.slot_start.minute not in valid_minutes:
raise HTTPException(
status_code=400,
detail=f"Slot must be on {SLOT_DURATION_MINUTES}-minute boundary "
f"(valid minutes: {valid_minutes})",
)
if request.slot_start.second != 0 or request.slot_start.microsecond != 0:
raise HTTPException(
status_code=400,
detail="Slot start time must not have seconds or microseconds",
)
# Verify slot falls within availability
slot_start_time = request.slot_start.time()
slot_end_dt = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
slot_end_time = slot_end_dt.time()
result = await db.execute(
select(Availability).where(
and_(
Availability.date == slot_date,
Availability.start_time <= slot_start_time,
Availability.end_time >= slot_end_time,
)
)
)
matching_availability = result.scalar_one_or_none()
if not matching_availability:
slot_str = request.slot_start.strftime("%Y-%m-%d %H:%M")
raise HTTPException(
status_code=400,
detail=f"Selected slot at {slot_str} UTC is not within "
f"any available time ranges for {slot_date}",
)
# Create the appointment
slot_end = request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)
appointment = Appointment(
user_id=current_user.id,
slot_start=request.slot_start,
slot_end=slot_end,
note=request.note,
status=AppointmentStatus.BOOKED,
)
db.add(appointment)
try:
await db.commit()
await db.refresh(appointment)
except IntegrityError:
await db.rollback()
raise HTTPException(
status_code=409,
detail="This slot has already been booked. Select another slot.",
) from None
return _to_appointment_response(appointment, current_user.email)
# =============================================================================
# User's Appointments Endpoints
# =============================================================================
appointments_router = APIRouter(prefix="/api/appointments", tags=["appointments"])
@appointments_router.get("", response_model=list[AppointmentResponse])
async def get_my_appointments(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.VIEW_OWN_APPOINTMENTS)),
) -> list[AppointmentResponse]:
"""Get the current user's appointments, sorted by date (upcoming first)."""
result = await db.execute(
select(Appointment)
.where(Appointment.user_id == current_user.id)
.order_by(Appointment.slot_start.desc())
)
appointments = result.scalars().all()
return [_to_appointment_response(apt, current_user.email) for apt in appointments]
@appointments_router.post(
"/{appointment_id}/cancel", response_model=AppointmentResponse
)
async def cancel_my_appointment(
appointment_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_permission(Permission.CANCEL_OWN_APPOINTMENT)),
) -> AppointmentResponse:
"""Cancel one of the current user's appointments."""
# Get the appointment with eager loading of user relationship
result = await db.execute(
select(Appointment)
.options(joinedload(Appointment.user))
.where(Appointment.id == appointment_id)
)
appointment = result.scalar_one_or_none()
if not appointment:
raise HTTPException(
status_code=404,
detail=f"Appointment {appointment_id} not found",
)
# Verify ownership
if appointment.user_id != current_user.id:
raise HTTPException(
status_code=403,
detail="Cannot cancel another user's appointment",
)
# Check if already cancelled
if appointment.status != AppointmentStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel: status is '{appointment.status.value}'",
)
# Check if appointment is in the past
if appointment.slot_start <= datetime.now(UTC):
apt_time = appointment.slot_start.strftime("%Y-%m-%d %H:%M")
raise HTTPException(
status_code=400,
detail=f"Cannot cancel appointment at {apt_time} UTC: "
"already started or in the past",
)
# Cancel the appointment
appointment.status = AppointmentStatus.CANCELLED_BY_USER
appointment.cancelled_at = datetime.now(UTC)
await db.commit()
await db.refresh(appointment)
return _to_appointment_response(appointment, current_user.email)
# =============================================================================
# Admin Appointments Endpoints
# =============================================================================
admin_appointments_router = APIRouter(
prefix="/api/admin/appointments", tags=["admin-appointments"]
)
@admin_appointments_router.get("", response_model=PaginatedAppointments)
async def get_all_appointments(
page: int = Query(1, ge=1),
per_page: int = Query(10, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(require_permission(Permission.VIEW_ALL_APPOINTMENTS)),
) -> PaginatedAppointments:
"""Get all appointments (admin only), sorted by date descending with pagination."""
# Get total count
count_result = await db.execute(select(func.count(Appointment.id)))
total = count_result.scalar() or 0
# Get paginated appointments with explicit eager loading of user relationship
offset = calculate_offset(page, per_page)
result = await db.execute(
select(Appointment)
.options(joinedload(Appointment.user))
.order_by(Appointment.slot_start.desc())
.offset(offset)
.limit(per_page)
)
appointments = result.scalars().all()
# Build responses using the eager-loaded user relationship
records = [
_to_appointment_response(apt) # Uses eager-loaded relationship
for apt in appointments
]
return create_paginated_response(records, total, page, per_page)
@admin_appointments_router.post(
"/{appointment_id}/cancel", response_model=AppointmentResponse
)
async def admin_cancel_appointment(
appointment_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
),
) -> AppointmentResponse:
"""Cancel any appointment (admin only)."""
# Get the appointment with eager loading of user relationship
result = await db.execute(
select(Appointment)
.options(joinedload(Appointment.user))
.where(Appointment.id == appointment_id)
)
appointment = result.scalar_one_or_none()
if not appointment:
raise HTTPException(
status_code=404,
detail=f"Appointment {appointment_id} not found",
)
# Check if already cancelled
if appointment.status != AppointmentStatus.BOOKED:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel: status is '{appointment.status.value}'",
)
# Check if appointment is in the past
if appointment.slot_start <= datetime.now(UTC):
apt_time = appointment.slot_start.strftime("%Y-%m-%d %H:%M")
raise HTTPException(
status_code=400,
detail=f"Cannot cancel appointment at {apt_time} UTC: "
"already started or in the past",
)
# Cancel the appointment
appointment.status = AppointmentStatus.CANCELLED_BY_ADMIN
appointment.cancelled_at = datetime.now(UTC)
await db.commit()
await db.refresh(appointment)
return _to_appointment_response(appointment) # Uses eager-loaded relationship
# All routers from this module for easy registration
routers = [router, appointments_router, admin_appointments_router]