Rozsirenie analytiky: login event, prepinac vsetci/prihlaseni, plny nazov krajiny
Pageview beacony uz neposielaju player_id z klienta (nedovereny vstup na neautentifikovanom endpointe) -- prihlasenie sa eviduje server-side ako event "login" (aj z registracie), s player_id skutocneho uctu. Admin dashboard dostal prepinac scope vsetci/prihlaseni, ktory konzistentne pocita grafy aj tabulky (kazdy login samostatne, navstevnicky den pre anonymnu navstevnost). PageView.country teraz uklada cely anglicky nazov krajiny namiesto ISO kodu (potrebna zmena schemy). Pridane sledovanie klikov na "Pravidla hry" aj z GameList. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+21
-1
@@ -6,6 +6,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from json import JSONEncoder
|
from json import JSONEncoder
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
import socketio
|
import socketio
|
||||||
|
|
||||||
@@ -103,6 +104,9 @@ async def _handle_track(scope, receive, send):
|
|||||||
data = {}
|
data = {}
|
||||||
headers = dict(scope.get("headers") or [])
|
headers = dict(scope.get("headers") or [])
|
||||||
user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300]
|
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(
|
await stats_module.record_pageview(
|
||||||
path=str(data.get("path", ""))[:200],
|
path=str(data.get("path", ""))[:200],
|
||||||
referrer=str(data.get("referrer", ""))[:300],
|
referrer=str(data.get("referrer", ""))[:300],
|
||||||
@@ -120,7 +124,9 @@ async def _handle_admin_stats(scope, send):
|
|||||||
if not _admin_authorized(scope):
|
if not _admin_authorized(scope):
|
||||||
_register_admin_failure(ip)
|
_register_admin_failure(ip)
|
||||||
return await _send_text(send, 403, b"forbidden")
|
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")
|
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)
|
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")
|
@sio.on("confirm_account")
|
||||||
async def confirm_account(sid, username, code):
|
async def confirm_account(sid, username, code):
|
||||||
try:
|
try:
|
||||||
@@ -389,6 +407,7 @@ async def confirm_account(sid, username, code):
|
|||||||
except AuthError as exc:
|
except AuthError as exc:
|
||||||
return await send_error(sid, str(exc))
|
return await send_error(sid, str(exc))
|
||||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
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)
|
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:
|
except AuthError as exc:
|
||||||
return await send_error(sid, str(exc))
|
return await send_error(sid, str(exc))
|
||||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
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)
|
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+169
-68
@@ -19,8 +19,9 @@ _geoip_load_attempted = False
|
|||||||
|
|
||||||
|
|
||||||
def _country_for_ip(ip: str) -> str:
|
def _country_for_ip(ip: str) -> str:
|
||||||
"""ISO kod krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo "" ak nie je
|
"""Cely anglicky nazov krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo ""
|
||||||
dostupny subor alebo sa IP neda rozlusit (privatna/lokalna adresa a pod.)."""
|
ak nie je dostupny subor alebo sa IP neda rozlusit (privatna/lokalna
|
||||||
|
adresa a pod.)."""
|
||||||
global _geoip_reader, _geoip_load_attempted
|
global _geoip_reader, _geoip_load_attempted
|
||||||
if not ip:
|
if not ip:
|
||||||
return ""
|
return ""
|
||||||
@@ -33,12 +34,36 @@ def _country_for_ip(ip: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
_geoip_reader = geoip2.database.Reader(path)
|
_geoip_reader = geoip2.database.Reader(path)
|
||||||
try:
|
try:
|
||||||
return _geoip_reader.country(ip).country.iso_code or ""
|
return _geoip_reader.country(ip).country.name or ""
|
||||||
except (geoip2.errors.AddressNotFoundError, ValueError):
|
except (geoip2.errors.AddressNotFoundError, ValueError):
|
||||||
return ""
|
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/<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. 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)
|
ua = parse_ua(user_agent)
|
||||||
device_type = (
|
device_type = (
|
||||||
"bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc"
|
"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:
|
async with async_session() as session:
|
||||||
session.add(
|
session.add(
|
||||||
PageView(
|
PageView(
|
||||||
path=path,
|
path=normalized,
|
||||||
|
player_id=player_id,
|
||||||
referrer=referrer,
|
referrer=referrer,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
browser=ua.browser.family[:40],
|
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
|
_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),
|
"""Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os),
|
||||||
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
|
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
|
||||||
Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne
|
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)
|
vday = func.date(PageView.created_at)
|
||||||
recent_days = (
|
if logged_in_only:
|
||||||
select(vday.label("day"))
|
recent_days_q = select(vday.label("day")).distinct().where(PageView.path == "login")
|
||||||
.distinct()
|
rows_q = (
|
||||||
.order_by(vday.desc())
|
|
||||||
.limit(_DAYS_WINDOW)
|
|
||||||
.subquery()
|
|
||||||
)
|
|
||||||
rows = (
|
|
||||||
await session.execute(
|
|
||||||
select(vday.label("day"), column.label("cat"), func.count().label("n"))
|
select(vday.label("day"), column.label("cat"), func.count().label("n"))
|
||||||
.where(vday.in_(select(recent_days.c.day)))
|
.where(PageView.path == "login")
|
||||||
.group_by(vday, column)
|
|
||||||
.order_by(vday.desc())
|
|
||||||
)
|
)
|
||||||
).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]] = {}
|
nested: dict[str, dict[str, int]] = {}
|
||||||
for r in rows:
|
for r in rows:
|
||||||
nested.setdefault(str(r.day), {})[r.cat] = r.n
|
nested.setdefault(str(r.day), {})[r.cat] = r.n
|
||||||
return nested
|
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:
|
async with async_session() as session:
|
||||||
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
|
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
|
||||||
# across SQLite and Postgres and returns a plain string/date value without
|
# across SQLite and Postgres and returns a plain string/date value without
|
||||||
@@ -151,23 +248,31 @@ async def get_daily_stats() -> dict:
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
vday = func.date(PageView.created_at)
|
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 = (
|
pageview_rows = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(vday.label("day"), func.count().label("n"))
|
pageview_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
||||||
.group_by(vday)
|
|
||||||
.order_by(vday.desc())
|
|
||||||
.limit(_DAYS_WINDOW)
|
|
||||||
)
|
)
|
||||||
).all()
|
).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(
|
await session.execute(
|
||||||
select(PageView.referrer, func.count().label("n"))
|
visitor_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
||||||
.where(PageView.referrer != "")
|
|
||||||
.group_by(PageView.referrer)
|
|
||||||
.order_by(func.count().desc())
|
|
||||||
.limit(20)
|
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
# Top stranky vzdy zo vsetkych navstev -- scope prepinac sa ich netyka.
|
||||||
top_paths = (
|
top_paths = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(PageView.path, func.count().label("n"))
|
select(PageView.path, func.count().label("n"))
|
||||||
@@ -177,39 +282,34 @@ async def get_daily_stats() -> dict:
|
|||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
browsers = (
|
if logged_in_only:
|
||||||
await session.execute(
|
# Kazdy login sa pocita samostatne (nededuplikovane) -- sedi to s
|
||||||
select(PageView.browser, func.count().label("n"))
|
# pageviews_per_day, kde opakovane prihlasenie toho isteho hraca
|
||||||
.group_by(PageView.browser)
|
# v ten isty den ma tiez pridat 2, nie 1.
|
||||||
.order_by(func.count().desc())
|
top_referrers = (await _login_event_counts(session, PageView.referrer, exclude_empty=True))[:20]
|
||||||
)
|
browsers = await _login_event_counts(session, PageView.browser)
|
||||||
).all()
|
os_rows = await _login_event_counts(session, PageView.os)
|
||||||
os_rows = (
|
device_rows = await _login_event_counts(session, PageView.device_type)
|
||||||
await session.execute(
|
country_rows = await _login_event_counts(session, PageView.country, exclude_empty=True)
|
||||||
select(PageView.os, func.count().label("n"))
|
else:
|
||||||
.group_by(PageView.os)
|
# Zlozenie anonymnej navstevnosti sa pocita v "navstevnickych
|
||||||
.order_by(func.count().desc())
|
# dnoch" (den+IP raz), nie v klikoch -- jeden aktivny hrac tak
|
||||||
)
|
# neprevazi tabulky.
|
||||||
).all()
|
top_referrers = (await _daily_unique_by(session, PageView.referrer, exclude_empty=True))[:20]
|
||||||
device_rows = (
|
browsers = await _daily_unique_by(session, PageView.browser)
|
||||||
await session.execute(
|
os_rows = await _daily_unique_by(session, PageView.os)
|
||||||
select(PageView.device_type, func.count().label("n"))
|
device_rows = await _daily_unique_by(session, PageView.device_type)
|
||||||
.group_by(PageView.device_type)
|
country_rows = await _daily_unique_by(session, PageView.country, exclude_empty=True)
|
||||||
.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()
|
|
||||||
|
|
||||||
pageviews_per_day_by_device = await _pageviews_by_day_and(session, PageView.device_type)
|
pageviews_per_day_by_device = await _pageviews_by_day_and(
|
||||||
pageviews_per_day_by_browser = await _pageviews_by_day_and(session, PageView.browser)
|
session, PageView.device_type, logged_in_only=logged_in_only
|
||||||
pageviews_per_day_by_os = await _pageviews_by_day_and(session, PageView.os)
|
)
|
||||||
|
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 {
|
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},
|
||||||
@@ -220,13 +320,14 @@ async def get_daily_stats() -> dict:
|
|||||||
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
"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},
|
"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},
|
"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_device": pageviews_per_day_by_device,
|
||||||
"pageviews_per_day_by_browser": pageviews_per_day_by_browser,
|
"pageviews_per_day_by_browser": pageviews_per_day_by_browser,
|
||||||
"pageviews_per_day_by_os": pageviews_per_day_by_os,
|
"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},
|
"top_paths": {r.path: r.n for r in top_paths},
|
||||||
"browsers": {r.browser: r.n for r in browsers},
|
"browsers": {r.cat: r.n for r in browsers},
|
||||||
"operating_systems": {r.os: r.n for r in os_rows},
|
"operating_systems": {r.cat: r.n for r in os_rows},
|
||||||
"device_types": {r.device_type: r.n for r in device_rows},
|
"device_types": {r.cat: r.n for r in device_rows},
|
||||||
"countries": {r.country: r.n for r in country_rows},
|
"countries": {r.cat: r.n for r in country_rows},
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -93,14 +93,20 @@ class PageView(Base):
|
|||||||
__tablename__ = "page_views"
|
__tablename__ = "page_views"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
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))
|
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="")
|
referrer: Mapped[str] = mapped_column(String(300), default="")
|
||||||
user_agent: Mapped[str] = mapped_column(String(300), default="")
|
user_agent: Mapped[str] = mapped_column(String(300), default="")
|
||||||
browser: Mapped[str] = mapped_column(String(40), default="")
|
browser: Mapped[str] = mapped_column(String(40), default="")
|
||||||
os: 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
|
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
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime, server_default=func.now()
|
DateTime, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
|||||||
+11
-6
@@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect } from 'react';
|
|||||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
|
||||||
import { useGameStore } from './store/gameStore';
|
import { useGameStore } from './store/gameStore';
|
||||||
import { socket, emit } from './lib/socket';
|
import { socket, emit } from './lib/socket';
|
||||||
|
import { trackEvent } from './lib/track';
|
||||||
import type { MyPlayer } from './types';
|
import type { MyPlayer } from './types';
|
||||||
import GameList from './pages/GameList';
|
import GameList from './pages/GameList';
|
||||||
import Lobby from './pages/Lobby';
|
import Lobby from './pages/Lobby';
|
||||||
@@ -73,15 +74,19 @@ function AppInner() {
|
|||||||
// stats), not a page the user actually navigated to, so they'd otherwise
|
// 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
|
// 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.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (navigationType === 'REPLACE') return;
|
if (navigationType === 'REPLACE') return;
|
||||||
if (location.pathname.startsWith('/admin')) return;
|
if (location.pathname.startsWith('/admin')) return;
|
||||||
fetch('/api/track', {
|
if (
|
||||||
method: 'POST',
|
location.pathname === '/' ||
|
||||||
headers: { 'Content-Type': 'application/json' },
|
location.pathname.startsWith('/lobby') ||
|
||||||
body: JSON.stringify({ path: location.pathname, referrer: document.referrer }),
|
location.pathname.startsWith('/game')
|
||||||
keepalive: true,
|
) {
|
||||||
}).catch(() => {});
|
return;
|
||||||
|
}
|
||||||
|
trackEvent(location.pathname, document.referrer);
|
||||||
}, [location.pathname, navigationType]);
|
}, [location.pathname, navigationType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { useGameStore } from '../store/gameStore';
|
import { useGameStore } from '../store/gameStore';
|
||||||
import { emit } from '../lib/socket';
|
import { emit } from '../lib/socket';
|
||||||
|
import { trackEvent } from '../lib/track';
|
||||||
import RulesModal from '../components/RulesModal';
|
import RulesModal from '../components/RulesModal';
|
||||||
|
|
||||||
type Mode = 'login' | 'register';
|
type Mode = 'login' | 'register';
|
||||||
@@ -45,6 +46,11 @@ export default function Auth() {
|
|||||||
setRegistration(null);
|
setRegistration(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShowRules = () => {
|
||||||
|
setShowRules(true);
|
||||||
|
trackEvent('rules_view');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
|
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
|
||||||
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
|
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
|
||||||
@@ -162,7 +168,7 @@ export default function Auth() {
|
|||||||
<div className="mt-10 flex items-center gap-3">
|
<div className="mt-10 flex items-center gap-3">
|
||||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRules(true)}
|
onClick={handleShowRules}
|
||||||
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
|
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
|
||||||
>
|
>
|
||||||
Pravidlá hry
|
Pravidlá hry
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useGameStore } from '../store/gameStore';
|
import { useGameStore } from '../store/gameStore';
|
||||||
import { emit, socket, setAuthToken } from '../lib/socket';
|
import { emit, socket, setAuthToken } from '../lib/socket';
|
||||||
|
import { trackEvent } from '../lib/track';
|
||||||
import NameModal from '../components/NameModal';
|
import NameModal from '../components/NameModal';
|
||||||
import RulesModal from '../components/RulesModal';
|
import RulesModal from '../components/RulesModal';
|
||||||
|
|
||||||
@@ -12,6 +13,11 @@ export default function GameList() {
|
|||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [showRules, setShowRules] = useState(false);
|
const [showRules, setShowRules] = useState(false);
|
||||||
|
|
||||||
|
const handleShowRules = () => {
|
||||||
|
setShowRules(true);
|
||||||
|
trackEvent('rules_view');
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('bridzik_token');
|
localStorage.removeItem('bridzik_token');
|
||||||
localStorage.removeItem('bridzik_player');
|
localStorage.removeItem('bridzik_player');
|
||||||
@@ -85,7 +91,7 @@ export default function GameList() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRules(true)}
|
onClick={handleShowRules}
|
||||||
className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
|
className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
|
||||||
>
|
>
|
||||||
Pravidlá hry
|
Pravidlá hry
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface DailyStats {
|
|||||||
peak_hours: Record<string, number>;
|
peak_hours: Record<string, number>;
|
||||||
rounds_per_day: Record<string, number>;
|
rounds_per_day: Record<string, number>;
|
||||||
pageviews_per_day: Record<string, number>;
|
pageviews_per_day: Record<string, number>;
|
||||||
|
visitors_per_day: Record<string, number>;
|
||||||
pageviews_per_day_by_device: Record<string, Record<string, number>>;
|
pageviews_per_day_by_device: Record<string, Record<string, number>>;
|
||||||
pageviews_per_day_by_browser: Record<string, Record<string, number>>;
|
pageviews_per_day_by_browser: Record<string, Record<string, number>>;
|
||||||
pageviews_per_day_by_os: Record<string, Record<string, number>>;
|
pageviews_per_day_by_os: Record<string, Record<string, number>>;
|
||||||
@@ -100,15 +101,37 @@ function BreakdownTable({ title, data }: { title: string; data: Record<string, n
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Scope = 'all' | 'logged_in';
|
||||||
|
|
||||||
|
function ScopeToggle({ scope, onChange }: { scope: Scope; onChange: (s: Scope) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex rounded-lg overflow-hidden border border-gold/20 self-start">
|
||||||
|
{(['all', 'logged_in'] as Scope[]).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => onChange(s)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-serif tracking-wide transition-colors ${
|
||||||
|
scope === s ? 'bg-gold text-table' : 'bg-header text-green-dim hover:text-gold'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s === 'all' ? 'Všetci' : 'Prihlásení'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminStats() {
|
export default function AdminStats() {
|
||||||
const token = useAdminToken();
|
const token = useAdminToken();
|
||||||
const desktop = useIsDesktop();
|
const desktop = useIsDesktop();
|
||||||
const [data, setData] = useState<DailyStats | null>(null);
|
const [data, setData] = useState<DailyStats | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [scope, setScope] = useState<Scope>('all');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
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) => {
|
.then((res) => {
|
||||||
if (res.status === 403) throw new Error('Neplatný token.');
|
if (res.status === 403) throw new Error('Neplatný token.');
|
||||||
if (!res.ok) throw new Error('Chyba pri načítaní štatistík.');
|
if (!res.ok) throw new Error('Chyba pri načítaní štatistík.');
|
||||||
@@ -123,7 +146,7 @@ export default function AdminStats() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token, scope]);
|
||||||
|
|
||||||
if (error) return <p className="text-red-400 text-sm">{error}</p>;
|
if (error) return <p className="text-red-400 text-sm">{error}</p>;
|
||||||
if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
|
if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
|
||||||
@@ -149,8 +172,15 @@ export default function AdminStats() {
|
|||||||
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
|
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Prepinac sa tyka len traffic-analytickych widgetov nizsie (PageView) --
|
||||||
|
herne metriky vyssie su uz vzdy o prihlasenych uctoch. */}
|
||||||
|
<ScopeToggle scope={scope} onChange={setScope} />
|
||||||
|
|
||||||
<PageviewsChart
|
<PageviewsChart
|
||||||
total={data.pageviews_per_day}
|
total={data.pageviews_per_day}
|
||||||
|
totalLabel={scope === 'logged_in' ? 'Prihlásení' : 'Zobrazenia'}
|
||||||
|
visitors={data.visitors_per_day}
|
||||||
|
visitorsLabel={scope === 'logged_in' ? 'Unikátní' : 'Návštevníci'}
|
||||||
byDevice={data.pageviews_per_day_by_device}
|
byDevice={data.pageviews_per_day_by_device}
|
||||||
byBrowser={data.pageviews_per_day_by_browser}
|
byBrowser={data.pageviews_per_day_by_browser}
|
||||||
byOs={data.pageviews_per_day_by_os}
|
byOs={data.pageviews_per_day_by_os}
|
||||||
|
|||||||
@@ -25,18 +25,32 @@ const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
total: Record<string, number>;
|
total: Record<string, number>;
|
||||||
|
totalLabel?: string;
|
||||||
|
visitors: Record<string, number>;
|
||||||
|
visitorsLabel?: string;
|
||||||
byDevice: Record<string, Record<string, number>>;
|
byDevice: Record<string, Record<string, number>>;
|
||||||
byBrowser: Record<string, Record<string, number>>;
|
byBrowser: Record<string, Record<string, number>>;
|
||||||
byOs: Record<string, Record<string, number>>;
|
byOs: Record<string, Record<string, number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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<Dimension>('total');
|
const [dimension, setDimension] = useState<Dimension>('total');
|
||||||
|
|
||||||
const { rows, categories } = useMemo(() => {
|
const { rows, categories } = useMemo(() => {
|
||||||
if (dimension === 'total') {
|
if (dimension === 'total') {
|
||||||
const days = Object.keys(total).sort();
|
const days = [...new Set([...Object.keys(total), ...Object.keys(visitors)])].sort();
|
||||||
return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] };
|
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 byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs;
|
||||||
const days = Object.keys(byDay).sort();
|
const days = Object.keys(byDay).sort();
|
||||||
@@ -47,7 +61,9 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro
|
|||||||
return row;
|
return row;
|
||||||
});
|
});
|
||||||
return { rows, categories };
|
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 (
|
return (
|
||||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||||
@@ -73,13 +89,13 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro
|
|||||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||||
{dimension !== 'total' && <Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />}
|
<Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />
|
||||||
{categories.map((c, i) => (
|
{categories.map((c, i) => (
|
||||||
<Line
|
<Line
|
||||||
key={c}
|
key={c}
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={c}
|
dataKey={c}
|
||||||
name={dimension === 'total' ? 'Návštevy' : c}
|
name={lineName(c)}
|
||||||
stroke={PALETTE[i % PALETTE.length]}
|
stroke={PALETTE[i % PALETTE.length]}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={false}
|
||||||
|
|||||||
+183
-4
@@ -64,7 +64,7 @@ class StatsCase(unittest.TestCase):
|
|||||||
|
|
||||||
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="/", 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",
|
||||||
))
|
))
|
||||||
|
|
||||||
data = run(stats.get_daily_stats())
|
data = run(stats.get_daily_stats())
|
||||||
@@ -73,7 +73,7 @@ class StatsCase(unittest.TestCase):
|
|||||||
self.assertIn("Windows", data["operating_systems"])
|
self.assertIn("Windows", data["operating_systems"])
|
||||||
self.assertGreaterEqual(data["device_types"].get("pc", 0), 1)
|
self.assertGreaterEqual(data["device_types"].get("pc", 0), 1)
|
||||||
self.assertGreaterEqual(data["top_referrers"].get("https://example.com", 0), 1)
|
self.assertGreaterEqual(data["top_referrers"].get("https://example.com", 0), 1)
|
||||||
self.assertGreaterEqual(data["top_paths"].get("/", 0), 1)
|
self.assertGreaterEqual(data["top_paths"].get("/history", 0), 1)
|
||||||
|
|
||||||
# Bez GEOIP_DB_PATH (v testoch nenastaveny) sa krajina jednoducho nerozlusi,
|
# Bez GEOIP_DB_PATH (v testoch nenastaveny) sa krajina jednoducho nerozlusi,
|
||||||
# ale IP sa uz do page_views ulozi.
|
# ale IP sa uz do page_views ulozi.
|
||||||
@@ -102,14 +102,193 @@ class StatsCase(unittest.TestCase):
|
|||||||
self.assertEqual(stats._country_for_ip("203.0.113.5"), "")
|
self.assertEqual(stats._country_for_ip("203.0.113.5"), "")
|
||||||
self.assertEqual(stats._country_for_ip(""), "")
|
self.assertEqual(stats._country_for_ip(""), "")
|
||||||
|
|
||||||
|
def test_dynamic_path_segments_normalized(self):
|
||||||
|
self.assertEqual(stats._normalize_path("/lobby/abc-123"), "/lobby")
|
||||||
|
self.assertEqual(stats._normalize_path("/game/abc-123"), "/game")
|
||||||
|
self.assertEqual(stats._normalize_path("/lobby"), "/lobby")
|
||||||
|
self.assertEqual(stats._normalize_path("/history"), "/history")
|
||||||
|
# /gameXYZ nie je /game/<id> -- 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):
|
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())
|
data = run(stats.get_daily_stats())
|
||||||
today = next(iter(data["pageviews_per_day_by_device"]))
|
today = next(iter(data["pageviews_per_day_by_device"]))
|
||||||
self.assertGreaterEqual(data["pageviews_per_day_by_device"][today].get("pc", 0), 1)
|
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("Chrome", data["pageviews_per_day_by_browser"][today])
|
||||||
self.assertIn("Windows", data["pageviews_per_day_by_os"][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):
|
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"]
|
||||||
@@ -126,7 +305,7 @@ class StatsCase(unittest.TestCase):
|
|||||||
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
|
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
|
||||||
|
|
||||||
def test_empty_referrer_excluded_from_top_referrers(self):
|
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())
|
data = run(stats.get_daily_stats())
|
||||||
self.assertNotIn("", data["top_referrers"])
|
self.assertNotIn("", data["top_referrers"])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user