Statistiky: dokoncene hry rozdelene na s ludmi / s botmi
Karta "Dokoncene hry" (%) nahradena dvomi poctami dokoncenych hier podla toho, ci na niektorom sedadle sedel bot (username "bot:..."). Zaroven oprava serializacie avg_game_duration_minutes -- Postgres vracia z func.avg Decimal, ktory json.dumps nevie serializovat (500 na /api/admin/stats). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+26
-5
@@ -8,12 +8,16 @@ import os
|
|||||||
|
|
||||||
import geoip2.database
|
import geoip2.database
|
||||||
import geoip2.errors
|
import geoip2.errors
|
||||||
from sqlalchemy import extract, func, select
|
from sqlalchemy import extract, func, or_, select
|
||||||
from user_agents import parse as parse_ua
|
from user_agents import parse as parse_ua
|
||||||
|
|
||||||
from db.db import async_session
|
from db.db import async_session
|
||||||
from db.models import Game, Guess, PageView, Player
|
from db.models import Game, Guess, PageView, Player
|
||||||
|
|
||||||
|
# Konvencia na rozpoznanie botieho uctu (viz api.bots.BOT_PREFIX) -- drzana tu
|
||||||
|
# lokalne, aby sa do statistickej cesty netahala rl vrstva (siet/numpy).
|
||||||
|
_BOT_PREFIX = "bot:"
|
||||||
|
|
||||||
_geoip_reader: "geoip2.database.Reader | None" = None
|
_geoip_reader: "geoip2.database.Reader | None" = None
|
||||||
_geoip_load_attempted = False
|
_geoip_load_attempted = False
|
||||||
|
|
||||||
@@ -216,8 +220,24 @@ async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
total, finished = (
|
# Dokoncene hry rozdelene podla toho, ci na niektorom zo 4 sedadiel sedel
|
||||||
await session.execute(select(func.count(), func.count(Game.ended_at)))
|
# bot (ucet s username "bot:..."). has_bot je pravdive, ak aspon jedno
|
||||||
|
# sedadlo patri botiemu uctu.
|
||||||
|
bot_ids = select(Player.id).where(Player.username.like(f"{_BOT_PREFIX}%"))
|
||||||
|
has_bot = or_(
|
||||||
|
Game.player0_id.in_(bot_ids),
|
||||||
|
Game.player1_id.in_(bot_ids),
|
||||||
|
Game.player2_id.in_(bot_ids),
|
||||||
|
Game.player3_id.in_(bot_ids),
|
||||||
|
)
|
||||||
|
finished = Game.ended_at.is_not(None)
|
||||||
|
finished_bot_games, finished_human_games = (
|
||||||
|
await session.execute(
|
||||||
|
select(
|
||||||
|
func.count().filter(finished & has_bot),
|
||||||
|
func.count().filter(finished & ~has_bot),
|
||||||
|
)
|
||||||
|
)
|
||||||
).one()
|
).one()
|
||||||
|
|
||||||
avg_duration = (
|
avg_duration = (
|
||||||
@@ -328,8 +348,9 @@ async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
|||||||
return {
|
return {
|
||||||
"games_per_day": {str(r.day): r.n for r in game_rows},
|
"games_per_day": {str(r.day): r.n for r in game_rows},
|
||||||
"players_per_day": {str(r.day): r.n for r in player_rows},
|
"players_per_day": {str(r.day): r.n for r in player_rows},
|
||||||
"completion_rate": finished / total if total else None,
|
"finished_bot_games": finished_bot_games,
|
||||||
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
|
"finished_human_games": finished_human_games,
|
||||||
|
"avg_game_duration_minutes": (float(avg_duration) / 60) if avg_duration else None,
|
||||||
"total_players": total_players,
|
"total_players": total_players,
|
||||||
"unconfirmed_players": unconfirmed_players,
|
"unconfirmed_players": unconfirmed_players,
|
||||||
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import PageviewsChart from './PageviewsChart';
|
|||||||
interface DailyStats {
|
interface DailyStats {
|
||||||
games_per_day: Record<string, number>;
|
games_per_day: Record<string, number>;
|
||||||
players_per_day: Record<string, number>;
|
players_per_day: Record<string, number>;
|
||||||
completion_rate: number | null;
|
finished_bot_games: number;
|
||||||
|
finished_human_games: number;
|
||||||
avg_game_duration_minutes: number | null;
|
avg_game_duration_minutes: number | null;
|
||||||
total_players: number;
|
total_players: number;
|
||||||
unconfirmed_players: number;
|
unconfirmed_players: number;
|
||||||
@@ -157,10 +158,8 @@ export default function AdminStats() {
|
|||||||
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
|
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
|
||||||
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
|
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
|
||||||
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
|
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
|
||||||
<SummaryCard
|
<SummaryCard label="Dokončené hry s ľuďmi" value={String(data.finished_human_games)} />
|
||||||
label="Dokončené hry"
|
<SummaryCard label="Dokončené hry s botmi" value={String(data.finished_bot_games)} />
|
||||||
value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'}
|
|
||||||
/>
|
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="Priem. dĺžka hry"
|
label="Priem. dĺžka hry"
|
||||||
value={data.avg_game_duration_minutes != null ? `${Math.round(data.avg_game_duration_minutes)} min` : '—'}
|
value={data.avg_game_duration_minutes != null ? `${Math.round(data.avg_game_duration_minutes)} min` : '—'}
|
||||||
|
|||||||
+36
-2
@@ -22,7 +22,8 @@ import pyotp # noqa: E402
|
|||||||
|
|
||||||
import api as api_module # noqa: E402
|
import api as api_module # noqa: E402
|
||||||
from api import auth, history, stats # noqa: E402
|
from api import auth, history, stats # noqa: E402
|
||||||
from db.db import init_db # noqa: E402
|
from db.db import async_session, init_db # noqa: E402
|
||||||
|
from db.models import Player # noqa: E402
|
||||||
|
|
||||||
CHROME_UA = (
|
CHROME_UA = (
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
@@ -62,6 +63,20 @@ class StatsCase(unittest.TestCase):
|
|||||||
ids.append(ident["player_id"])
|
ids.append(ident["player_id"])
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
def _make_bot_player(self):
|
||||||
|
"""Vlozi boti ucet (username "bot:...") priamo do DB a vrati jeho id."""
|
||||||
|
async def _insert():
|
||||||
|
async with async_session() as session:
|
||||||
|
bot = Player(
|
||||||
|
username="bot:heuristic-" + uuid.uuid4().hex[:8],
|
||||||
|
totp_secret="x",
|
||||||
|
totp_last_step=1,
|
||||||
|
)
|
||||||
|
session.add(bot)
|
||||||
|
await session.commit()
|
||||||
|
return bot.id
|
||||||
|
return run(_insert())
|
||||||
|
|
||||||
def test_record_pageview_parses_user_agent(self):
|
def test_record_pageview_parses_user_agent(self):
|
||||||
run(stats.record_pageview(
|
run(stats.record_pageview(
|
||||||
path="/history", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5",
|
path="/history", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5",
|
||||||
@@ -307,6 +322,8 @@ class StatsCase(unittest.TestCase):
|
|||||||
def test_daily_stats_reflect_games_and_players(self):
|
def test_daily_stats_reflect_games_and_players(self):
|
||||||
before = run(stats.get_daily_stats())
|
before = run(stats.get_daily_stats())
|
||||||
base_total_players = before["total_players"]
|
base_total_players = before["total_players"]
|
||||||
|
base_human_games = before["finished_human_games"]
|
||||||
|
base_bot_games = before["finished_bot_games"]
|
||||||
|
|
||||||
ids = self._make_players()
|
ids = self._make_players()
|
||||||
gid = str(uuid.uuid4())
|
gid = str(uuid.uuid4())
|
||||||
@@ -315,7 +332,24 @@ class StatsCase(unittest.TestCase):
|
|||||||
|
|
||||||
data = run(stats.get_daily_stats())
|
data = run(stats.get_daily_stats())
|
||||||
self.assertEqual(data["total_players"], base_total_players + 4)
|
self.assertEqual(data["total_players"], base_total_players + 4)
|
||||||
self.assertEqual(data["completion_rate"], 1.0)
|
# Dokoncena hra so 4 ludskymi hracmi -> pripocita sa k "s ludmi", nie "s botmi"
|
||||||
|
self.assertEqual(data["finished_human_games"], base_human_games + 1)
|
||||||
|
self.assertEqual(data["finished_bot_games"], base_bot_games)
|
||||||
|
|
||||||
|
def test_game_with_a_bot_seat_counts_as_bot_game(self):
|
||||||
|
before = run(stats.get_daily_stats())
|
||||||
|
base_human_games = before["finished_human_games"]
|
||||||
|
base_bot_games = before["finished_bot_games"]
|
||||||
|
|
||||||
|
# 3 ludia + 1 bot na poslednom sedadle
|
||||||
|
ids = self._make_players(n=3) + [self._make_bot_player()]
|
||||||
|
gid = str(uuid.uuid4())
|
||||||
|
run(history.record_game_started(gid, "SBotom", ids))
|
||||||
|
run(history.record_completed_rounds(gid, make_core()))
|
||||||
|
|
||||||
|
data = run(stats.get_daily_stats())
|
||||||
|
self.assertEqual(data["finished_bot_games"], base_bot_games + 1)
|
||||||
|
self.assertEqual(data["finished_human_games"], base_human_games)
|
||||||
self.assertGreaterEqual(sum(data["games_per_day"].values()), 1)
|
self.assertGreaterEqual(sum(data["games_per_day"].values()), 1)
|
||||||
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
|
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user