Add ruff linter/formatter for Python
- Add ruff as dev dependency - Configure ruff in pyproject.toml with strict 88-char line limit - Ignore B008 (FastAPI Depends pattern is standard) - Allow longer lines in tests for readability - Fix all lint issues in source files - Add Makefile targets: lint-backend, format-backend, fix-backend
This commit is contained in:
parent
69bc8413e0
commit
6c218130e9
31 changed files with 1234 additions and 876 deletions
|
|
@ -1,22 +1,23 @@
|
|||
"""Audit routes for viewing action records."""
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, func, desc
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from models import User, SumRecord, CounterRecord, Permission
|
||||
from models import CounterRecord, Permission, SumRecord, User
|
||||
from schemas import (
|
||||
CounterRecordResponse,
|
||||
SumRecordResponse,
|
||||
PaginatedCounterRecords,
|
||||
PaginatedSumRecords,
|
||||
SumRecordResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/audit", tags=["audit"])
|
||||
|
||||
R = TypeVar("R", bound=BaseModel)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Authentication routes for register, login, logout, and current user."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy import select
|
||||
|
|
@ -9,18 +10,17 @@ from auth import (
|
|||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
COOKIE_NAME,
|
||||
COOKIE_SECURE,
|
||||
get_password_hash,
|
||||
get_user_by_email,
|
||||
authenticate_user,
|
||||
build_user_response,
|
||||
create_access_token,
|
||||
get_current_user,
|
||||
build_user_response,
|
||||
get_password_hash,
|
||||
get_user_by_email,
|
||||
)
|
||||
from database import get_db
|
||||
from invite_utils import normalize_identifier
|
||||
from models import User, Role, ROLE_REGULAR, Invite, InviteStatus
|
||||
from schemas import UserLogin, UserResponse, RegisterWithInvite
|
||||
|
||||
from models import ROLE_REGULAR, Invite, InviteStatus, Role, User
|
||||
from schemas import RegisterWithInvite, UserLogin, UserResponse
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
|
@ -52,9 +52,8 @@ async def register(
|
|||
"""Register a new user using an invite code."""
|
||||
# Validate invite
|
||||
normalized_identifier = normalize_identifier(user_data.invite_identifier)
|
||||
result = await db.execute(
|
||||
select(Invite).where(Invite.identifier == normalized_identifier)
|
||||
)
|
||||
query = select(Invite).where(Invite.identifier == normalized_identifier)
|
||||
result = await db.execute(query)
|
||||
invite = result.scalar_one_or_none()
|
||||
|
||||
# Return same error for not found, spent, and revoked to avoid information leakage
|
||||
|
|
@ -90,7 +89,7 @@ async def register(
|
|||
# Mark invite as spent
|
||||
invite.status = InviteStatus.SPENT
|
||||
invite.used_by_id = user.id
|
||||
invite.spent_at = datetime.now(timezone.utc)
|
||||
invite.spent_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
"""Availability routes for admin to manage booking availability."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, delete, and_
|
||||
from sqlalchemy import and_, delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from models import User, Availability, Permission
|
||||
from models import Availability, Permission, User
|
||||
from schemas import (
|
||||
TimeSlot,
|
||||
AvailabilityDay,
|
||||
AvailabilityResponse,
|
||||
SetAvailabilityRequest,
|
||||
CopyAvailabilityRequest,
|
||||
SetAvailabilityRequest,
|
||||
TimeSlot,
|
||||
)
|
||||
from shared_constants import MIN_ADVANCE_DAYS, MAX_ADVANCE_DAYS
|
||||
|
||||
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 the valid date range for availability (using MIN_ADVANCE_DAYS to MAX_ADVANCE_DAYS)."""
|
||||
"""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)
|
||||
|
|
@ -34,12 +34,14 @@ def _validate_date_in_range(d: date, min_date: date, max_date: date) -> None:
|
|||
if d < min_date:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot set availability for past dates. Earliest allowed: {min_date}",
|
||||
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 availability more than {MAX_ADVANCE_DAYS} days ahead. Latest allowed: {max_date}",
|
||||
detail=f"Cannot set more than {MAX_ADVANCE_DAYS} days ahead. "
|
||||
f"Latest allowed: {max_date}",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -56,7 +58,7 @@ async def get_availability(
|
|||
status_code=400,
|
||||
detail="'from' date must be before or equal to 'to' date",
|
||||
)
|
||||
|
||||
|
||||
# Query availability in range
|
||||
result = await db.execute(
|
||||
select(Availability)
|
||||
|
|
@ -64,23 +66,24 @@ async def get_availability(
|
|||
.order_by(Availability.date, Availability.start_time)
|
||||
)
|
||||
slots = result.scalars().all()
|
||||
|
||||
|
||||
# Group by date
|
||||
days_dict: dict[date, list[TimeSlot]] = {}
|
||||
for slot in slots:
|
||||
if slot.date not in days_dict:
|
||||
days_dict[slot.date] = []
|
||||
days_dict[slot.date].append(TimeSlot(
|
||||
start_time=slot.start_time,
|
||||
end_time=slot.end_time,
|
||||
))
|
||||
|
||||
days_dict[slot.date].append(
|
||||
TimeSlot(
|
||||
start_time=slot.start_time,
|
||||
end_time=slot.end_time,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert to response format
|
||||
days = [
|
||||
AvailabilityDay(date=d, slots=days_dict[d])
|
||||
for d in sorted(days_dict.keys())
|
||||
AvailabilityDay(date=d, slots=days_dict[d]) for d in sorted(days_dict.keys())
|
||||
]
|
||||
|
||||
|
||||
return AvailabilityResponse(days=days)
|
||||
|
||||
|
||||
|
|
@ -93,29 +96,31 @@ async def set_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, min_date, max_date)
|
||||
|
||||
|
||||
# Validate slots don't overlap
|
||||
sorted_slots = sorted(request.slots, key=lambda s: s.start_time)
|
||||
for i in range(len(sorted_slots) - 1):
|
||||
if sorted_slots[i].end_time > sorted_slots[i + 1].start_time:
|
||||
end = sorted_slots[i].end_time
|
||||
start = sorted_slots[i + 1].start_time
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Time slots overlap on {request.date}: slot ending at {sorted_slots[i].end_time} overlaps with slot starting at {sorted_slots[i + 1].start_time}. Please ensure all time slots are non-overlapping.",
|
||||
detail=f"Time slots overlap: slot ending at {end} "
|
||||
f"overlaps with slot starting at {start}",
|
||||
)
|
||||
|
||||
|
||||
# Validate each slot's end_time > start_time
|
||||
for slot in request.slots:
|
||||
if slot.end_time <= slot.start_time:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid time slot on {request.date}: end time {slot.end_time} must be after start time {slot.start_time}. Please correct the time range.",
|
||||
detail=f"Invalid time slot: end time {slot.end_time} "
|
||||
f"must be after start time {slot.start_time}",
|
||||
)
|
||||
|
||||
|
||||
# Delete existing availability for this date
|
||||
await db.execute(
|
||||
delete(Availability).where(Availability.date == request.date)
|
||||
)
|
||||
|
||||
await db.execute(delete(Availability).where(Availability.date == request.date))
|
||||
|
||||
# Create new availability slots
|
||||
for slot in request.slots:
|
||||
availability = Availability(
|
||||
|
|
@ -124,9 +129,9 @@ async def set_availability(
|
|||
end_time=slot.end_time,
|
||||
)
|
||||
db.add(availability)
|
||||
|
||||
|
||||
await db.commit()
|
||||
|
||||
|
||||
return AvailabilityDay(date=request.date, slots=request.slots)
|
||||
|
||||
|
||||
|
|
@ -138,14 +143,14 @@ async def copy_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 (for consistency, though DB query would fail anyway)
|
||||
|
||||
# Validate source date is in range
|
||||
_validate_date_in_range(request.source_date, min_date, max_date)
|
||||
|
||||
|
||||
# Validate target dates
|
||||
for target_date in request.target_dates:
|
||||
_validate_date_in_range(target_date, min_date, max_date)
|
||||
|
||||
|
||||
# Get source availability
|
||||
result = await db.execute(
|
||||
select(Availability)
|
||||
|
|
@ -153,13 +158,13 @@ async def copy_availability(
|
|||
.order_by(Availability.start_time)
|
||||
)
|
||||
source_slots = result.scalars().all()
|
||||
|
||||
|
||||
if not source_slots:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"No availability found for source date {request.source_date}",
|
||||
)
|
||||
|
||||
|
||||
# Copy to each target date within a single atomic transaction
|
||||
# All deletes and inserts happen before commit, ensuring atomicity
|
||||
copied_days: list[AvailabilityDay] = []
|
||||
|
|
@ -167,12 +172,11 @@ async def copy_availability(
|
|||
for target_date in request.target_dates:
|
||||
if target_date == request.source_date:
|
||||
continue # Skip copying to self
|
||||
|
||||
|
||||
# Delete existing availability for target date
|
||||
await db.execute(
|
||||
delete(Availability).where(Availability.date == target_date)
|
||||
)
|
||||
|
||||
del_query = delete(Availability).where(Availability.date == target_date)
|
||||
await db.execute(del_query)
|
||||
|
||||
# Copy slots
|
||||
target_slots: list[TimeSlot] = []
|
||||
for source_slot in source_slots:
|
||||
|
|
@ -182,19 +186,20 @@ async def copy_availability(
|
|||
end_time=source_slot.end_time,
|
||||
)
|
||||
db.add(new_availability)
|
||||
target_slots.append(TimeSlot(
|
||||
start_time=source_slot.start_time,
|
||||
end_time=source_slot.end_time,
|
||||
))
|
||||
|
||||
target_slots.append(
|
||||
TimeSlot(
|
||||
start_time=source_slot.start_time,
|
||||
end_time=source_slot.end_time,
|
||||
)
|
||||
)
|
||||
|
||||
copied_days.append(AvailabilityDay(date=target_date, slots=target_slots))
|
||||
|
||||
|
||||
# Commit all changes atomically
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Rollback on any error to maintain atomicity
|
||||
await db.rollback()
|
||||
raise
|
||||
|
||||
return AvailabilityResponse(days=copied_days)
|
||||
|
||||
return AvailabilityResponse(days=copied_days)
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
"""Booking routes for users to book appointments."""
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select, and_, func
|
||||
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 models import User, Availability, Appointment, AppointmentStatus, Permission
|
||||
from models import Appointment, AppointmentStatus, Availability, Permission, User
|
||||
from schemas import (
|
||||
BookableSlot,
|
||||
AvailableSlotsResponse,
|
||||
BookingRequest,
|
||||
AppointmentResponse,
|
||||
AvailableSlotsResponse,
|
||||
BookableSlot,
|
||||
BookingRequest,
|
||||
PaginatedAppointments,
|
||||
)
|
||||
from shared_constants import SLOT_DURATION_MINUTES, MIN_ADVANCE_DAYS, MAX_ADVANCE_DAYS
|
||||
|
||||
from shared_constants import MAX_ADVANCE_DAYS, MIN_ADVANCE_DAYS, SLOT_DURATION_MINUTES
|
||||
|
||||
router = APIRouter(prefix="/api/booking", tags=["booking"])
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ def _to_appointment_response(
|
|||
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
|
||||
|
|
@ -49,7 +49,7 @@ def _to_appointment_response(
|
|||
|
||||
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] = []
|
||||
|
|
@ -74,12 +74,14 @@ def _validate_booking_date(d: date) -> None:
|
|||
if d < min_date:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot book for today or past dates. Earliest bookable date: {min_date}",
|
||||
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. Latest bookable: {max_date}",
|
||||
detail=f"Cannot book more than {MAX_ADVANCE_DAYS} days ahead. "
|
||||
f"Latest bookable: {max_date}",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -89,18 +91,18 @@ def _expand_availability_to_slots(
|
|||
) -> 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=timezone.utc)
|
||||
end = datetime.combine(target_date, avail.end_time, tzinfo=timezone.utc)
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -112,7 +114,7 @@ async def get_available_slots(
|
|||
) -> 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)
|
||||
|
|
@ -120,20 +122,19 @@ async def get_available_slots(
|
|||
.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=timezone.utc)
|
||||
day_end = datetime.combine(target_date, time.max, tzinfo=timezone.utc)
|
||||
|
||||
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(
|
||||
select(Appointment.slot_start).where(
|
||||
and_(
|
||||
Appointment.slot_start >= day_start,
|
||||
Appointment.slot_start <= day_end,
|
||||
|
|
@ -142,13 +143,12 @@ async def get_available_slots(
|
|||
)
|
||||
)
|
||||
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
|
||||
slot for slot in all_slots if slot.start_time not in booked_starts
|
||||
]
|
||||
|
||||
|
||||
return AvailableSlotsResponse(date=target_date, slots=available_slots)
|
||||
|
||||
|
||||
|
|
@ -161,27 +161,28 @@ async def create_booking(
|
|||
"""Book an appointment slot."""
|
||||
slot_date = request.slot_start.date()
|
||||
_validate_booking_date(slot_date)
|
||||
|
||||
# Validate slot is on the correct minute boundary (derived from SLOT_DURATION_MINUTES)
|
||||
|
||||
# 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 start time must be on {SLOT_DURATION_MINUTES}-minute boundary (valid minutes: {valid_minutes})",
|
||||
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_time = (request.slot_start + timedelta(minutes=SLOT_DURATION_MINUTES)).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(
|
||||
select(Availability).where(
|
||||
and_(
|
||||
Availability.date == slot_date,
|
||||
Availability.start_time <= slot_start_time,
|
||||
|
|
@ -190,13 +191,15 @@ async def create_booking(
|
|||
)
|
||||
)
|
||||
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 {request.slot_start.strftime('%Y-%m-%d %H:%M')} UTC is not within any available time ranges for {slot_date}. Please select a different time slot.",
|
||||
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(
|
||||
|
|
@ -206,9 +209,9 @@ async def create_booking(
|
|||
note=request.note,
|
||||
status=AppointmentStatus.BOOKED,
|
||||
)
|
||||
|
||||
|
||||
db.add(appointment)
|
||||
|
||||
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
|
|
@ -216,9 +219,9 @@ async def create_booking(
|
|||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This slot has already been booked. Please select another slot.",
|
||||
)
|
||||
|
||||
detail="This slot has already been booked. Select another slot.",
|
||||
) from None
|
||||
|
||||
return _to_appointment_response(appointment, current_user.email)
|
||||
|
||||
|
||||
|
|
@ -241,60 +244,63 @@ async def get_my_appointments(
|
|||
.order_by(Appointment.slot_start.desc())
|
||||
)
|
||||
appointments = result.scalars().all()
|
||||
|
||||
return [
|
||||
_to_appointment_response(apt, current_user.email)
|
||||
for apt in appointments
|
||||
]
|
||||
|
||||
return [_to_appointment_response(apt, current_user.email) for apt in appointments]
|
||||
|
||||
|
||||
@appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
|
||||
@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 explicit eager loading of user relationship
|
||||
# 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 with ID {appointment_id} not found. It may have been deleted or the ID is invalid.",
|
||||
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")
|
||||
|
||||
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 appointment with status '{appointment.status.value}'"
|
||||
detail=f"Cannot cancel: status is '{appointment.status.value}'",
|
||||
)
|
||||
|
||||
|
||||
# Check if appointment is in the past
|
||||
if appointment.slot_start <= datetime.now(timezone.utc):
|
||||
appointment_time = appointment.slot_start.strftime('%Y-%m-%d %H:%M') + " UTC"
|
||||
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 scheduled for {appointment_time} as it is in the past or has already started."
|
||||
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(timezone.utc)
|
||||
|
||||
appointment.cancelled_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
|
||||
|
||||
return _to_appointment_response(appointment, current_user.email)
|
||||
|
||||
|
||||
|
|
@ -302,7 +308,9 @@ async def cancel_my_appointment(
|
|||
# Admin Appointments Endpoints
|
||||
# =============================================================================
|
||||
|
||||
admin_appointments_router = APIRouter(prefix="/api/admin/appointments", tags=["admin-appointments"])
|
||||
admin_appointments_router = APIRouter(
|
||||
prefix="/api/admin/appointments", tags=["admin-appointments"]
|
||||
)
|
||||
|
||||
|
||||
@admin_appointments_router.get("", response_model=PaginatedAppointments)
|
||||
|
|
@ -317,7 +325,7 @@ async def get_all_appointments(
|
|||
count_result = await db.execute(select(func.count(Appointment.id)))
|
||||
total = count_result.scalar() or 0
|
||||
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
|
||||
# Get paginated appointments with explicit eager loading of user relationship
|
||||
offset = (page - 1) * per_page
|
||||
result = await db.execute(
|
||||
|
|
@ -328,13 +336,13 @@ async def get_all_appointments(
|
|||
.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 PaginatedAppointments(
|
||||
records=records,
|
||||
total=total,
|
||||
|
|
@ -344,47 +352,52 @@ async def get_all_appointments(
|
|||
)
|
||||
|
||||
|
||||
@admin_appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentResponse)
|
||||
@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)),
|
||||
_current_user: User = Depends(
|
||||
require_permission(Permission.CANCEL_ANY_APPOINTMENT)
|
||||
),
|
||||
) -> AppointmentResponse:
|
||||
"""Cancel any appointment (admin only)."""
|
||||
# Get the appointment with explicit eager loading of user relationship
|
||||
# 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 with ID {appointment_id} not found. It may have been deleted or the ID is invalid.",
|
||||
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 appointment with status '{appointment.status.value}'"
|
||||
detail=f"Cannot cancel: status is '{appointment.status.value}'",
|
||||
)
|
||||
|
||||
|
||||
# Check if appointment is in the past
|
||||
if appointment.slot_start <= datetime.now(timezone.utc):
|
||||
appointment_time = appointment.slot_start.strftime('%Y-%m-%d %H:%M') + " UTC"
|
||||
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 scheduled for {appointment_time} as it is in the past or has already started."
|
||||
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(timezone.utc)
|
||||
|
||||
appointment.cancelled_at = datetime.now(UTC)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(appointment)
|
||||
|
||||
|
||||
return _to_appointment_response(appointment) # Uses eager-loaded relationship
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"""Counter routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from models import Counter, User, CounterRecord, Permission
|
||||
|
||||
from models import Counter, CounterRecord, Permission, User
|
||||
|
||||
router = APIRouter(prefix="/api/counter", tags=["counter"])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,29 @@
|
|||
"""Invite routes for public check, user invites, and admin management."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy import select, func, desc
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from invite_utils import generate_invite_identifier, normalize_identifier, is_valid_identifier_format
|
||||
from models import User, Invite, InviteStatus, Permission
|
||||
from invite_utils import (
|
||||
generate_invite_identifier,
|
||||
is_valid_identifier_format,
|
||||
normalize_identifier,
|
||||
)
|
||||
from models import Invite, InviteStatus, Permission, User
|
||||
from schemas import (
|
||||
AdminUserResponse,
|
||||
InviteCheckResponse,
|
||||
InviteCreate,
|
||||
InviteResponse,
|
||||
UserInviteResponse,
|
||||
PaginatedInviteRecords,
|
||||
AdminUserResponse,
|
||||
UserInviteResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/invites", tags=["invites"])
|
||||
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
|
@ -54,9 +58,7 @@ async def check_invite(
|
|||
if not is_valid_identifier_format(normalized):
|
||||
return InviteCheckResponse(valid=False, error="Invalid invite code format")
|
||||
|
||||
result = await db.execute(
|
||||
select(Invite).where(Invite.identifier == normalized)
|
||||
)
|
||||
result = await db.execute(select(Invite).where(Invite.identifier == normalized))
|
||||
invite = result.scalar_one_or_none()
|
||||
|
||||
# Return same error for not found, spent, and revoked to avoid information leakage
|
||||
|
|
@ -112,9 +114,7 @@ async def create_invite(
|
|||
) -> InviteResponse:
|
||||
"""Create a new invite for a specified godfather user."""
|
||||
# Validate godfather exists
|
||||
result = await db.execute(
|
||||
select(User.id).where(User.id == data.godfather_id)
|
||||
)
|
||||
result = await db.execute(select(User.id).where(User.id == data.godfather_id))
|
||||
godfather_id = result.scalar_one_or_none()
|
||||
if not godfather_id:
|
||||
raise HTTPException(
|
||||
|
|
@ -141,8 +141,8 @@ async def create_invite(
|
|||
if attempt == MAX_INVITE_COLLISION_RETRIES - 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate unique invite code. Please try again.",
|
||||
)
|
||||
detail="Failed to generate unique invite code. Try again.",
|
||||
) from None
|
||||
|
||||
if invite is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -156,7 +156,9 @@ async def create_invite(
|
|||
async def list_all_invites(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(10, ge=1, le=100),
|
||||
status_filter: str | None = Query(None, alias="status", description="Filter by status: ready, spent, revoked"),
|
||||
status_filter: str | None = Query(
|
||||
None, alias="status", description="Filter by status: ready, spent, revoked"
|
||||
),
|
||||
godfather_id: int | None = Query(None, description="Filter by godfather user ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
|
||||
|
|
@ -175,8 +177,9 @@ async def list_all_invites(
|
|||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status: {status_filter}. Must be ready, spent, or revoked",
|
||||
)
|
||||
detail=f"Invalid status: {status_filter}. "
|
||||
"Must be ready, spent, or revoked",
|
||||
) from None
|
||||
|
||||
if godfather_id:
|
||||
query = query.where(Invite.godfather_id == godfather_id)
|
||||
|
|
@ -224,11 +227,12 @@ async def revoke_invite(
|
|||
if invite.status != InviteStatus.READY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot revoke invite with status '{invite.status.value}'. Only READY invites can be revoked.",
|
||||
detail=f"Cannot revoke invite with status '{invite.status.value}'. "
|
||||
"Only READY invites can be revoked.",
|
||||
)
|
||||
|
||||
invite.status = InviteStatus.REVOKED
|
||||
invite.revoked_at = datetime.now(timezone.utc)
|
||||
invite.revoked_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""Meta endpoints for shared constants."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from models import Permission, InviteStatus, ROLE_ADMIN, ROLE_REGULAR
|
||||
from models import ROLE_ADMIN, ROLE_REGULAR, InviteStatus, Permission
|
||||
from schemas import ConstantsResponse
|
||||
|
||||
router = APIRouter(prefix="/api/meta", tags=["meta"])
|
||||
|
|
@ -15,4 +16,3 @@ async def get_constants() -> ConstantsResponse:
|
|||
roles=[ROLE_ADMIN, ROLE_REGULAR],
|
||||
invite_statuses=[s.value for s in InviteStatus],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"""Profile routes for user contact details."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User, ROLE_REGULAR
|
||||
from models import ROLE_REGULAR, User
|
||||
from schemas import ProfileResponse, ProfileUpdate
|
||||
from validation import validate_profile_fields
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/profile", tags=["profile"])
|
||||
|
||||
|
||||
|
|
@ -29,9 +29,7 @@ async def get_godfather_email(db: AsyncSession, godfather_id: int | None) -> str
|
|||
"""Get the email of a godfather user by ID."""
|
||||
if not godfather_id:
|
||||
return None
|
||||
result = await db.execute(
|
||||
select(User.email).where(User.id == godfather_id)
|
||||
)
|
||||
result = await db.execute(select(User.email).where(User.id == godfather_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"""Sum calculation routes."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from auth import require_permission
|
||||
from database import get_db
|
||||
from models import User, SumRecord, Permission
|
||||
from models import Permission, SumRecord, User
|
||||
from schemas import SumRequest, SumResponse
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/sum", tags=["sum"])
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue