Admin step B: stats endpoint + /admin dashboard
- users.is_admin (+ migration); admin = is_admin OR email in GOODNEWS_ADMIN_EMAILS (normalized). is_admin exposed on /api/auth/me. Server-authorized GET /api/admin/stats (403 for non-admins). - queries.admin_stats: visitors (today/7d/30d), returning vs one-and-done, top opened articles, popular groupings + topics (derived from article_id at query time), share breakdown, daily opens/visits trend — all aggregate, no PII. - /admin page (gated, redirects non-admins): stat cards, CSS bar lists, a daily trend; "Admin dashboard" link on /account for admins. 129 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -196,6 +196,75 @@ def history(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[di
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
|
||||
"""Aggregate, non-personal usage stats for the admin dashboard."""
|
||||
since = f"-{days} days"
|
||||
|
||||
def scalar(sql, params=()):
|
||||
return conn.execute(sql, params).fetchone()[0] or 0
|
||||
|
||||
visitors = {
|
||||
"today": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day=date('now')"),
|
||||
"d7": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now','-7 days')"),
|
||||
"d30": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
|
||||
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?)", (since,)),
|
||||
}
|
||||
|
||||
# Returning (seen on ≥2 distinct days) vs one-and-done, over the window.
|
||||
rows = conn.execute(
|
||||
"SELECT CASE WHEN d>=2 THEN 'returning' ELSE 'once' END g, COUNT(*) n FROM ("
|
||||
" SELECT visitor_hash, COUNT(DISTINCT day) d FROM events "
|
||||
" WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?) GROUP BY visitor_hash"
|
||||
") GROUP BY g", (since,),
|
||||
).fetchall()
|
||||
loyalty = {r["g"]: r["n"] for r in rows}
|
||||
|
||||
top_articles = [dict(r) for r in conn.execute(
|
||||
"SELECT e.article_id AS id, a.title, src.name AS source, COUNT(*) AS opens "
|
||||
"FROM events e JOIN articles a ON a.id=e.article_id JOIN sources src ON src.id=a.source_id "
|
||||
"WHERE e.kind='open' AND e.article_id>0 AND e.day>=date('now',?) "
|
||||
"GROUP BY e.article_id ORDER BY opens DESC LIMIT 12", (since,),
|
||||
)]
|
||||
|
||||
top_groupings = [dict(r) for r in conn.execute(
|
||||
"SELECT t.tag, COUNT(*) AS opens FROM events e JOIN article_tags t ON t.article_id=e.article_id "
|
||||
"WHERE e.kind='open' AND e.day>=date('now',?) GROUP BY t.tag ORDER BY opens DESC LIMIT 12", (since,),
|
||||
)]
|
||||
|
||||
top_topics = [dict(r) for r in conn.execute(
|
||||
"SELECT s.topic, COUNT(*) AS opens FROM events e JOIN article_scores s ON s.article_id=e.article_id "
|
||||
"WHERE e.kind='open' AND s.topic IS NOT NULL AND e.day>=date('now',?) "
|
||||
"GROUP BY s.topic ORDER BY opens DESC", (since,),
|
||||
)]
|
||||
|
||||
share_rows = conn.execute(
|
||||
"SELECT kind, COUNT(*) n FROM events "
|
||||
"WHERE kind IN ('share_ub','copy_source','native_share','source_click') AND day>=date('now',?) "
|
||||
"GROUP BY kind", (since,),
|
||||
).fetchall()
|
||||
shares = {k: 0 for k in ("share_ub", "copy_source", "native_share", "source_click")}
|
||||
shares.update({r["kind"]: r["n"] for r in share_rows})
|
||||
|
||||
daily = [dict(r) for r in conn.execute(
|
||||
"SELECT day, SUM(kind='open') AS opens, SUM(kind='visit') AS visits FROM events "
|
||||
"WHERE day>=date('now',?) GROUP BY day ORDER BY day", (since,),
|
||||
)]
|
||||
|
||||
return {
|
||||
"days": days,
|
||||
"visitors": visitors,
|
||||
"returning": loyalty.get("returning", 0),
|
||||
"once": loyalty.get("once", 0),
|
||||
"top_articles": top_articles,
|
||||
"top_groupings": top_groupings,
|
||||
"top_topics": top_topics,
|
||||
"shares": shares,
|
||||
"daily": daily,
|
||||
}
|
||||
|
||||
|
||||
def existing_article_ids(conn: sqlite3.Connection, ids: list[int]) -> list[int]:
|
||||
"""Filter to ids that still exist (FK-safe inserts for save/history/import)."""
|
||||
clean = list({int(i) for i in ids})[:1000]
|
||||
|
||||
Reference in New Issue
Block a user