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:
counterweight 2025-12-21 21:54:26 +01:00
parent 69bc8413e0
commit 6c218130e9
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
31 changed files with 1234 additions and 876 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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"])

View file

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

View file

@ -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],
)

View file

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

View file

@ -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"])