feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s

Add user authentication with login/signup/protected routes, boss pokemon
detail fields and result team tracking, moves and abilities selector
components and API, run ownership and visibility controls, and various
UI improvements across encounters, run list, and journal pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 21:41:38 +01:00
parent a6cb309b8b
commit 0a519e356e
69 changed files with 3574 additions and 693 deletions

View File

@@ -1,10 +1,12 @@
from datetime import UTC, datetime
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, selectinload
from app.core.auth import AuthUser, get_current_user, require_auth
from app.core.database import get_session
from app.models.boss_result import BossResult
from app.models.encounter import Encounter
@@ -12,8 +14,10 @@ from app.models.evolution import Evolution
from app.models.game import Game
from app.models.genlocke import GenlockeLeg
from app.models.genlocke_transfer import GenlockeTransfer
from app.models.nuzlocke_run import NuzlockeRun
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
from app.models.user import User
from app.schemas.run import (
OwnerResponse,
RunCreate,
RunDetailResponse,
RunGenlockeContext,
@@ -157,41 +161,136 @@ async def _compute_lineage_suggestion(
return f"{base_name} {numeral}"
def _build_run_response(run: NuzlockeRun) -> RunResponse:
"""Build RunResponse with owner info if present."""
owner = None
if run.owner:
owner = OwnerResponse(id=run.owner.id, display_name=run.owner.display_name)
return RunResponse(
id=run.id,
game_id=run.game_id,
name=run.name,
status=run.status,
rules=run.rules,
hof_encounter_ids=run.hof_encounter_ids,
naming_scheme=run.naming_scheme,
visibility=run.visibility,
owner=owner,
started_at=run.started_at,
completed_at=run.completed_at,
)
def _check_run_access(
run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False
) -> None:
"""
Check if user can access the run.
Raises 403 for private runs if user is not owner.
If require_owner=True, always requires ownership (for mutations).
"""
if run.owner_id is None:
# Unowned runs are accessible by everyone (legacy)
if require_owner:
raise HTTPException(
status_code=403, detail="Only the run owner can perform this action"
)
return
user_id = UUID(user.id) if user else None
if require_owner:
if user_id != run.owner_id:
raise HTTPException(
status_code=403, detail="Only the run owner can perform this action"
)
return
if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id:
raise HTTPException(status_code=403, detail="This run is private")
@router.post("", response_model=RunResponse, status_code=201)
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
async def create_run(
data: RunCreate,
session: AsyncSession = Depends(get_session),
user: AuthUser = Depends(require_auth),
):
# Validate game exists
game = await session.get(Game, data.game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
# Ensure user exists in local DB
user_id = UUID(user.id)
db_user = await session.get(User, user_id)
if db_user is None:
db_user = User(id=user_id, email=user.email or "")
session.add(db_user)
run = NuzlockeRun(
game_id=data.game_id,
owner_id=user_id,
name=data.name,
status="active",
visibility=data.visibility,
rules=data.rules,
naming_scheme=data.naming_scheme,
)
session.add(run)
await session.commit()
await session.refresh(run)
return run
# Reload with owner relationship
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run.id)
.options(joinedload(NuzlockeRun.owner))
)
run = result.scalar_one()
return _build_run_response(run)
@router.get("", response_model=list[RunResponse])
async def list_runs(session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(NuzlockeRun).order_by(NuzlockeRun.started_at.desc())
)
return result.scalars().all()
async def list_runs(
request: Request,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
"""
List runs. Shows public runs and user's own private runs.
"""
query = select(NuzlockeRun).options(joinedload(NuzlockeRun.owner))
if user:
user_id = UUID(user.id)
# Show public runs OR runs owned by current user
query = query.where(
(NuzlockeRun.visibility == RunVisibility.PUBLIC)
| (NuzlockeRun.owner_id == user_id)
)
else:
# Anonymous: only public runs
query = query.where(NuzlockeRun.visibility == RunVisibility.PUBLIC)
query = query.order_by(NuzlockeRun.started_at.desc())
result = await session.execute(query)
runs = result.scalars().all()
return [_build_run_response(run) for run in runs]
@router.get("/{run_id}", response_model=RunDetailResponse)
async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
async def get_run(
run_id: int,
request: Request,
session: AsyncSession = Depends(get_session),
user: AuthUser | None = Depends(get_current_user),
):
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run_id)
.options(
joinedload(NuzlockeRun.game),
joinedload(NuzlockeRun.owner),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.pokemon),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.current_pokemon),
selectinload(NuzlockeRun.encounters).joinedload(Encounter.route),
@@ -201,6 +300,9 @@ async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Check visibility access
_check_run_access(run, user)
# Check if this run belongs to a genlocke
genlocke_context = None
leg_result = await session.execute(
@@ -262,11 +364,20 @@ async def update_run(
run_id: int,
data: RunUpdate,
session: AsyncSession = Depends(get_session),
user: AuthUser = Depends(require_auth),
):
run = await session.get(NuzlockeRun, run_id)
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run_id)
.options(joinedload(NuzlockeRun.owner))
)
run = result.scalar_one_or_none()
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Check ownership for mutations (unowned runs allow anyone for backwards compat)
_check_run_access(run, user, require_owner=run.owner_id is not None)
update_data = data.model_dump(exclude_unset=True)
# Validate hof_encounter_ids if provided
@@ -352,16 +463,30 @@ async def update_run(
genlocke.status = "completed"
await session.commit()
await session.refresh(run)
return run
# Reload with owner relationship
result = await session.execute(
select(NuzlockeRun)
.where(NuzlockeRun.id == run.id)
.options(joinedload(NuzlockeRun.owner))
)
run = result.scalar_one()
return _build_run_response(run)
@router.delete("/{run_id}", status_code=204)
async def delete_run(run_id: int, session: AsyncSession = Depends(get_session)):
async def delete_run(
run_id: int,
session: AsyncSession = Depends(get_session),
user: AuthUser = Depends(require_auth),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
# Check ownership for deletion (unowned runs allow anyone for backwards compat)
_check_run_access(run, user, require_owner=run.owner_id is not None)
# Block deletion if run is linked to a genlocke leg
leg_result = await session.execute(
select(GenlockeLeg).where(GenlockeLeg.run_id == run_id)