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, "pinwheel_zone": route.pinwheel_zone, "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()