"""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/, /game/... (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. Ostava len /auth, /history a pomenovane # eventy (napr. "rules_view", "login"), ktore sem nespadaju. _SKIPPED_PATHS = frozenset({"/", "/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() total_players = (await session.execute(select(func.count(Player.id)))).scalar() 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, "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}, }