Add admin panel with CRUD endpoints and management UI

Add admin API endpoints for games, routes, pokemon, and route encounters
with full CRUD operations including bulk import. Build admin frontend
with game/route/pokemon management pages, navigation, and data tables.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 18:36:19 +01:00
parent a911259ef5
commit 55e6650e0e
28 changed files with 2140 additions and 10 deletions

View File

@@ -6,7 +6,16 @@ from sqlalchemy.orm import selectinload
from app.core.database import get_session
from app.models.game import Game
from app.models.route import Route
from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse
from app.schemas.game import (
GameCreate,
GameDetailResponse,
GameResponse,
GameUpdate,
RouteCreate,
RouteReorderRequest,
RouteResponse,
RouteUpdate,
)
router = APIRouter()
@@ -48,3 +57,161 @@ async def list_game_routes(
.order_by(Route.order)
)
return result.scalars().all()
# --- Admin endpoints ---
@router.post("", response_model=GameResponse, status_code=201)
async def create_game(
data: GameCreate, session: AsyncSession = Depends(get_session)
):
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)
):
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)
):
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.",
)
# Delete routes (and their route_encounters via cascade)
routes = await session.execute(
select(Route).where(Route.game_id == game_id)
)
for route in 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)
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
route = Route(game_id=game_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),
):
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
for item in data.routes:
route = await session.get(Route, item.id)
if route is None or route.game_id != game_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.game_id == game_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),
):
route = await session.get(Route, route_id)
if route is None or route.game_id != game_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),
):
result = await session.execute(
select(Route)
.where(Route.id == route_id, Route.game_id == game_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.",
)
await session.delete(route)
await session.commit()

View File

@@ -1,17 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from sqlalchemy.orm import joinedload, selectinload
from app.core.database import get_session
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.route_encounter import RouteEncounter
from app.schemas.pokemon import PokemonResponse, RouteEncounterDetailResponse
from app.schemas.pokemon import (
BulkImportItem,
BulkImportResult,
PokemonCreate,
PokemonResponse,
PokemonUpdate,
RouteEncounterCreate,
RouteEncounterDetailResponse,
RouteEncounterUpdate,
)
router = APIRouter()
@router.get("/pokemon", response_model=list[PokemonResponse])
async def list_pokemon(
search: str | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
session: AsyncSession = Depends(get_session),
):
query = select(Pokemon)
if search:
query = query.where(
func.lower(Pokemon.name).contains(search.lower())
)
query = query.order_by(Pokemon.national_dex).offset(offset).limit(limit)
result = await session.execute(query)
return result.scalars().all()
@router.post("/pokemon/bulk-import", response_model=BulkImportResult)
async def bulk_import_pokemon(
items: list[BulkImportItem],
session: AsyncSession = Depends(get_session),
):
created = 0
updated = 0
errors: list[str] = []
for item in items:
try:
existing = await session.execute(
select(Pokemon).where(Pokemon.national_dex == item.national_dex)
)
pokemon = existing.scalar_one_or_none()
if pokemon is not None:
pokemon.name = item.name
pokemon.types = item.types
if item.sprite_url is not None:
pokemon.sprite_url = item.sprite_url
updated += 1
else:
pokemon = Pokemon(**item.model_dump())
session.add(pokemon)
created += 1
except Exception as e:
errors.append(f"Dex #{item.national_dex} ({item.name}): {e}")
await session.commit()
return BulkImportResult(created=created, updated=updated, errors=errors)
@router.post("/pokemon", response_model=PokemonResponse, status_code=201)
async def create_pokemon(
data: PokemonCreate, session: AsyncSession = Depends(get_session)
):
existing = await session.execute(
select(Pokemon).where(Pokemon.national_dex == data.national_dex)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail=f"Pokemon with national dex #{data.national_dex} already exists",
)
pokemon = Pokemon(**data.model_dump())
session.add(pokemon)
await session.commit()
await session.refresh(pokemon)
return pokemon
@router.get("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def get_pokemon(
pokemon_id: int, session: AsyncSession = Depends(get_session)
@@ -22,6 +101,61 @@ async def get_pokemon(
return pokemon
@router.put("/pokemon/{pokemon_id}", response_model=PokemonResponse)
async def update_pokemon(
pokemon_id: int,
data: PokemonUpdate,
session: AsyncSession = Depends(get_session),
):
pokemon = await session.get(Pokemon, pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
update_data = data.model_dump(exclude_unset=True)
if "national_dex" in update_data:
existing = await session.execute(
select(Pokemon).where(
Pokemon.national_dex == update_data["national_dex"],
Pokemon.id != pokemon_id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=409,
detail=f"Pokemon with national dex #{update_data['national_dex']} already exists",
)
for field, value in update_data.items():
setattr(pokemon, field, value)
await session.commit()
await session.refresh(pokemon)
return pokemon
@router.delete("/pokemon/{pokemon_id}", status_code=204)
async def delete_pokemon(
pokemon_id: int, session: AsyncSession = Depends(get_session)
):
result = await session.execute(
select(Pokemon)
.where(Pokemon.id == pokemon_id)
.options(selectinload(Pokemon.encounters))
)
pokemon = result.scalar_one_or_none()
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
if pokemon.encounters:
raise HTTPException(
status_code=409,
detail="Cannot delete pokemon with existing encounters.",
)
await session.delete(pokemon)
await session.commit()
@router.get(
"/routes/{route_id}/pokemon",
response_model=list[RouteEncounterDetailResponse],
@@ -41,3 +175,88 @@ async def list_route_encounters(
.order_by(RouteEncounter.encounter_rate.desc())
)
return result.scalars().unique().all()
@router.post(
"/routes/{route_id}/pokemon",
response_model=RouteEncounterDetailResponse,
status_code=201,
)
async def add_route_encounter(
route_id: int,
data: RouteEncounterCreate,
session: AsyncSession = Depends(get_session),
):
route = await session.get(Route, route_id)
if route is None:
raise HTTPException(status_code=404, detail="Route not found")
pokemon = await session.get(Pokemon, data.pokemon_id)
if pokemon is None:
raise HTTPException(status_code=404, detail="Pokemon not found")
encounter = RouteEncounter(route_id=route_id, **data.model_dump())
session.add(encounter)
await session.commit()
# Reload with pokemon relationship
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.id == encounter.id)
.options(joinedload(RouteEncounter.pokemon))
)
return result.scalar_one()
@router.put(
"/routes/{route_id}/pokemon/{encounter_id}",
response_model=RouteEncounterDetailResponse,
)
async def update_route_encounter(
route_id: int,
encounter_id: int,
data: RouteEncounterUpdate,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.id == encounter_id, RouteEncounter.route_id == route_id)
.options(joinedload(RouteEncounter.pokemon))
)
encounter = result.scalar_one_or_none()
if encounter is None:
raise HTTPException(status_code=404, detail="Route encounter not found")
for field, value in data.model_dump(exclude_unset=True).items():
setattr(encounter, field, value)
await session.commit()
await session.refresh(encounter)
# Reload with pokemon relationship
result = await session.execute(
select(RouteEncounter)
.where(RouteEncounter.id == encounter.id)
.options(joinedload(RouteEncounter.pokemon))
)
return result.scalar_one()
@router.delete("/routes/{route_id}/pokemon/{encounter_id}", status_code=204)
async def remove_route_encounter(
route_id: int,
encounter_id: int,
session: AsyncSession = Depends(get_session),
):
encounter = await session.execute(
select(RouteEncounter).where(
RouteEncounter.id == encounter_id,
RouteEncounter.route_id == route_id,
)
)
encounter = encounter.scalar_one_or_none()
if encounter is None:
raise HTTPException(status_code=404, detail="Route encounter not found")
await session.delete(encounter)
await session.commit()

View File

@@ -4,25 +4,51 @@ from app.schemas.encounter import (
EncounterResponse,
EncounterUpdate,
)
from app.schemas.game import GameDetailResponse, GameResponse, RouteResponse
from app.schemas.game import (
GameCreate,
GameDetailResponse,
GameResponse,
GameUpdate,
RouteCreate,
RouteReorderRequest,
RouteResponse,
RouteUpdate,
)
from app.schemas.pokemon import (
BulkImportItem,
BulkImportResult,
PokemonCreate,
PokemonResponse,
PokemonUpdate,
RouteEncounterCreate,
RouteEncounterDetailResponse,
RouteEncounterResponse,
RouteEncounterUpdate,
)
from app.schemas.run import RunCreate, RunDetailResponse, RunResponse, RunUpdate
__all__ = [
"BulkImportItem",
"BulkImportResult",
"EncounterCreate",
"EncounterDetailResponse",
"EncounterResponse",
"EncounterUpdate",
"GameCreate",
"GameDetailResponse",
"GameResponse",
"RouteResponse",
"GameUpdate",
"PokemonCreate",
"PokemonResponse",
"PokemonUpdate",
"RouteCreate",
"RouteEncounterCreate",
"RouteEncounterDetailResponse",
"RouteEncounterResponse",
"RouteEncounterUpdate",
"RouteReorderRequest",
"RouteResponse",
"RouteUpdate",
"RunCreate",
"RunDetailResponse",
"RunResponse",

View File

@@ -20,3 +20,43 @@ class GameResponse(CamelModel):
class GameDetailResponse(GameResponse):
routes: list[RouteResponse] = []
# --- Admin schemas ---
class GameCreate(CamelModel):
name: str
slug: str
generation: int
region: str
box_art_url: str | None = None
release_year: int | None = None
class GameUpdate(CamelModel):
name: str | None = None
slug: str | None = None
generation: int | None = None
region: str | None = None
box_art_url: str | None = None
release_year: int | None = None
class RouteCreate(CamelModel):
name: str
order: int
class RouteUpdate(CamelModel):
name: str | None = None
order: int | None = None
class RouteReorderItem(CamelModel):
id: int
order: int
class RouteReorderRequest(CamelModel):
routes: list[RouteReorderItem]

View File

@@ -1,3 +1,5 @@
from pydantic import BaseModel
from app.schemas.base import CamelModel
@@ -21,3 +23,48 @@ class RouteEncounterResponse(CamelModel):
class RouteEncounterDetailResponse(RouteEncounterResponse):
pokemon: PokemonResponse
# --- Admin schemas ---
class PokemonCreate(CamelModel):
national_dex: int
name: str
types: list[str]
sprite_url: str | None = None
class PokemonUpdate(CamelModel):
national_dex: int | None = None
name: str | None = None
types: list[str] | None = None
sprite_url: str | None = None
class RouteEncounterCreate(CamelModel):
pokemon_id: int
encounter_method: str
encounter_rate: int
min_level: int
max_level: int
class RouteEncounterUpdate(CamelModel):
encounter_method: str | None = None
encounter_rate: int | None = None
min_level: int | None = None
max_level: int | None = None
class BulkImportItem(BaseModel):
national_dex: int
name: str
types: list[str]
sprite_url: str | None = None
class BulkImportResult(CamelModel):
created: int
updated: int
errors: list[str]