edh-elo/app/routers/games.py
Jack Jackson 6f6b58159f
Some checks failed
Publish / build-and-push (push) Failing after 4m6s
Default to "most-recent 10 games" in /game/list view
Also fully migrate to Gitea Actions (from Drone) for CI/CD.
2025-04-05 16:35:39 -07:00

233 lines
8.4 KiB
Python

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
]
winning_deck_ids = [deck_ids.index(game.winning_deck_id)]
if game.other_winning_deck_ids:
winning_deck_ids.extend(
[
deck_ids.index(int(other_winning_id))
for other_winning_id in game.other_winning_deck_ids.split(",")
]
)
new_scores = rerank(deck_scores_before_this_game, winning_deck_ids)
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, sort_by="id", sort_order="asc", db=Depends(get_db)
):
match sort_by:
case "id" | "date":
return crud.get_games(
db, skip=skip, limit=limit, sort_by=sort_by, sort_order=sort_order
)
case "winning_deck":
sort_by_column = (
models.Deck.name.desc() if sort_order == "desc" else models.Deck.name
)
return (
db.query(models.Game)
.join(models.Deck, models.Game.winning_deck_id == models.Deck.id)
.order_by(sort_by_column)
.offset(skip)
.limit(limit)
.all()
)
case _:
raise HTTPException(
# TODO - would be really cool if there was a way to reference the called-URL from this message!
# For more genericized code. Maybe can do this by setting `request: Request` in the function signature
# and referencing it?
status_code=400,
detail="Unsupported sort_by for /api/",
)
# 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)):
query_parameters = request.query_params
if not query_parameters:
# I.e. they navigated to just `/game/list`, meaning we should serve the default view - 10 latest games
query_parameters = {"sort_order": "desc", "limit": 10}
# `**query_parameters` expands the object as named-params to the function - setting `limit` and `offset` appropriately
games = list_games(db=db, **query_parameters)
# 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}
)