Files
nuzlocke-tracker/backend/src/app/api/games.py

268 lines
8.0 KiB
Python
Raw Normal View History

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
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.models.route_encounter import RouteEncounter
from app.schemas.game import (
GameCreate,
GameDetailResponse,
GameResponse,
GameUpdate,
RouteCreate,
RouteReorderRequest,
RouteResponse,
RouteUpdate,
RouteWithChildrenResponse,
)
router = APIRouter()
@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("/{game_id}", response_model=GameDetailResponse)
async def get_game(game_id: int, session: AsyncSession = Depends(get_session)):
result = await session.execute(
select(Game)
.where(Game.id == game_id)
.options(selectinload(Game.routes))
)
game = result.scalar_one_or_none()
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
# Sort routes by order for the response
game.routes.sort(key=lambda r: r.order)
return game
@router.get(
"/{game_id}/routes",
response_model=list[RouteWithChildrenResponse] | list[RouteResponse],
)
async def list_game_routes(
game_id: int,
flat: bool = False,
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.
"""
# Verify game exists
game = await session.get(Game, game_id)
if game is None:
raise HTTPException(status_code=404, detail="Game not found")
result = await session.execute(
select(Route)
.where(Route.game_id == game_id)
.options(selectinload(Route.route_encounters))
.order_by(Route.order)
)
all_routes = result.scalars().all()
def route_to_dict(route: Route) -> dict:
methods = sorted({re.encounter_method for re in route.route_encounters})
return {
"id": route.id,
"name": route.name,
"game_id": route.game_id,
"order": route.order,
"parent_route_id": route.parent_route_id,
"encounter_methods": methods,
}
if flat:
return [route_to_dict(r) for r in all_routes]
# 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)
else:
children_by_parent.setdefault(route.parent_route_id, []).append(
route_to_dict(route)
)
# Build response with nested children
response = []
for route in top_level_routes:
route_dict = route_to_dict(route)
route_dict["children"] = children_by_parent.get(route.id, [])
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)
):
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()