diff --git a/api/__init__.py b/api/__init__.py index 2600388..a6e2662 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -6,6 +6,7 @@ import time import uuid from collections import defaultdict from json import JSONEncoder +from urllib.parse import parse_qs import socketio @@ -103,6 +104,9 @@ async def _handle_track(scope, receive, send): data = {} headers = dict(scope.get("headers") or []) user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300] + # player_id sa sem nikdy nedava z klienta (nedovereny/spoofovatelny vstup na + # neautentifikovanom endpointe) -- pripaja sa len server-side na evente + # "login" (pozri handler @sio.on("login")). await stats_module.record_pageview( path=str(data.get("path", ""))[:200], referrer=str(data.get("referrer", ""))[:300], @@ -120,7 +124,9 @@ async def _handle_admin_stats(scope, send): if not _admin_authorized(scope): _register_admin_failure(ip) return await _send_text(send, 403, b"forbidden") - data = await stats_module.get_daily_stats() + query = parse_qs((scope.get("query_string") or b"").decode("utf-8", "ignore")) + logged_in_only = query.get("logged_in", ["0"])[0] == "1" + data = await stats_module.get_daily_stats(logged_in_only=logged_in_only) await _send_text(send, 200, json.dumps(data).encode(), b"application/json") @@ -382,6 +388,18 @@ async def register_account(sid, username): await sio.emit("register_account", data, to=sid) +async def _record_login_event(sid: str, player_id: int) -> None: + scope = (sio.get_environ(sid) or {}).get("asgi.scope", {}) + headers = dict(scope.get("headers") or []) + await stats_module.record_pageview( + path="login", + referrer="", + user_agent=headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300], + ip=_client_ip(scope), + player_id=player_id, + ) + + @sio.on("confirm_account") async def confirm_account(sid, username, code): try: @@ -389,6 +407,7 @@ async def confirm_account(sid, username, code): except AuthError as exc: return await send_error(sid, str(exc)) accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} + await _record_login_event(sid, identity["player_id"]) await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid) @@ -399,6 +418,7 @@ async def login(sid, username, code): except AuthError as exc: return await send_error(sid, str(exc)) accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} + await _record_login_event(sid, identity["player_id"]) await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid) diff --git a/api/stats.py b/api/stats.py index eae2a07..e3ff3c9 100644 --- a/api/stats.py +++ b/api/stats.py @@ -19,8 +19,9 @@ _geoip_load_attempted = False def _country_for_ip(ip: str) -> str: - """ISO kod krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo "" ak nie je - dostupny subor alebo sa IP neda rozlusit (privatna/lokalna adresa a pod.).""" + """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 "" @@ -33,12 +34,36 @@ def _country_for_ip(ip: str) -> str: return "" _geoip_reader = geoip2.database.Reader(path) try: - return _geoip_reader.country(ip).country.iso_code or "" + return _geoip_reader.country(ip).country.name or "" except (geoip2.errors.AddressNotFoundError, ValueError): return "" -async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "") -> None: +# 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" @@ -46,7 +71,8 @@ async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = " async with async_session() as session: session.add( PageView( - path=path, + path=normalized, + player_id=player_id, referrer=referrer, user_agent=user_agent, browser=ua.browser.family[:40], @@ -61,35 +87,106 @@ async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = " _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) -> dict[str, dict[str, int]]: + +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.""" + 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) - recent_days = ( - select(vday.label("day")) - .distinct() - .order_by(vday.desc()) - .limit(_DAYS_WINDOW) - .subquery() - ) - rows = ( - await session.execute( + 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(vday.in_(select(recent_days.c.day))) - .group_by(vday, column) - .order_by(vday.desc()) + .where(PageView.path == "login") ) - ).all() + 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 get_daily_stats() -> dict: +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 @@ -151,23 +248,31 @@ async def get_daily_stats() -> dict: ).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( - select(vday.label("day"), func.count().label("n")) - .group_by(vday) - .order_by(vday.desc()) - .limit(_DAYS_WINDOW) + pageview_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW) ) ).all() - top_referrers = ( + # Unikatni navstevnici za den (distinct IP+UA v ramci dna; ten isty + # navstevnik sa na dalsi den pocita znova). + visitor_rows = ( await session.execute( - select(PageView.referrer, func.count().label("n")) - .where(PageView.referrer != "") - .group_by(PageView.referrer) - .order_by(func.count().desc()) - .limit(20) + 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")) @@ -177,39 +282,34 @@ async def get_daily_stats() -> dict: ) ).all() - browsers = ( - await session.execute( - select(PageView.browser, func.count().label("n")) - .group_by(PageView.browser) - .order_by(func.count().desc()) - ) - ).all() - os_rows = ( - await session.execute( - select(PageView.os, func.count().label("n")) - .group_by(PageView.os) - .order_by(func.count().desc()) - ) - ).all() - device_rows = ( - await session.execute( - select(PageView.device_type, func.count().label("n")) - .group_by(PageView.device_type) - .order_by(func.count().desc()) - ) - ).all() - country_rows = ( - await session.execute( - select(PageView.country, func.count().label("n")) - .where(PageView.country != "") - .group_by(PageView.country) - .order_by(func.count().desc()) - ) - ).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) - pageviews_per_day_by_browser = await _pageviews_by_day_and(session, PageView.browser) - pageviews_per_day_by_os = await _pageviews_by_day_and(session, PageView.os) + 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}, @@ -220,13 +320,14 @@ async def get_daily_stats() -> dict: "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.referrer: r.n for r in top_referrers}, + "top_referrers": {r.cat: r.n for r in top_referrers}, "top_paths": {r.path: r.n for r in top_paths}, - "browsers": {r.browser: r.n for r in browsers}, - "operating_systems": {r.os: r.n for r in os_rows}, - "device_types": {r.device_type: r.n for r in device_rows}, - "countries": {r.country: r.n for r in country_rows}, + "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}, } diff --git a/db/models.py b/db/models.py index 47370fd..3867512 100644 --- a/db/models.py +++ b/db/models.py @@ -93,14 +93,20 @@ class PageView(Base): __tablename__ = "page_views" id: Mapped[int] = mapped_column(primary_key=True) + # Konvencia: zacina "/" -> skutocna URL cesta; inak nazov eventu (napr. + # "rules_view", "login") -- ziadny osobitny stlpec, rovnaky stlpec pre oboje. path: Mapped[str] = mapped_column(String(200)) + # Vyplnene len ak je znamy prihlaseny hrac v momente zapisu (napr. event "login"). + player_id: Mapped[int | None] = mapped_column( + ForeignKey("players.id"), nullable=True, index=True + ) referrer: Mapped[str] = mapped_column(String(300), default="") user_agent: Mapped[str] = mapped_column(String(300), default="") browser: Mapped[str] = mapped_column(String(40), default="") os: Mapped[str] = mapped_column(String(40), default="") device_type: Mapped[str] = mapped_column(String(20), default="") # mobile/tablet/pc/bot ip: Mapped[str] = mapped_column(String(45), default="") # surova IP (IPv4/IPv6), "" ak nezname - country: Mapped[str] = mapped_column(String(2), default="") # ISO kod z GeoIP, "" ak nerozlusene + country: Mapped[str] = mapped_column(String(60), default="") # anglicky nazov z GeoIP, "" ak nerozlusene created_at: Mapped[datetime] = mapped_column( DateTime, server_default=func.now() ) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ade9735..5b3faaf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom'; import { useGameStore } from './store/gameStore'; import { socket, emit } from './lib/socket'; +import { trackEvent } from './lib/track'; import type { MyPlayer } from './types'; import GameList from './pages/GameList'; import Lobby from './pages/Lobby'; @@ -73,15 +74,19 @@ function AppInner() { // stats), not a page the user actually navigated to, so they'd otherwise // inflate the count with one extra row per redirect hop. Skip /admin itself // too -- that's the dashboard viewing its own traffic, not player usage. + // Skip /, /lobby, /game -- high-frequency in-game navigation with no + // analytical value; the backend drops these anyway (api/stats.py _SKIPPED_PATHS). useEffect(() => { if (navigationType === 'REPLACE') return; if (location.pathname.startsWith('/admin')) return; - fetch('/api/track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: location.pathname, referrer: document.referrer }), - keepalive: true, - }).catch(() => {}); + if ( + location.pathname === '/' || + location.pathname.startsWith('/lobby') || + location.pathname.startsWith('/game') + ) { + return; + } + trackEvent(location.pathname, document.referrer); }, [location.pathname, navigationType]); return ( diff --git a/frontend/src/lib/track.ts b/frontend/src/lib/track.ts new file mode 100644 index 0000000..469a643 --- /dev/null +++ b/frontend/src/lib/track.ts @@ -0,0 +1,12 @@ +/** Fire-and-forget event/pageview beacon for self-hosted analytics; must + * never affect the app (network errors are swallowed). player_id is only + * ever attached server-side to the "login" event -- ordinary beacons don't + * carry it. */ +export function trackEvent(path: string, referrer = '') { + fetch('/api/track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, referrer }), + keepalive: true, + }).catch(() => {}); +} diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx index 2763b8f..e830ba8 100644 --- a/frontend/src/pages/Auth.tsx +++ b/frontend/src/pages/Auth.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { useGameStore } from '../store/gameStore'; import { emit } from '../lib/socket'; +import { trackEvent } from '../lib/track'; import RulesModal from '../components/RulesModal'; type Mode = 'login' | 'register'; @@ -45,6 +46,11 @@ export default function Auth() { setRegistration(null); }; + const handleShowRules = () => { + setShowRules(true); + trackEvent('rules_view'); + }; + return (

