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:
@@ -5,10 +5,13 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.auth import AuthUser, require_auth
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.boss_result_team import BossResultTeam
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
@@ -28,6 +31,18 @@ from app.seeds.loader import upsert_bosses
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _boss_pokemon_load_options():
|
||||
"""Standard eager-loading options for BossPokemon relationships."""
|
||||
return (
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.ability),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move1),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move2),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move3),
|
||||
selectinload(BossBattle.pokemon).selectinload(BossPokemon.move4),
|
||||
)
|
||||
|
||||
|
||||
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
|
||||
game = await session.get(Game, game_id)
|
||||
if game is None:
|
||||
@@ -53,7 +68,7 @@ async def list_bosses(
|
||||
query = (
|
||||
select(BossBattle)
|
||||
.where(BossBattle.version_group_id == vg_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
.order_by(BossBattle.order)
|
||||
)
|
||||
|
||||
@@ -71,6 +86,7 @@ async def reorder_bosses(
|
||||
game_id: int,
|
||||
data: BossReorderRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -101,7 +117,7 @@ async def reorder_bosses(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.version_group_id == vg_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
.order_by(BossBattle.order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -114,6 +130,7 @@ async def create_boss(
|
||||
game_id: int,
|
||||
data: BossBattleCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -133,7 +150,7 @@ async def create_boss(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -144,6 +161,7 @@ async def update_boss(
|
||||
boss_id: int,
|
||||
data: BossBattleUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -158,7 +176,7 @@ async def update_boss(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss_id, BossBattle.version_group_id == vg_id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
boss = result.scalar_one_or_none()
|
||||
if boss is None:
|
||||
@@ -174,7 +192,7 @@ async def update_boss(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -184,6 +202,7 @@ async def delete_boss(
|
||||
game_id: int,
|
||||
boss_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -206,6 +225,7 @@ async def bulk_import_bosses(
|
||||
game_id: int,
|
||||
items: list[BulkBossItem],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -248,6 +268,7 @@ async def set_boss_team(
|
||||
boss_id: int,
|
||||
team: list[BossPokemonInput],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
vg_id = await _get_version_group_id(session, game_id)
|
||||
|
||||
@@ -272,6 +293,13 @@ async def set_boss_team(
|
||||
level=item.level,
|
||||
order=item.order,
|
||||
condition_label=item.condition_label,
|
||||
ability_id=item.ability_id,
|
||||
held_item=item.held_item,
|
||||
nature=item.nature,
|
||||
move1_id=item.move1_id,
|
||||
move2_id=item.move2_id,
|
||||
move3_id=item.move3_id,
|
||||
move4_id=item.move4_id,
|
||||
)
|
||||
session.add(bp)
|
||||
|
||||
@@ -286,7 +314,7 @@ async def set_boss_team(
|
||||
result = await session.execute(
|
||||
select(BossBattle)
|
||||
.where(BossBattle.id == boss.id)
|
||||
.options(selectinload(BossBattle.pokemon).selectinload(BossPokemon.pokemon))
|
||||
.options(*_boss_pokemon_load_options())
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
@@ -301,7 +329,10 @@ async def list_boss_results(run_id: int, session: AsyncSession = Depends(get_ses
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
result = await session.execute(
|
||||
select(BossResult).where(BossResult.run_id == run_id).order_by(BossResult.id)
|
||||
select(BossResult)
|
||||
.where(BossResult.run_id == run_id)
|
||||
.options(selectinload(BossResult.team))
|
||||
.order_by(BossResult.id)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -313,6 +344,7 @@ async def create_boss_result(
|
||||
run_id: int,
|
||||
data: BossResultCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
run = await session.get(NuzlockeRun, run_id)
|
||||
if run is None:
|
||||
@@ -322,12 +354,30 @@ async def create_boss_result(
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
|
||||
# Validate team encounter IDs belong to this run
|
||||
if data.team:
|
||||
encounter_ids = [t.encounter_id for t in data.team]
|
||||
enc_result = await session.execute(
|
||||
select(Encounter).where(
|
||||
Encounter.id.in_(encounter_ids), Encounter.run_id == run_id
|
||||
)
|
||||
)
|
||||
found_encounters = {e.id for e in enc_result.scalars().all()}
|
||||
missing = [eid for eid in encounter_ids if eid not in found_encounters]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Encounters not found in this run: {missing}",
|
||||
)
|
||||
|
||||
# Check for existing result (upsert)
|
||||
existing = await session.execute(
|
||||
select(BossResult).where(
|
||||
select(BossResult)
|
||||
.where(
|
||||
BossResult.run_id == run_id,
|
||||
BossResult.boss_battle_id == data.boss_battle_id,
|
||||
)
|
||||
.options(selectinload(BossResult.team))
|
||||
)
|
||||
result = existing.scalar_one_or_none()
|
||||
|
||||
@@ -335,6 +385,10 @@ async def create_boss_result(
|
||||
result.result = data.result
|
||||
result.attempts = data.attempts
|
||||
result.completed_at = datetime.now(UTC) if data.result == "won" else None
|
||||
# Clear existing team and add new
|
||||
for tm in result.team:
|
||||
await session.delete(tm)
|
||||
await session.flush()
|
||||
else:
|
||||
result = BossResult(
|
||||
run_id=run_id,
|
||||
@@ -344,10 +398,26 @@ async def create_boss_result(
|
||||
completed_at=datetime.now(UTC) if data.result == "won" else None,
|
||||
)
|
||||
session.add(result)
|
||||
await session.flush()
|
||||
|
||||
# Add team members
|
||||
for tm in data.team:
|
||||
team_member = BossResultTeam(
|
||||
boss_result_id=result.id,
|
||||
encounter_id=tm.encounter_id,
|
||||
level=tm.level,
|
||||
)
|
||||
session.add(team_member)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(result)
|
||||
return result
|
||||
|
||||
# Re-fetch with team loaded
|
||||
fresh = await session.execute(
|
||||
select(BossResult)
|
||||
.where(BossResult.id == result.id)
|
||||
.options(selectinload(BossResult.team))
|
||||
)
|
||||
return fresh.scalar_one()
|
||||
|
||||
|
||||
@router.delete("/runs/{run_id}/boss-results/{result_id}", status_code=204)
|
||||
@@ -355,6 +425,7 @@ async def delete_boss_result(
|
||||
run_id: int,
|
||||
result_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
result = await session.execute(
|
||||
select(BossResult).where(
|
||||
|
||||
Reference in New Issue
Block a user