Add cursory "biggest movers" stats
Also adds rudimentary testing framework for seeding a database from a given `.db` SQLite file. Probably extract this for general use!
This commit is contained in:
parent
460467bd0b
commit
f120336f1d
@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from ..sql import crud
|
||||
from ..routers.stats import top_movers
|
||||
from ..templates import jinja_templates, _jsonify
|
||||
from ..sql.database import get_db
|
||||
|
||||
@ -11,8 +12,9 @@ html_router = APIRouter(include_in_schema=False, default_response_class=HTMLResp
|
||||
@html_router.get("/")
|
||||
def main(request: Request, db=Depends(get_db)):
|
||||
games = crud.get_games(db=db)
|
||||
movers = top_movers(db=db)
|
||||
return jinja_templates.TemplateResponse(
|
||||
request, "/main.html", {"games": _jsonify(games)}
|
||||
request, "/main.html", {"games": _jsonify(games), "top_movers": movers}
|
||||
)
|
||||
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, MINYEAR
|
||||
from datetime import datetime, timedelta, MINYEAR
|
||||
from heapq import nlargest, nsmallest
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
@ -76,6 +77,77 @@ def stats_graph_api(
|
||||
}
|
||||
|
||||
|
||||
# As with many APIs, this is a candidate for parallelization if desired -
|
||||
# could key by deck_id, then in parallel get scores over the time period for that deck.
|
||||
# But performance isn't likely to be a big issue!
|
||||
@api_router.get("/top_movers")
|
||||
def top_movers(
|
||||
lookback_in_days: int = 7,
|
||||
number_of_movers: int = 3,
|
||||
db=Depends(get_db),
|
||||
):
|
||||
# TODO - this will error-out on an empty database
|
||||
date_of_latest_game = (
|
||||
db.query(models.Game.date)
|
||||
.order_by(models.Game.date.desc())
|
||||
.limit(1)
|
||||
.first()
|
||||
._tuple()[0]
|
||||
)
|
||||
beginning_of_lookback = date_of_latest_game - timedelta(days=lookback_in_days)
|
||||
|
||||
# TODO - this mostly duplicates logic from `stats_graph_api`. Extract?
|
||||
row_number_column = (
|
||||
func.row_number()
|
||||
.over(
|
||||
partition_by=[models.Deck.name, models.Game.date],
|
||||
order_by=models.EloScore.id.desc(),
|
||||
)
|
||||
.label("row_number")
|
||||
)
|
||||
sub_query = (
|
||||
db.query(
|
||||
models.Deck.id, models.Deck.name, models.EloScore.score, models.Game.date
|
||||
)
|
||||
.outerjoin(models.EloScore, models.Deck.id == models.EloScore.deck_id)
|
||||
.join(models.Game, models.EloScore.after_game_id == models.Game.id)
|
||||
.add_column(row_number_column)
|
||||
.subquery()
|
||||
)
|
||||
scores = (
|
||||
db.query(sub_query)
|
||||
.filter(sub_query.c.row_number == 1)
|
||||
.order_by(sub_query.c.date)
|
||||
.all()
|
||||
)
|
||||
score_tracker = defaultdict(dict)
|
||||
# First, get the score-per-deck at the start and end of the time period
|
||||
for score in scores:
|
||||
if score.date <= beginning_of_lookback:
|
||||
score_tracker[score.id]["start_score"] = score.score
|
||||
score_tracker[score.id]["latest_score"] = score.score
|
||||
# Technically we don't need to _keep_ adding this (as it won't change for a given deck_id) - but, until/unless
|
||||
# this logic is parallelized, there's no efficient way for the algorithm to know that it's operating on a deck
|
||||
# that's already been seen once before
|
||||
score_tracker[score.id]["name"] = score.name
|
||||
# Then, find biggest movers
|
||||
calculateds = [
|
||||
{
|
||||
"deck_id": deck_id,
|
||||
"name": score_tracker[deck_id]["name"],
|
||||
"start": score_tracker[deck_id]["start_score"],
|
||||
"end": score_tracker[deck_id]["latest_score"],
|
||||
"diff": score_tracker[deck_id]["latest_score"]
|
||||
- score_tracker[deck_id]["start_score"],
|
||||
}
|
||||
for deck_id in score_tracker
|
||||
]
|
||||
return {
|
||||
"positive": nlargest(number_of_movers, calculateds, key=lambda x: x["diff"]),
|
||||
"negative": nsmallest(number_of_movers, calculateds, key=lambda x: x["diff"]),
|
||||
}
|
||||
|
||||
|
||||
@html_router.get("/graph")
|
||||
def stats_graph(request: Request, db=Depends(get_db)):
|
||||
return jinja_templates.TemplateResponse(request, "stats/graph.html")
|
||||
|
@ -4,8 +4,58 @@
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
<!-- https://www.w3schools.com/css/css_tooltip.asp -->
|
||||
<style>
|
||||
|
||||
h2 {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
|
||||
}
|
||||
|
||||
/* Tooltip text */
|
||||
.tooltip .tooltiptext {
|
||||
visibility: hidden;
|
||||
width: 480px;
|
||||
background-color: black;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
padding: 5px 0;
|
||||
border-radius: 6px;
|
||||
|
||||
/* Position the tooltip text - see examples below! */
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Show the tooltip text when you mouse over the tooltip container */
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p>Welcome to EDH ELO! Click "Games" above to see the list of Games, or "Record New Game" in the top-right to record a game</p>
|
||||
<div>
|
||||
<h2>Biggest recent movers</h2><div class="tooltip">?<span class="tooltiptext">Logic:<br/>Find the date of the latest game<br/>Look back a period of 7 days from that date<br/>Calculate score differential between those two dates for all decks<br/>Rank by that<br/><br/>TODO - add a dedicated "biggest movers" page under "Stats" where anchor dates and number-of-top-movers can be specified</span></span></div>
|
||||
<h3>Positive</h3>
|
||||
<ol>
|
||||
{% for positive_mover in top_movers['positive'] %}
|
||||
<li><strong>{{ positive_mover['name'] }}</strong> - +{{ positive_mover['diff'] }} ({{ positive_mover['start'] }} -> {{ positive_mover['end'] }})</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
<h3>Negative</h3>
|
||||
<ol>
|
||||
{% for negative_mover in top_movers['negative'] %}
|
||||
<li><strong>{{ negative_mover['name'] }}</strong> - {{ negative_mover['diff'] }} ({{ negative_mover['start'] }} -> {{ negative_mover['end'] }})</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
</div>
|
||||
{% endblock %}
|
BIN
test-data/sqlite-database-snapshots/empty_db.db
Normal file
BIN
test-data/sqlite-database-snapshots/empty_db.db
Normal file
Binary file not shown.
BIN
test-data/sqlite-database-snapshots/populated_db.db
Normal file
BIN
test-data/sqlite-database-snapshots/populated_db.db
Normal file
Binary file not shown.
72
tests/routers/test_stats.py
Normal file
72
tests/routers/test_stats.py
Normal file
@ -0,0 +1,72 @@
|
||||
import pathlib
|
||||
import pytest
|
||||
import random
|
||||
import shutil
|
||||
import string
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.sql.models import Base
|
||||
from app.routers.stats import top_movers
|
||||
|
||||
# TODO - this is almost a copy-paste of `isolated_database` from `tests/sql/test_crud` -
|
||||
# consider unifying and extracting.
|
||||
|
||||
|
||||
@pytest.mark.parametrize("isolated_database", [["populated_db.db"]], indirect=True)
|
||||
def test_initialization(isolated_database):
|
||||
response = top_movers(db=isolated_database)
|
||||
|
||||
biggest_positive = response["positive"][0]
|
||||
assert biggest_positive["deck_id"] == 74
|
||||
assert biggest_positive["name"] == "Goose Mother"
|
||||
assert float(biggest_positive["diff"]) == 6.758
|
||||
|
||||
biggest_negative = response["negative"][0]
|
||||
assert biggest_negative["deck_id"] == 45
|
||||
assert biggest_negative["name"] == "Ashad"
|
||||
assert float(biggest_negative["diff"]) == -6.542
|
||||
|
||||
|
||||
# This fixture expects a parameter representing the filename within `test-data/sqlite-database-snapshots` that should be
|
||||
# used to initialize the database.
|
||||
# See http://stackoverflow.com/a/33879151
|
||||
@pytest.fixture(scope="function")
|
||||
def isolated_database(request, cleanups):
|
||||
database_dir = "database"
|
||||
db_dir_path = pathlib.Path(database_dir)
|
||||
if not db_dir_path.exists():
|
||||
db_dir_path.mkdir()
|
||||
db_dir_path.chmod(0o777)
|
||||
|
||||
isolated_db_name = f"isolated_database_{''.join([random.choice(string.ascii_lowercase) for _ in range(5)])}.db"
|
||||
isolated_db_path = db_dir_path.joinpath(isolated_db_name)
|
||||
|
||||
seedPath = pathlib.Path("test-data").joinpath(
|
||||
"sqlite-database-snapshots", request.param[0]
|
||||
)
|
||||
if not seedPath.exists():
|
||||
raise Exception(
|
||||
f"Cannot initialize a database from {seedPath} - does not exist"
|
||||
)
|
||||
shutil.copy(str(seedPath), isolated_db_path.absolute())
|
||||
|
||||
engine = create_engine(f"sqlite:///{isolated_db_path.absolute()}")
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
def success_cleanup():
|
||||
isolated_db_path.unlink()
|
||||
|
||||
def failure_cleanup():
|
||||
print(
|
||||
f"Isolated database {isolated_db_path.absolute()}, used in test `{request.node.name}` at path `{request.node.path.absolute()}`, has been preserved for debugging"
|
||||
)
|
||||
|
||||
cleanups.add_success(success_cleanup)
|
||||
cleanups.add_failure(failure_cleanup)
|
||||
|
||||
yield SessionLocal()
|
||||
# yield isolated_db_path
|
@ -38,6 +38,7 @@ def _test_create_and_retrieve(db, name: str):
|
||||
assert get_player.name == name
|
||||
|
||||
|
||||
# TODO - there's an almost-duplicate of this in `tests/routers/test_stats` - consider extracting.
|
||||
@pytest.fixture(scope="function")
|
||||
def isolated_database(request, cleanups):
|
||||
database_dir = "database"
|
||||
|
Loading…
x
Reference in New Issue
Block a user