first round of review

This commit is contained in:
counterweight 2025-12-20 11:43:32 +01:00
parent 870804e7b9
commit 23049da55a
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
15 changed files with 325 additions and 182 deletions

View file

@ -6,6 +6,7 @@ from fastapi import FastAPI, Depends, HTTPException, Response, status, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, EmailStr
from sqlalchemy import select, func, desc
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from auth import (
@ -25,7 +26,7 @@ from auth import (
from database import engine, get_db, Base
from models import Counter, User, SumRecord, CounterRecord, Permission, Role, ROLE_REGULAR, Invite, InviteStatus
from validation import validate_profile_fields
from invite_utils import generate_invite_identifier, normalize_identifier
from invite_utils import generate_invite_identifier, normalize_identifier, is_valid_identifier_format
R = TypeVar("R", bound=BaseModel)
@ -115,6 +116,10 @@ async def check_invite(
"""Check if an invite is valid and can be used for signup."""
normalized = normalize_identifier(identifier)
# Validate format before querying database
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)
)
@ -441,18 +446,23 @@ async def require_regular_user(
return current_user
async def get_godfather_email(db: AsyncSession, godfather_id: int | None) -> str | None:
"""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)
)
return result.scalar_one_or_none()
@app.get("/api/profile", response_model=ProfileResponse)
async def get_profile(
current_user: User = Depends(require_regular_user),
db: AsyncSession = Depends(get_db),
):
"""Get the current user's profile (contact details and godfather)."""
godfather_email = None
if current_user.godfather_id:
result = await db.execute(
select(User.email).where(User.id == current_user.godfather_id)
)
godfather_email = result.scalar_one_or_none()
godfather_email = await get_godfather_email(db, current_user.godfather_id)
return ProfileResponse(
contact_email=current_user.contact_email,
@ -493,13 +503,7 @@ async def update_profile(
await db.commit()
await db.refresh(current_user)
# Get godfather email if set
godfather_email = None
if current_user.godfather_id:
gf_result = await db.execute(
select(User.email).where(User.id == current_user.godfather_id)
)
godfather_email = gf_result.scalar_one_or_none()
godfather_email = await get_godfather_email(db, current_user.godfather_id)
return ProfileResponse(
contact_email=current_user.contact_email,
@ -530,6 +534,9 @@ class InviteResponse(BaseModel):
revoked_at: datetime | None
MAX_INVITE_COLLISION_RETRIES = 3
@app.post("/api/admin/invites", response_model=InviteResponse)
async def create_invite(
data: InviteCreate,
@ -537,33 +544,46 @@ async def create_invite(
_current_user: User = Depends(require_permission(Permission.MANAGE_INVITES)),
):
"""Create a new invite for a specified godfather user."""
# Validate godfather exists
result = await db.execute(select(User).where(User.id == data.godfather_id))
godfather = result.scalar_one_or_none()
if not godfather:
# Validate godfather exists and get their info
result = await db.execute(
select(User.id, User.email).where(User.id == data.godfather_id)
)
godfather_row = result.one_or_none()
if not godfather_row:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Godfather user not found",
)
godfather_id, godfather_email = godfather_row
# Generate unique identifier
identifier = generate_invite_identifier()
# Create invite
invite = Invite(
identifier=identifier,
godfather_id=godfather.id,
status=InviteStatus.READY,
)
db.add(invite)
await db.commit()
await db.refresh(invite)
# Try to create invite with retry on collision
invite: Invite | None = None
for attempt in range(MAX_INVITE_COLLISION_RETRIES):
identifier = generate_invite_identifier()
invite = Invite(
identifier=identifier,
godfather_id=godfather_id,
status=InviteStatus.READY,
)
db.add(invite)
try:
await db.commit()
await db.refresh(invite)
break
except IntegrityError:
await db.rollback()
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.",
)
assert invite is not None # We either succeeded or raised an exception above
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather.email,
godfather_email=godfather_email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=None,
@ -596,26 +616,18 @@ async def get_my_invites(
)
invites = result.scalars().all()
responses = []
for invite in invites:
used_by_email = None
if invite.used_by_id:
# Fetch the user who used this invite
user_result = await db.execute(
select(User.email).where(User.id == invite.used_by_id)
)
used_by_email = user_result.scalar_one_or_none()
responses.append(UserInviteResponse(
# Use preloaded used_by relationship (selectin loading)
return [
UserInviteResponse(
id=invite.id,
identifier=invite.identifier,
status=invite.status.value,
used_by_email=used_by_email,
used_by_email=invite.used_by.email if invite.used_by else None,
created_at=invite.created_at,
spent_at=invite.spent_at,
))
return responses
)
for invite in invites
]
# Admin Invite Management
@ -674,37 +686,23 @@ async def list_all_invites(
total = count_result.scalar() or 0
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
# Get paginated invites
# Get paginated invites (relationships loaded via selectin)
offset = (page - 1) * per_page
query = query.order_by(desc(Invite.created_at)).offset(offset).limit(per_page)
result = await db.execute(query)
invites = result.scalars().all()
# Build responses with user emails
# Build responses using preloaded relationships
records = []
for invite in invites:
# Get godfather email
gf_result = await db.execute(
select(User.email).where(User.id == invite.godfather_id)
)
godfather_email = gf_result.scalar_one()
# Get used_by email if applicable
used_by_email = None
if invite.used_by_id:
ub_result = await db.execute(
select(User.email).where(User.id == invite.used_by_id)
)
used_by_email = ub_result.scalar_one_or_none()
records.append(InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather_email,
godfather_email=invite.godfather.email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=used_by_email,
used_by_email=invite.used_by.email if invite.used_by else None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,
@ -746,20 +744,15 @@ async def revoke_invite(
await db.commit()
await db.refresh(invite)
# Get godfather email
gf_result = await db.execute(
select(User.email).where(User.id == invite.godfather_id)
)
godfather_email = gf_result.scalar_one()
# Use preloaded relationships (selectin loading)
return InviteResponse(
id=invite.id,
identifier=invite.identifier,
godfather_id=invite.godfather_id,
godfather_email=godfather_email,
godfather_email=invite.godfather.email,
status=invite.status.value,
used_by_id=invite.used_by_id,
used_by_email=None,
used_by_email=invite.used_by.email if invite.used_by else None,
created_at=invite.created_at,
spent_at=invite.spent_at,
revoked_at=invite.revoked_at,