diff --git a/Dockerfile b/Dockerfile index 54155c1..0201887 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,4 +66,4 @@ RUN apt update RUN apt install sqlite3 USER appuser # Expects that the source code will be mounted into the container -CMD uvicorn app:app --reload --host 0.0.0.0 +CMD uvicorn app:app --reload --host 0.0.0.0 --log-config ./local-run-log-config.yaml diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 0c0b83b..7006c61 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import base, decks, games, players, score, seed, stats +from . import base, decks, games, players, score, seed, stats, suggest api_router = APIRouter(prefix="/api") html_router = APIRouter() @@ -11,11 +11,13 @@ api_router.include_router(games.api_router) api_router.include_router(score.api_router) api_router.include_router(seed.api_router) api_router.include_router(stats.api_router) +api_router.include_router(suggest.api_router) html_router.include_router(decks.html_router) html_router.include_router(players.html_router) html_router.include_router(games.html_router) html_router.include_router(seed.html_router) html_router.include_router(stats.html_router) +html_router.include_router(suggest.html_router) html_router.include_router(base.html_router) diff --git a/app/routers/suggest.py b/app/routers/suggest.py new file mode 100644 index 0000000..b6ffbb3 --- /dev/null +++ b/app/routers/suggest.py @@ -0,0 +1,145 @@ +import logging + +from collections import defaultdict +from dataclasses import dataclass +from itertools import product + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import HTMLResponse +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import func +from typing import Callable, List, Mapping, Optional + +from app.routers import players +from app.sql import models +from ..sql.database import get_db +from ..templates import jinja_templates + +api_router = APIRouter(prefix="/suggest", tags=["suggest"]) +html_router = APIRouter( + prefix="/suggest", include_in_schema=False, default_response_class=HTMLResponse +) + +LOGGER = logging.getLogger(__name__) + + +@api_router.get("/matchup") +def suggest_game( + player_ids: Optional[str] = None, + excluded_decks: Optional[str] = None, + strategy_id: Optional[str] = None, + db=Depends(get_db), +): + if not strategy_id: + strategy_id = "smallest_score_spread" + if strategy_id not in STRATEGIES: + raise HTTPException( + status_code=400, + detail=f"Cannot generate a matchup recommendation with strategy {strategy_id}. Available options: {STRATEGIES.keys()}", + ) + + # TODO - this should be an error, surely! + player_ids_as_list = list(map(int, player_ids.split(","))) if player_ids else [] + excluded_decks_as_list = ( + list(map(int, excluded_decks.split(","))) if excluded_decks else [] + ) + LOGGER.info(f"{player_ids=}") + LOGGER.info(f"{excluded_decks=}") + return STRATEGIES[strategy_id].strategy( + player_ids_as_list, excluded_decks_as_list, db + ) + + +def smallest_score_spread_strategy( + player_ids: List[int], excluded_decks: List[int], db: Session +): + row_number_column = ( + func.row_number() + .over( + partition_by=[models.Deck.name], + order_by=models.EloScore.id.desc(), + ) + .label("row_number") + ) + + sub_query = ( + db.query( + models.Deck.id, models.Deck.name, models.Player.name, models.EloScore.score + ) + .join(models.Player, models.Deck.owner_id == models.Player.id) + .join(models.EloScore, models.Deck.id == models.EloScore.deck_id) + .filter(models.Deck.owner_id.in_(player_ids)) + .filter(models.Deck.id.not_in(excluded_decks)) + .add_column(row_number_column) + ) + + sub_query = sub_query.subquery() + query = db.query(sub_query).filter(sub_query.c.row_number == 1) + results = query.all() + + decks_by_player = defaultdict(list) + for row in results: + LOGGER.info(f"Processing {row}") + decks_by_player[row[2]].append( + {"id": row[0], "name": row[1], "latest_score": row[3]} + ) + + # Tried using `heapq` for this logic but it keps producing duplicates :shrug: + matchups = [] + for info_tuple in product(*decks_by_player.values()): + scores = [info["latest_score"] for info in info_tuple] + score_spread = max([abs(x - y) for (x, y) in product(scores, repeat=2)]) + matchup = Matchup( + # Oof, there's _definitely_ a better way of doing this... + participants=list( + zip(decks_by_player.keys(), [info["name"] for info in info_tuple]) + ), + score=100 - score_spread, + description=f"Score spread of {score_spread}, between {max(scores)} and {min(scores)}", + ) + matchups.append((score_spread, matchup)) + + matchups.sort(key=lambda tup: tup[0]) + return [item[1] for item in matchups[:5]] + + +# `order=True` so that tuples containing these can be put into a heap +@dataclass(order=True) +class Matchup: + # `participants` is a List of dictionaries containing `player_name` and `deck_name` + participants: List[Mapping] + score: int + description: str + + +@dataclass +class StrategyDefinition: + strategy_id: str + friendly_name: str + # Parameters are: + # `List[int]` of player_ids + # `List[int]` of excluded_decks + # `Session`, a db-connection + strategy: Callable[[List[int], List[int], Session], List[Matchup]] + description: Optional[str] = None + + +STRATEGIES: Mapping[str, StrategyDefinition] = { + strat_def.strategy_id: strat_def + for strat_def in [ + StrategyDefinition( + strategy_id="smallest_score_spread", + friendly_name="Smallest Score Spread", + strategy=smallest_score_spread_strategy, + ) + ] +} + + +@html_router.get("/matchup") +def suggest_matchup_html(request: Request, db=Depends(get_db)): + return jinja_templates.TemplateResponse( + request, + "suggest/matchup.html", + {"players": players.list_players(db=db), "strategies": STRATEGIES}, + ) diff --git a/app/static/js/suggest/matchup.js b/app/static/js/suggest/matchup.js new file mode 100644 index 0000000..429f398 --- /dev/null +++ b/app/static/js/suggest/matchup.js @@ -0,0 +1,43 @@ +function change_strategy(eventData) { + $('#strategy_select option[value=-1]').attr('disabled', 'disabled') +} + +function get_matchup(eventData) { + console.log($('.player-checkbox:checked')) + player_ids = $('.player-checkbox:checked').map(function(){return $(this).val()}).get().join() + strategy_id = $('#strategy_select').val() + console.log(`Player_ids is ${player_ids}`) + console.log(`Strategy_id is ${strategy_id}`) + + $.ajax({ + type: 'GET', + url: '/api/suggest/matchup?player_id', + data: { + player_ids: player_ids, + strategy_id: strategy_id + }, + contentType: 'application/json', + dataType: 'json', + success: function(data) { + console.log(data); + $('#matchup_display_div').html('

