import json import logging from functional import seq from typing import List, Mapping, Union from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from sqlalchemy.orm import Session from app.sql import models from .players import list_players from ..elo import rerank from ..sql import crud, schemas from ..sql.database import get_db from ..templates import jinja_templates api_router = APIRouter(prefix="/game", tags=["game"]) html_router = APIRouter( prefix="/game", include_in_schema=False, default_response_class=HTMLResponse ) LOGGER = logging.getLogger(__name__) ######## # API Routes ######## @api_router.post("/", response_model=schemas.Game, status_code=201) def create_game(game: schemas.GameCreate, db: Session = Depends(get_db)): created_game = crud.create_game(db=db, game=game) # Update ELO scores last_score = ( db.query(models.EloScore).order_by(models.EloScore.after_game_id.desc()).first() ) if last_score: last_scored_game_id = last_score.after_game_id else: last_scored_game_id = 0 if created_game.id != last_scored_game_id + 1: # TODO - better error reporting? LOGGER.error( f"Created a game with id {created_game.id}, which is not after the last-scored-game-id {last_scored_game_id}. ELO calculation paused." ) return created_game deck_ids = [id for id in [getattr(game, f"deck_id_{n+1}") for n in range(6)] if id] deck_scores_before_this_game = [ crud.get_latest_score_for_deck(db, deck_id) for deck_id in deck_ids ] new_scores = rerank( deck_scores_before_this_game, deck_ids.index(game.winning_deck_id) ) for score, deck_id in zip(new_scores, deck_ids): db.add( models.EloScore(after_game_id=created_game.id, deck_id=deck_id, score=score) ) db.commit() return created_game # TODO - when this is updated to support sorting, also update `app/routers/seed.py:all_in_one` to take advantage of it # TODO - and `latest_game` @api_router.get("/list", response_model=list[schemas.Game]) def list_games(skip: int = 0, limit: int = 100, db=Depends(get_db)): return crud.get_games(db, skip=skip, limit=limit) # This is helpful for at least the `all_in_one` data-seed path, but it could conceivably also be useful for a # "Frontpage/dashboard" widget @api_router.get("/latest_game", response_model=schemas.Game) def latest_game(db=Depends(get_db)): # Limit gives me a bit of time to allow natural growth before being forced into updating to basing on sorted-response # Will error out if called on a database with no games # TODO - logging does not seem to be coming up during testing latest_game = db.query(models.Game).order_by(models.Game.date.desc()).first() if latest_game is None: # I.e. database is empty raise HTTPException( status_code=404, detail="Cannot read latest_game of an empty database" ) return latest_game # Note that this must be after all the "static" routes, lest it take precedence @api_router.get("/{game_id}", response_model=schemas.Game) def read_game(game_id: int, db=Depends(get_db)): db_game = crud.get_game_by_id(db, game_id) if db_game is None: raise HTTPException(status_code=404, detail="Game not found") return db_game @api_router.delete("/{game_id}", status_code=204) def delete_game(game_id: str, db=Depends(get_db)): crud.delete_game_by_id(db, int(game_id)) # Do not add more api routes under here! See the comment above `read_game` ######## # HTML Routes ######## @html_router.get("/create", response_class=HTMLResponse) def game_create_html(request: Request, db=Depends(get_db)): players = list_players(db=db) win_types = db.query(models.WinType).all() return jinja_templates.TemplateResponse( request, "games/create.html", { "players": players, # `json.dumps` is necessary because otherwise # the keys are surrounded with single-quotes, # on which JavaScript's `JSON.parse` will choke. "player_decks": json.dumps( { str(player.id): [ {key: getattr(deck, key) for key in ["id", "name"]} for deck in player.decks ] for player in players } ), "win_types": win_types, }, ) # TODO - pagination @html_router.get("/list") def games_html(request: Request, db=Depends(get_db)): games = list_games(db=db) # TODO - a more "data-intensive application" implementation would fetch only the decks involved in the games for # this page decks = crud.get_decks(db=db, limit=-1) decks_by_id = {deck.id: deck for deck in decks} game_names = {game.id: _build_game_deck_names(game, decks_by_id) for game in games} return jinja_templates.TemplateResponse( request, "games/list.html", {"games": games, "decks_by_id": decks_by_id, "game_names": game_names}, ) # Although this is underscore-prefixed, it _is_ intentionally called from `decks.py`, since that logic # is used there, too. # Maybe this should be extracted to a file that's appropriate for "logic that's to do with games, but which is not a # router"? # Still learning my way around FastAPI project structure! def _render_game_participants( game: models.Game, db: Session ) -> List[Mapping[str, Union[str, int]]]: return ( seq(range(6)) .map(lambda i: i + 1) .map(lambda i: f"deck_id_{i}") .map(lambda key: getattr(game, key)) .filter(lambda x: x) # Not every game has 6 participants! .map(lambda deck_id: crud.get_deck_by_id(db, deck_id)) .map(lambda deck: {"owner": deck.owner.name, "name": deck.name, "id": deck.id}) ) def _build_game_deck_names( game: models.Game, decks_by_id: Mapping[int, models.Deck] ) -> List[str]: return ( seq(range(6)) .map(lambda i: i + 1) .map(lambda i: f"deck_id_{i}") .map(lambda key: getattr(game, key)) .filter(lambda x: x) .map(lambda deck_id: decks_by_id[deck_id]) .map(lambda deck: {"owner": deck.owner.name, "name": deck.name, "id": deck.id}) ) # This must be after the static-path routes, lest it take priority over them @html_router.get("/{game_id}") def game_html(request: Request, game_id: str, db=Depends(get_db)): game = read_game(game_id, db) decks = crud.get_decks(db=db, limit=-1) decks_by_id = {deck.id: deck for deck in decks} game_deck_names = _build_game_deck_names(game, decks_by_id) return jinja_templates.TemplateResponse( request, "games/detail.html", {"game": game, "game_deck_names": game_deck_names} )