Ucet bez potvrdeneho kodu (auth_token is NULL a totp_last_step == 0) uz neblokuje meno: opakovany register_account vyda novy secret (stary QR prestane platit) a login vyhodi RegistrationIncomplete, na ktoru server odpovie novym QR -- klient sa prepne na registracny tab. Admin statistiky vykazuju nedokoncene registracie osobitne, Hraci celkom pocita len potvrdene ucty. Novy event register v analytike. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
349 lines
15 KiB
Python
349 lines
15 KiB
Python
"""Self-hosted usage analytics: zapis pageview beacon + citanie agregatov pre /admin/stats.
|
|
|
|
Oddelene od api/history.py (ktory drzi zivu hru + restore-on-startup logiku),
|
|
rovnako ako je api/auth.py samostatny modul.
|
|
"""
|
|
|
|
import os
|
|
|
|
import geoip2.database
|
|
import geoip2.errors
|
|
from sqlalchemy import extract, func, select
|
|
from user_agents import parse as parse_ua
|
|
|
|
from db.db import async_session
|
|
from db.models import Game, Guess, PageView, Player
|
|
|
|
_geoip_reader: "geoip2.database.Reader | None" = None
|
|
_geoip_load_attempted = False
|
|
|
|
|
|
def _country_for_ip(ip: str) -> str:
|
|
"""Cely anglicky nazov krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo ""
|
|
ak nie je dostupny subor alebo sa IP neda rozlusit (privatna/lokalna
|
|
adresa a pod.)."""
|
|
global _geoip_reader, _geoip_load_attempted
|
|
if not ip:
|
|
return ""
|
|
if _geoip_reader is None:
|
|
if _geoip_load_attempted:
|
|
return ""
|
|
_geoip_load_attempted = True
|
|
path = os.environ.get("GEOIP_DB_PATH", "")
|
|
if not path or not os.path.exists(path):
|
|
return ""
|
|
_geoip_reader = geoip2.database.Reader(path)
|
|
try:
|
|
return _geoip_reader.country(ip).country.name or ""
|
|
except (geoip2.errors.AddressNotFoundError, ValueError):
|
|
return ""
|
|
|
|
|
|
# Cesty s dynamickym ID segmentom -- do statistik sa uklada len prefix, aby sa
|
|
# navstevy neroztriestili na /lobby/<gid>, /game/<gid>... (kazda hra inak max 4x).
|
|
_DYNAMIC_PATH_PREFIXES = ("/lobby", "/game")
|
|
|
|
# Tieto (po normalizacii) sa vobec nezaznamenavaju -- vysoka frekvencia (kazda
|
|
# akcia v hre) bez analytickej hodnoty. "/auth" je zas 1:1 duplicita eventu
|
|
# "landing": dostat sa nan da len plnym loadom stranky (interne redirecty nan
|
|
# su REPLACE a beacon ich skipuje), a kazdy plny load uz posiela "landing"
|
|
# (main.tsx). Ostava len /history a pomenovane eventy (napr. "landing",
|
|
# "rules_view", "login"), ktore sem nespadaju.
|
|
_SKIPPED_PATHS = frozenset({"/", "/auth", "/lobby", "/game"})
|
|
|
|
|
|
def _normalize_path(path: str) -> str:
|
|
for prefix in _DYNAMIC_PATH_PREFIXES:
|
|
if path == prefix or path.startswith(prefix + "/"):
|
|
return prefix
|
|
return path
|
|
|
|
|
|
async def record_pageview(
|
|
path: str, referrer: str, user_agent: str, ip: str = "", player_id: int | None = None
|
|
) -> None:
|
|
"""Zapise navstevu URL cesty ALEBO pomenovany event -- rovnaky stlpec `path`
|
|
rozlisuje oboje podla toho, ci zacina "/" (pozri PageView.path)."""
|
|
normalized = _normalize_path(path)
|
|
if normalized in _SKIPPED_PATHS:
|
|
return
|
|
ua = parse_ua(user_agent)
|
|
device_type = (
|
|
"bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc"
|
|
)
|
|
async with async_session() as session:
|
|
session.add(
|
|
PageView(
|
|
path=normalized,
|
|
player_id=player_id,
|
|
referrer=referrer,
|
|
user_agent=user_agent,
|
|
browser=ua.browser.family[:40],
|
|
os=ua.os.family[:40],
|
|
device_type=device_type,
|
|
ip=ip[:45],
|
|
country=_country_for_ip(ip),
|
|
)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
_DAYS_WINDOW = 30 # kazdy "za den" graf/rozklad zobrazuje rovnake okno
|
|
|
|
# Identita navstevnika = IP + User-Agent (rovnaky pristup ako Plausible/
|
|
# GoatCounter): odlisi dvoch ludi za jednym NAT-om s roznym prehliadacom/
|
|
# zariadenim. Dvoch s uplne identickym UA neodlisi nic bez cookies.
|
|
_visitor_id = PageView.ip + "|" + PageView.user_agent
|
|
|
|
|
|
async def _pageviews_by_day_and(session, column, logged_in_only=False) -> dict[str, dict[str, int]]:
|
|
"""Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os),
|
|
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
|
|
Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne
|
|
grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu.
|
|
|
|
Scope "logged_in": kazdy login sa pocita samostatne (rovnako ako
|
|
_login_event_counts), aby graf sedel s cislami v BreakdownTable nizsie.
|
|
Scope "all": pocita sa "navstevnicky den" (rovnako ako _daily_unique_by),
|
|
z toho isteho dovodu -- inak by graf (klikova statistika) nesedel s
|
|
cislami dole (navstevnicka statistika)."""
|
|
vday = func.date(PageView.created_at)
|
|
if logged_in_only:
|
|
recent_days_q = select(vday.label("day")).distinct().where(PageView.path == "login")
|
|
rows_q = (
|
|
select(vday.label("day"), column.label("cat"), func.count().label("n"))
|
|
.where(PageView.path == "login")
|
|
)
|
|
recent_days = recent_days_q.order_by(vday.desc()).limit(_DAYS_WINDOW).subquery()
|
|
rows = (
|
|
await session.execute(
|
|
rows_q.where(vday.in_(select(recent_days.c.day)))
|
|
.group_by(vday, column)
|
|
.order_by(vday.desc())
|
|
)
|
|
).all()
|
|
else:
|
|
inner = select(
|
|
vday.label("day"), column.label("cat"), _visitor_id.label("visitor")
|
|
).distinct().subquery()
|
|
recent_days = (
|
|
select(inner.c.day).distinct().order_by(inner.c.day.desc()).limit(_DAYS_WINDOW)
|
|
).subquery()
|
|
rows = (
|
|
await session.execute(
|
|
select(inner.c.day, inner.c.cat, func.count().label("n"))
|
|
.where(inner.c.day.in_(select(recent_days.c.day)))
|
|
.group_by(inner.c.day, inner.c.cat)
|
|
.order_by(inner.c.day.desc())
|
|
)
|
|
).all()
|
|
nested: dict[str, dict[str, int]] = {}
|
|
for r in rows:
|
|
nested.setdefault(str(r.day), {})[r.cat] = r.n
|
|
return nested
|
|
|
|
|
|
async def _daily_unique_by(session, column, exclude_empty=False) -> list:
|
|
"""Rozklad podla dimenzie (browser/os/...) pre anonymnu navstevnost, kde
|
|
jednotka nie je klik ale "navstevnicky den": ten isty navstevnik (IP+UA)
|
|
sa v ramci jedneho dna pocita raz, na dalsi den znova. Sedi tak so suctom
|
|
grafu visitors_per_day. Pre scope "logged_in" sa nepouziva -- tam ma kazde
|
|
prihlasenie vahu 1x (viz _login_event_counts), aby to sedelo s
|
|
pageviews_per_day ("Ked sa 2x prihlasi ten isty user, chcem to mat ako 2x").
|
|
|
|
Portable cez SQLite aj Postgres: najprv DISTINCT (den, kategoria, navstevnik)
|
|
v subquery, potom GROUP BY kategoria."""
|
|
vday = func.date(PageView.created_at)
|
|
inner = select(
|
|
vday.label("day"), column.label("cat"), _visitor_id.label("visitor")
|
|
).distinct()
|
|
if exclude_empty:
|
|
inner = inner.where(column != "")
|
|
sub = inner.subquery()
|
|
return (
|
|
await session.execute(
|
|
select(sub.c.cat, func.count().label("n"))
|
|
.group_by(sub.c.cat)
|
|
.order_by(func.count().desc())
|
|
)
|
|
).all()
|
|
|
|
|
|
async def _login_event_counts(session, column, exclude_empty=False) -> list:
|
|
"""Rozklad podla dimenzie pocitany priamo z poctu login-eventov (kazdy
|
|
riadok PageView s path == "login" sa pocita samostatne) -- na rozdiel od
|
|
_daily_unique_by nededuplikuje podla navstevnika/dna, takze opakovane
|
|
prihlasenie toho isteho hraca v ten isty den sa prejavi ako 2, presne
|
|
ako v pageviews_per_day."""
|
|
q = select(column.label("cat"), func.count().label("n")).where(PageView.path == "login")
|
|
if exclude_empty:
|
|
q = q.where(column != "")
|
|
return (
|
|
await session.execute(q.group_by(column).order_by(func.count().desc()))
|
|
).all()
|
|
|
|
|
|
async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
|
"""logged_in_only obmedzuje traffic-analyticke widgety (PageView) na
|
|
zaznamy z eventu "login" (jediny event, ktory nesie player_id -- bezne
|
|
beacony ho neposielaju vobec). Herne metriky (games/players/rounds) su
|
|
uz zo svojej podstaty vzdy o prihlasenych uctoch, prepinac sa ich netyka.
|
|
top_paths ostava vzdy pocitane zo vsetkych navstev bez ohladu na scope --
|
|
najnavstevovanejsie stranky maju zmysel len ako celok."""
|
|
async with async_session() as session:
|
|
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
|
|
# across SQLite and Postgres and returns a plain string/date value without
|
|
# the double-conversion issue cast(..., Date) triggers on SQLite (aiosqlite
|
|
# already coerces TIMESTAMP columns to datetime before the Date result
|
|
# processor tries to re-parse them as an ISO string).
|
|
day = func.date(Game.created_at)
|
|
game_rows = (
|
|
await session.execute(
|
|
select(day.label("day"), func.count().label("n"))
|
|
.group_by(day)
|
|
.order_by(day.desc())
|
|
.limit(_DAYS_WINDOW)
|
|
)
|
|
).all()
|
|
|
|
pday = func.date(Player.created_at)
|
|
player_rows = (
|
|
await session.execute(
|
|
select(pday.label("day"), func.count().label("n"))
|
|
.group_by(pday)
|
|
.order_by(pday.desc())
|
|
.limit(_DAYS_WINDOW)
|
|
)
|
|
).all()
|
|
|
|
total, finished = (
|
|
await session.execute(select(func.count(), func.count(Game.ended_at)))
|
|
).one()
|
|
|
|
avg_duration = (
|
|
await session.execute(
|
|
select(func.avg(func.extract("epoch", Game.ended_at - Game.created_at))).where(
|
|
Game.ended_at.is_not(None)
|
|
)
|
|
)
|
|
).scalar()
|
|
|
|
# Nedokoncena registracia = kod nebol nikdy potvrdeny (SQL obdoba
|
|
# api/auth._is_unconfirmed). "Hraci celkom" pocita len potvrdene ucty,
|
|
# nedokoncene sa vykazuju osobitne.
|
|
unconfirmed = Player.auth_token.is_(None) & (Player.totp_last_step == 0)
|
|
total_players, unconfirmed_players = (
|
|
await session.execute(
|
|
select(
|
|
func.count(Player.id).filter(~unconfirmed),
|
|
func.count(Player.id).filter(unconfirmed),
|
|
)
|
|
)
|
|
).one()
|
|
|
|
peak_hours = (
|
|
await session.execute(
|
|
select(extract("hour", Game.created_at).label("h"), func.count().label("n"))
|
|
.group_by("h")
|
|
.order_by("h")
|
|
)
|
|
).all()
|
|
|
|
rday = func.date(Game.created_at)
|
|
rounds_rows = (
|
|
await session.execute(
|
|
select(rday.label("day"), func.count().label("n"))
|
|
.select_from(Guess)
|
|
.join(Game, Guess.game_id == Game.id)
|
|
.group_by(rday)
|
|
.order_by(rday.desc())
|
|
.limit(_DAYS_WINDOW)
|
|
)
|
|
).all()
|
|
|
|
vday = func.date(PageView.created_at)
|
|
pageview_q = select(vday.label("day"), func.count().label("n"))
|
|
if logged_in_only:
|
|
# "Navstevnici" v scope Prihlaseni = unikatni HRACI (player_id) za
|
|
# den, nie unikatne IP+UA -- to je presny pocet skutocnych uctov.
|
|
pageview_q = pageview_q.where(PageView.path == "login")
|
|
visitor_q = (
|
|
select(vday.label("day"), func.count(func.distinct(PageView.player_id)).label("n"))
|
|
.where(PageView.path == "login")
|
|
)
|
|
else:
|
|
visitor_q = select(vday.label("day"), func.count(func.distinct(_visitor_id)).label("n"))
|
|
|
|
pageview_rows = (
|
|
await session.execute(
|
|
pageview_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
|
)
|
|
).all()
|
|
# Unikatni navstevnici za den (distinct IP+UA v ramci dna; ten isty
|
|
# navstevnik sa na dalsi den pocita znova).
|
|
visitor_rows = (
|
|
await session.execute(
|
|
visitor_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
|
)
|
|
).all()
|
|
# Top stranky vzdy zo vsetkych navstev -- scope prepinac sa ich netyka.
|
|
top_paths = (
|
|
await session.execute(
|
|
select(PageView.path, func.count().label("n"))
|
|
.group_by(PageView.path)
|
|
.order_by(func.count().desc())
|
|
.limit(20)
|
|
)
|
|
).all()
|
|
|
|
if logged_in_only:
|
|
# Kazdy login sa pocita samostatne (nededuplikovane) -- sedi to s
|
|
# pageviews_per_day, kde opakovane prihlasenie toho isteho hraca
|
|
# v ten isty den ma tiez pridat 2, nie 1.
|
|
top_referrers = (await _login_event_counts(session, PageView.referrer, exclude_empty=True))[:20]
|
|
browsers = await _login_event_counts(session, PageView.browser)
|
|
os_rows = await _login_event_counts(session, PageView.os)
|
|
device_rows = await _login_event_counts(session, PageView.device_type)
|
|
country_rows = await _login_event_counts(session, PageView.country, exclude_empty=True)
|
|
else:
|
|
# Zlozenie anonymnej navstevnosti sa pocita v "navstevnickych
|
|
# dnoch" (den+IP raz), nie v klikoch -- jeden aktivny hrac tak
|
|
# neprevazi tabulky.
|
|
top_referrers = (await _daily_unique_by(session, PageView.referrer, exclude_empty=True))[:20]
|
|
browsers = await _daily_unique_by(session, PageView.browser)
|
|
os_rows = await _daily_unique_by(session, PageView.os)
|
|
device_rows = await _daily_unique_by(session, PageView.device_type)
|
|
country_rows = await _daily_unique_by(session, PageView.country, exclude_empty=True)
|
|
|
|
pageviews_per_day_by_device = await _pageviews_by_day_and(
|
|
session, PageView.device_type, logged_in_only=logged_in_only
|
|
)
|
|
pageviews_per_day_by_browser = await _pageviews_by_day_and(
|
|
session, PageView.browser, logged_in_only=logged_in_only
|
|
)
|
|
pageviews_per_day_by_os = await _pageviews_by_day_and(
|
|
session, PageView.os, logged_in_only=logged_in_only
|
|
)
|
|
|
|
return {
|
|
"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},
|
|
"completion_rate": finished / total if total else None,
|
|
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
|
|
"total_players": total_players,
|
|
"unconfirmed_players": unconfirmed_players,
|
|
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
|
"rounds_per_day": {str(r.day): r.n for r in rounds_rows},
|
|
"pageviews_per_day": {str(r.day): r.n for r in pageview_rows},
|
|
"visitors_per_day": {str(r.day): r.n for r in visitor_rows},
|
|
"pageviews_per_day_by_device": pageviews_per_day_by_device,
|
|
"pageviews_per_day_by_browser": pageviews_per_day_by_browser,
|
|
"pageviews_per_day_by_os": pageviews_per_day_by_os,
|
|
"top_referrers": {r.cat: r.n for r in top_referrers},
|
|
"top_paths": {r.path: r.n for r in top_paths},
|
|
"browsers": {r.cat: r.n for r in browsers},
|
|
"operating_systems": {r.cat: r.n for r in os_rows},
|
|
"device_types": {r.cat: r.n for r in device_rows},
|
|
"countries": {r.cat: r.n for r in country_rows},
|
|
}
|