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:
+169
-68
@@ -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/<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)
|
||||
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},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user