
Some checks failed
Publish / build-and-push (push) Failing after 4m6s
Also fully migrate to Gitea Actions (from Drone) for CI/CD.
233 lines
8.4 KiB
Python
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}
|
|
)
|