## 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>
507 lines
16 KiB
Python
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,
|
|
)
|