feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user