Files
nuzlocke-tracker/backend/src/app/api/games.py
Julian Tabel e8ded9184b
All checks were successful
CI / backend-tests (push) Successful in 32s
CI / frontend-tests (push) Successful in 29s
feat: auth-aware UI and role-based access control (#67)
## Summary

- Add `is_admin` column to users table with Alembic migration and a `require_admin` FastAPI dependency that protects all admin-facing write endpoints (games, pokemon, evolutions, bosses, routes CRUD)
- Expose admin status to frontend via user API and update AuthContext to fetch/store `isAdmin` after login
- Make navigation menu auth-aware (different links for logged-out, logged-in, and admin users) and protect frontend routes with `ProtectedRoute` and `AdminRoute` components, preserving deep-linking through redirects
- Fix test reliability: `drop_all` before `create_all` to clear stale PostgreSQL enums from interrupted test runs
- Fix test auth: add `admin_client` fixture and use valid UUID for mock user so tests pass with new admin-protected endpoints

## Test plan

- [x] All 252 backend tests pass
- [ ] Verify non-admin users cannot access admin write endpoints (games, pokemon, evolutions, bosses CRUD)
- [ ] Verify admin users can access admin endpoints normally
- [ ] Verify navigation shows correct links for logged-out, logged-in, and admin states
- [ ] Verify `/admin/*` routes redirect non-admin users with a toast
- [ ] Verify `/runs/new` and `/genlockes/new` redirect unauthenticated users to login, then back after auth

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #67
Co-authored-by: Julian Tabel <juliantabel.jt@gmail.com>
Co-committed-by: Julian Tabel <juliantabel.jt@gmail.com>
2026-03-21 11:44:05 +01:00

507 lines
16 KiB
Python

import json
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_admin
from app.core.database import get_session
from app.models.boss_battle import BossBattle
from app.models.game import Game
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.schemas.game import (
GameCreate,
GameDetailResponse,
GameResponse,
GameUpdate,
RegionResponse,
RouteCreate,
RouteReorderRequest,
RouteResponse,
RouteUpdate,
RouteWithChildrenResponse,
)
from app.schemas.pokemon import BulkImportResult, BulkRouteItem
from app.seeds.loader import upsert_route_encounters, upsert_routes
router = APIRouter()
async def _get_game_or_404(session: AsyncSession, game_id: int) -> Game:
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
return game
async def _get_version_group_id(session: AsyncSession, game_id: int) -> int:
game = await _get_game_or_404(session, game_id)
if game.version_group_id is None:
raise HTTPException(
status_code=400, detail="Game has no version group assigned"
)
return game.version_group_id
@router.get("", response_model=list[GameResponse])
async def list_games(session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Game).order_by(Game.id))
return result.scalars().all()
@router.get("/by-region", response_model=list[RegionResponse])
async def list_games_by_region(session: AsyncSession = Depends(get_session)):
"""Return games grouped by region with generation metadata and genlocke preset defaults."""
regions_path = Path(__file__).parent.parent / "seeds" / "data" / "regions.json"
with open(regions_path) as f:
regions_data = json.load(f)
result = await session.execute(select(Game).order_by(Game.release_year, Game.name))
all_games = result.scalars().all()
games_by_region: dict[str, list[Game]] = {}
for game in all_games:
games_by_region.setdefault(game.region, []).append(game)
response = []
for region in regions_data:
region_games = games_by_region.get(region["name"], [])
defaults = region["genlocke_defaults"]
response.append(
{
"name": region["name"],
"generation": region["generation"],
"order": region["order"],
"genlocke_defaults": {
"true_genlocke": defaults["true"],
"normal_genlocke": defaults["normal"],
},
"games": region_games,
}
)
return response
@router.get("/{game_id}", response_model=GameDetailResponse)
async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
game = await _get_game_or_404(session, game_id)
vg_id = game.version_group_id
# Load routes via version_group_id
result = await session.execute(
select(Route).where(Route.version_group_id == vg_id).order_by(Route.order)
)
routes = result.scalars().all()
# Attach routes to game for serialization
return {
"id": game.id,
"name": game.name,
"slug": game.slug,
"generation": game.generation,
"region": game.region,
"category": game.category,
"box_art_url": game.box_art_url,
"release_year": game.release_year,
"color": game.color,
"version_group_id": game.version_group_id,
"routes": [
{
"id": r.id,
"name": r.name,
"version_group_id": r.version_group_id,
"order": r.order,
"parent_route_id": r.parent_route_id,
"pinwheel_zone": r.pinwheel_zone,
"encounter_methods": [],
}
for r in routes
],
}
@router.get(
"/{game_id}/routes",
response_model=list[RouteWithChildrenResponse] | list[RouteResponse],
)
async def list_game_routes(
game_id: int,
flat: bool = False,
allowed_types: list[str] | None = Query(None),
session: AsyncSession = Depends(get_session),
):
"""
List routes for a game.
By default, returns a hierarchical structure with top-level routes containing
nested children. Use `flat=True` to get a flat list of all routes.
When `allowed_types` is provided, routes with no encounters matching any of
those Pokemon types are excluded.
"""
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute(
select(Route)
.where(Route.version_group_id == vg_id)
.options(
selectinload(Route.route_encounters).selectinload(RouteEncounter.pokemon)
)
.order_by(Route.order)
)
all_routes = result.scalars().all()
def route_to_dict(route: Route) -> dict:
# Only show encounter methods for the requested game
methods = sorted(
{
re.encounter_method
for re in route.route_encounters
if re.game_id == game_id
}
)
return {
"id": route.id,
"name": route.name,
"version_group_id": route.version_group_id,
"order": route.order,
"parent_route_id": route.parent_route_id,
"pinwheel_zone": route.pinwheel_zone,
"encounter_methods": methods,
}
# Determine which routes have encounters for this game
def has_encounters(route: Route) -> bool:
encounters = [re for re in route.route_encounters if re.game_id == game_id]
if not encounters:
return False
if allowed_types:
return any(
t in allowed_types for re in encounters for t in re.pokemon.types
)
return True
# Collect IDs of parent routes that have at least one child with encounters
parents_with_children = set()
for route in all_routes:
if route.parent_route_id is not None and has_encounters(route):
parents_with_children.add(route.parent_route_id)
if flat:
return [
route_to_dict(r)
for r in all_routes
if has_encounters(r) or r.id in parents_with_children
]
# Build hierarchical structure
# Group children by parent_route_id
children_by_parent: dict[int, list[dict]] = {}
top_level_routes: list[Route] = []
for route in all_routes:
if route.parent_route_id is None:
top_level_routes.append(route)
elif has_encounters(route):
children_by_parent.setdefault(route.parent_route_id, []).append(
route_to_dict(route)
)
# Build response with nested children
# Only include top-level routes that have their own encounters or remaining children
response = []
for route in top_level_routes:
children = children_by_parent.get(route.id, [])
if has_encounters(route) or children:
route_dict = route_to_dict(route)
route_dict["children"] = children
response.append(route_dict)
return response
# --- Admin endpoints ---
@router.post("", response_model=GameResponse, status_code=201)
async def create_game(
data: GameCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
existing = await session.execute(select(Game).where(Game.slug == data.slug))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409, detail="Game with this slug already exists"
)
game = Game(**data.model_dump())
session.add(game)
await session.commit()
await session.refresh(game)
return game
@router.put("/{game_id}", response_model=GameResponse)
async def update_game(
game_id: int,
data: GameUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
update_data = data.model_dump(exclude_unset=True)
if "slug" in update_data:
existing = await session.execute(
select(Game).where(Game.slug == update_data["slug"], Game.id != game_id)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409, detail="Game with this slug already exists"
)
for field, value in update_data.items():
setattr(game, field, value)
await session.commit()
await session.refresh(game)
return game
@router.delete("/{game_id}", status_code=204)
async def delete_game(
game_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
result = await session.execute(
select(Game).where(Game.id == game_id).options(selectinload(Game.runs))
)
game = result.scalar_one_or_none()
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
if game.runs:
raise HTTPException(
status_code=409,
detail="Cannot delete game with existing runs. Delete the runs first.",
)
vg_id = game.version_group_id
# Delete game-specific route_encounters
await session.execute(
delete(RouteEncounter).where(RouteEncounter.game_id == game_id)
)
# Check if this is the last game in the version group
other_games = await session.execute(
select(Game).where(Game.version_group_id == vg_id, Game.id != game_id)
)
is_last_in_group = other_games.scalar_one_or_none() is None
if is_last_in_group and vg_id is not None:
# Delete boss battles
await session.execute(
delete(BossBattle).where(BossBattle.version_group_id == vg_id)
)
# Delete routes (children first due to parent FK)
child_routes = await session.execute(
select(Route).where(
Route.version_group_id == vg_id,
Route.parent_route_id.isnot(None),
)
)
for route in child_routes.scalars().all():
await session.delete(route)
await session.flush()
parent_routes = await session.execute(
select(Route).where(Route.version_group_id == vg_id)
)
for route in parent_routes.scalars().all():
await session.delete(route)
await session.delete(game)
await session.commit()
@router.post("/{game_id}/routes", response_model=RouteResponse, status_code=201)
async def create_route(
game_id: int,
data: RouteCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
route = Route(version_group_id=vg_id, **data.model_dump())
session.add(route)
await session.commit()
await session.refresh(route)
return route
@router.put("/{game_id}/routes/reorder", response_model=list[RouteResponse])
async def reorder_routes(
game_id: int,
data: RouteReorderRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
for item in data.routes:
route = await session.get(Route, item.id)
if route is None or route.version_group_id != vg_id:
raise HTTPException(
status_code=400,
detail=f"Route {item.id} not found in this game",
)
route.order = item.order
await session.commit()
result = await session.execute(
select(Route).where(Route.version_group_id == vg_id).order_by(Route.order)
)
return result.scalars().all()
@router.put("/{game_id}/routes/{route_id}", response_model=RouteResponse)
async def update_route(
game_id: int,
route_id: int,
data: RouteUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
route = await session.get(Route, route_id)
if route is None or route.version_group_id != vg_id:
raise HTTPException(status_code=404, detail="Route not found in this game")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(route, field, value)
await session.commit()
await session.refresh(route)
return route
@router.delete("/{game_id}/routes/{route_id}", status_code=204)
async def delete_route(
game_id: int,
route_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
result = await session.execute(
select(Route)
.where(Route.id == route_id, Route.version_group_id == vg_id)
.options(selectinload(Route.encounters))
)
route = result.scalar_one_or_none()
if route is None:
raise HTTPException(status_code=404, detail="Route not found in this game")
if route.encounters:
raise HTTPException(
status_code=409,
detail="Cannot delete route with existing encounters. Delete the encounters first.",
)
# Null out any boss battle references to this route
await session.execute(
update(BossBattle)
.where(BossBattle.after_route_id == route_id)
.values(after_route_id=None)
)
await session.delete(route)
await session.commit()
@router.post("/{game_id}/routes/bulk-import", response_model=BulkImportResult)
async def bulk_import_routes(
game_id: int,
items: list[BulkRouteItem],
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_admin),
):
vg_id = await _get_version_group_id(session, game_id)
# Build pokeapi_id -> id mapping for encounter resolution
result = await session.execute(select(Pokemon.pokeapi_id, Pokemon.id))
dex_to_id = {row.pokeapi_id: row.id for row in result}
errors: list[str] = []
# Upsert routes using the seed loader (handles parent/child hierarchy)
routes_data = [item.model_dump() for item in items]
try:
route_name_to_id = await upsert_routes(session, vg_id, routes_data)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to import routes: {e}"
) from e
# Upsert encounters for each route
encounter_count = 0
for item in items:
route_id = route_name_to_id.get(item.name)
if route_id is None:
errors.append(f"Route '{item.name}' not found after upsert")
continue
if item.encounters:
try:
count = await upsert_route_encounters(
session,
route_id,
[e.model_dump() for e in item.encounters],
dex_to_id,
game_id,
)
encounter_count += count
except Exception as e:
errors.append(f"Encounters for '{item.name}': {e}")
for child in item.children:
child_id = route_name_to_id.get(child.name)
if child_id is None:
errors.append(f"Child route '{child.name}' not found after upsert")
continue
if child.encounters:
try:
count = await upsert_route_encounters(
session,
child_id,
[e.model_dump() for e in child.encounters],
dex_to_id,
game_id,
)
encounter_count += count
except Exception as e:
errors.append(f"Encounters for '{child.name}': {e}")
await session.commit()
route_count = len(route_name_to_id)
return BulkImportResult(
created=route_count,
updated=encounter_count,
errors=errors,
)