Bridžik

@@ -162,7 +168,7 @@ export default function Auth() {
+ ))} +
+ ); +} + export default function AdminStats() { const token = useAdminToken(); const desktop = useIsDesktop(); const [data, setData] = useState(null); const [error, setError] = useState(null); + const [scope, setScope] = useState('all'); useEffect(() => { let cancelled = false; - fetch('/api/admin/stats', { headers: { Authorization: `Bearer ${token}` } }) + const qs = scope === 'logged_in' ? '?logged_in=1' : ''; + fetch(`/api/admin/stats${qs}`, { headers: { Authorization: `Bearer ${token}` } }) .then((res) => { if (res.status === 403) throw new Error('Neplatný token.'); if (!res.ok) throw new Error('Chyba pri načítaní štatistík.'); @@ -123,7 +146,7 @@ export default function AdminStats() { return () => { cancelled = true; }; - }, [token]); + }, [token, scope]); if (error) return

{error}

; if (!data) return

Načítavam...

; @@ -149,8 +172,15 @@ export default function AdminStats() {
+ {/* Prepinac sa tyka len traffic-analytickych widgetov nizsie (PageView) -- + herne metriky vyssie su uz vzdy o prihlasenych uctoch. */} + + ; + totalLabel?: string; + visitors: Record; + visitorsLabel?: string; byDevice: Record>; byBrowser: Record>; byOs: Record>; } -export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Props) { +export default function PageviewsChart({ + total, + totalLabel = 'Zobrazenia', + visitors, + visitorsLabel = 'Návštevníci', + byDevice, + byBrowser, + byOs, +}: Props) { const [dimension, setDimension] = useState('total'); const { rows, categories } = useMemo(() => { if (dimension === 'total') { - const days = Object.keys(total).sort(); - return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] }; + const days = [...new Set([...Object.keys(total), ...Object.keys(visitors)])].sort(); + return { + rows: days.map((day) => ({ day, n: total[day] ?? 0, visitors: visitors[day] ?? 0 })), + categories: ['n', 'visitors'], + }; } const byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs; const days = Object.keys(byDay).sort(); @@ -47,7 +61,9 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro return row; }); return { rows, categories }; - }, [dimension, total, byDevice, byBrowser, byOs]); + }, [dimension, total, visitors, byDevice, byBrowser, byOs]); + + const lineName = (c: string) => (c === 'n' ? totalLabel : c === 'visitors' ? visitorsLabel : c); return (
@@ -73,13 +89,13 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro - {dimension !== 'total' && } + {categories.map((c, i) => ( -- nesmie sa orezat + self.assertEqual(stats._normalize_path("/gamex"), "/gamex") + + def test_skipped_paths_not_recorded(self): + # "/", "/lobby" a "/game" (aj s dynamickym ID) sa vobec nezapisuju -- + # vysoka frekvencia bez analytickej hodnoty (api/stats.py _SKIPPED_PATHS). + async def _count(): + from sqlalchemy import func, select as sa_select + + from db.db import async_session + from db.models import PageView + + async with async_session() as session: + return ( + await session.execute(sa_select(func.count()).select_from(PageView)) + ).scalar() + + before = run(_count()) + for path in ("/", "/lobby", "/lobby/abc-123", "/game", "/game/xyz-789"): + run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA)) + after = run(_count()) + self.assertEqual(after, before) + + def test_event_path_stores_player_id(self): + # Konvencia: nazov bez "/" na zaciatku = pomenovany event (napr. "login" + # zapisovany zo socket handlera po uspesnom prihlaseni), nie URL cesta. + ids = self._make_players(n=1) + run(stats.record_pageview( + path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0], + )) + + async def _last(): + from sqlalchemy import select as sa_select + + from db.db import async_session + from db.models import PageView + + async with async_session() as session: + row = ( + await session.execute( + sa_select(PageView).order_by(PageView.id.desc()).limit(1) + ) + ).scalar_one() + return row.path, row.player_id + + path, player_id = run(_last()) + self.assertEqual(path, "login") + self.assertEqual(player_id, ids[0]) + + data = run(stats.get_daily_stats()) + self.assertGreaterEqual(data["top_paths"].get("login", 0), 1) + + def test_rules_view_event_recorded_without_player_id(self): + run(stats.record_pageview(path="rules_view", referrer="", user_agent=CHROME_UA)) + data = run(stats.get_daily_stats()) + self.assertGreaterEqual(data["top_paths"].get("rules_view", 0), 1) + + def test_logged_in_only_scope_counts_login_events_only(self): + # Bezne beacony uz neposielaju player_id vobec -- scope "logged_in" + # preto filtruje priamo podla path == "login", nie podla pritomnosti + # player_id. Obycajna navsteva (aj s player_id) sa do neho nepocita. + ids = self._make_players(n=1) + ip = "203.0.113.99" + before_all = run(stats.get_daily_stats(logged_in_only=False)) + before_logged_in = run(stats.get_daily_stats(logged_in_only=True)) + all_before = sum(before_all["pageviews_per_day"].values()) + logged_in_before = sum(before_logged_in["pageviews_per_day"].values()) + + # Bezna navsteva -- ma pribudnut len vo "vsetci", aj keby mala player_id. + run(stats.record_pageview( + path="/history", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0], + )) + # Login event -- ma pribudnut v oboch. + run(stats.record_pageview( + path="login", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0], + )) + + after_all = run(stats.get_daily_stats(logged_in_only=False)) + after_logged_in = run(stats.get_daily_stats(logged_in_only=True)) + + self.assertEqual(sum(after_all["pageviews_per_day"].values()), all_before + 2) + self.assertEqual(sum(after_logged_in["pageviews_per_day"].values()), logged_in_before + 1) + + def test_logged_in_only_breakdown_tables_count_each_login_not_deduped(self): + # browsers/os/device_types/top_referrers/countries pouzivaju v scope + # "logged_in" priamy pocet login-eventov, nie unikatny navstevnicky + # den -- 2x prihlasenie tym istym prehliadacom/IP v ten isty den sa + # ma prejavit ako 2, rovnako ako v grafe pageviews_per_day. + ids = self._make_players(n=1) + before = run(stats.get_daily_stats(logged_in_only=True)) + before_browsers = sum(before["browsers"].values()) + + run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0])) + run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0])) + + data = run(stats.get_daily_stats(logged_in_only=True)) + self.assertEqual(sum(data["browsers"].values()), before_browsers + 2) + + def test_logged_in_visitors_per_day_counts_distinct_players_not_ip(self): + # V scope "logged_in" ma visitors_per_day znamenat unikatnych HRACOV + # (player_id) za den, nie unikatne IP+UA -- 2x prihlasenie toho + # isteho hraca (aj z inej IP/prehliadaca) sa ma pocitat len raz, na + # rozdiel od pageviews_per_day, kde sa kazdy login pocita zvlast. + ids = self._make_players(n=2) + before = run(stats.get_daily_stats(logged_in_only=True)) + before_visitors = sum(before["visitors_per_day"].values()) + before_pageviews = sum(before["pageviews_per_day"].values()) + + # Ten isty hrac, 2x prihlasenie z roznych "zariadeni" (rozne IP/UA). + run(stats.record_pageview( + path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.10", player_id=ids[0], + )) + run(stats.record_pageview( + path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.11", player_id=ids[0], + )) + # Iny hrac, 1x prihlasenie. + run(stats.record_pageview( + path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.12", player_id=ids[1], + )) + + data = run(stats.get_daily_stats(logged_in_only=True)) + self.assertEqual(sum(data["pageviews_per_day"].values()), before_pageviews + 3) + self.assertEqual(sum(data["visitors_per_day"].values()), before_visitors + 2) + + def test_visitors_counted_once_per_day_per_ip(self): + # 3 kliky z tej istej IP v ten isty den -> pageviews +3, visitors iba 1. + ip = "198.51.100.77" + for path in ("/history", "/history", "/history"): + run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA, ip=ip)) + + data = run(stats.get_daily_stats()) + today = next(iter(data["visitors_per_day"])) + # V testovej DB su vsetky zaznamy z dneska; unikatnych IP je menej nez klikov. + self.assertLess(data["visitors_per_day"][today], data["pageviews_per_day"][today]) + + # Tabulky zlozenia publika pocitaju den+IP raz -- 3 kliky tej istej IP + # nesmu zdvihnut "Chrome" o 3. Overime, ze pocet je mensi nez pocet klikov. + self.assertLess(data["browsers"].get("Chrome", 0), sum(data["pageviews_per_day"].values())) + + def test_same_ip_different_user_agent_counts_as_two_visitors(self): + ip = "192.0.2.44" + firefox_ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) " + "Gecko/20100101 Firefox/126.0" + ) + before = run(stats.get_daily_stats()) + today_before = sum(before["visitors_per_day"].values()) + + # Ta ista IP, dva rozne prehliadace -> 2 navstevnici (kazdy klikne 2x). + for _ in range(2): + run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip)) + run(stats.record_pageview(path="/history", referrer="", user_agent=firefox_ua, ip=ip)) + + after = run(stats.get_daily_stats()) + self.assertEqual(sum(after["visitors_per_day"].values()), today_before + 2) + def test_pageviews_per_day_breakdown_by_dimension(self): - run(stats.record_pageview(path="/", referrer="", user_agent=CHROME_UA)) + run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA)) data = run(stats.get_daily_stats()) today = next(iter(data["pageviews_per_day_by_device"])) self.assertGreaterEqual(data["pageviews_per_day_by_device"][today].get("pc", 0), 1) self.assertIn("Chrome", data["pageviews_per_day_by_browser"][today]) self.assertIn("Windows", data["pageviews_per_day_by_os"][today]) + def test_all_scope_chart_breakdown_matches_composition_tables(self): + # Bug hlaseny uzivatelom: v scope "vsetci" graf (pageviews_per_day_by_*) + # pocital kazdy klik zvlast, zatial co tabulky nizsie (browsers/os/ + # device_types) pocitali unikatny "navstevnicky den" -- cisla si tak + # nesedeli. Oboje ma teraz rovnaku dedup logiku (jeden navstevnik + + # kategoria + den = 1), takze sucty musia byt zhodne. + ip = "203.0.113.50" + for _ in range(3): + run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip)) + + data = run(stats.get_daily_stats(logged_in_only=False)) + chart_pc_total = sum(day.get("pc", 0) for day in data["pageviews_per_day_by_device"].values()) + chart_chrome_total = sum( + day.get("Chrome", 0) for day in data["pageviews_per_day_by_browser"].values() + ) + self.assertEqual(chart_pc_total, data["device_types"].get("pc", 0)) + self.assertEqual(chart_chrome_total, data["browsers"].get("Chrome", 0)) + def test_daily_stats_reflect_games_and_players(self): before = run(stats.get_daily_stats()) base_total_players = before["total_players"] @@ -126,7 +305,7 @@ class StatsCase(unittest.TestCase): self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4) def test_empty_referrer_excluded_from_top_referrers(self): - run(stats.record_pageview(path="/lobby", referrer="", user_agent=CHROME_UA)) + run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA)) data = run(stats.get_daily_stats()) self.assertNotIn("", data["top_referrers"])