Suggested matchups

') + for (var i = 0; iMatchup ${i+1}`)) + console.log(`${matchup['description']}`) + $('#matchup_display_div').append($(`Description: ${matchup['description']}`)) + $('#matchup_display_div').append($(`

Participants

`)) + ol = $('#matchup_display_div').append($('
    ')) + for (var j = 0; j${matchup.participants[j][0]} playing ${matchup.participants[j][1]}`)) + } + } + } + }) +} + +$(document).ready(function() { + $('#strategy_select').on('change', change_strategy); + + $('#get_matchup').click(get_matchup) +}) \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 50b4aed..155b4b1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -26,6 +26,8 @@ + Suggest Matchup +
    Record New Game diff --git a/app/templates/suggest/matchup.html b/app/templates/suggest/matchup.html new file mode 100644 index 0000000..3f71171 --- /dev/null +++ b/app/templates/suggest/matchup.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} + +{% block title %}Suggest Matchup{% endblock %} + +{% block head %} +{{ super() }} + +{% endblock %} + +{% block content %} +

    Suggest a Matchup

    +

    Who's playing?

    +{% for player in players %} + + +{% endfor %} + +

    How do you want to pick a matchup?

    + +
    +
    + +
    + +{% endblock %} \ No newline at end of file diff --git a/compose.yaml b/compose.yaml index 94eacd4..2d28771 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,6 +9,7 @@ services: source: ./app/ # Yes, really - we're using `/app` as the WD within the container, but `uvicorn` requires an import path. target: /app/app + - ./local-run-log-config.yaml:/app/local-run-log-config.yaml:delegated ports: - 8000:8000