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:
jay
2026-06-03 18:25:46 +00:00
parent 1a778e1334
commit 762f121320
7 changed files with 334 additions and 2 deletions
+21
View File
@@ -59,6 +59,8 @@ SESSION_COOKIE = "ub_session"
OAUTH_COOKIE = "ub_oauth"
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds())
SESSION_SECRET = os.environ.get("GOODNEWS_SESSION_SECRET", "dev-insecure-secret")
# Emails that are always admins (normalized), in addition to users.is_admin.
ADMIN_EMAILS = {e.strip().lower() for e in os.environ.get("GOODNEWS_ADMIN_EMAILS", "").split(",") if e.strip()}
# Secure cookies in production (https); off for http (local/test) so they round-trip.
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@@ -104,12 +106,24 @@ def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
return user
def _is_admin(user: sqlite3.Row) -> bool:
return bool(user["is_admin"]) or auth.normalize_email(user["email"]) in ADMIN_EMAILS
def _require_admin(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
user = _require_user(conn, request)
if not _is_admin(user):
raise HTTPException(status_code=403, detail="Admins only.")
return user
def _user_out(user: sqlite3.Row) -> dict:
return {
"id": user["id"],
"email": user["email"],
"display_name": user["display_name"],
"avatar_url": user["avatar_url"],
"is_admin": _is_admin(user),
}
@@ -316,6 +330,7 @@ class UserOut(BaseModel):
email: str
display_name: str | None = None
avatar_url: str | None = None
is_admin: bool = False
class SessionOut(BaseModel):
@@ -697,6 +712,12 @@ def create_app() -> FastAPI:
conn.commit()
return {"ok": True} # always identical; dedup'd by the unique key
@app.get("/api/admin/stats")
def admin_stats(request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
return queries.admin_stats(conn)
@app.get("/api/summary/{article_id}")
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
with get_conn() as conn:
+2 -1
View File
@@ -46,7 +46,8 @@ def normalize_email(email: str) -> str:
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
return conn.execute(
"SELECT id, email, display_name, avatar_url, created_at FROM users WHERE id = ?", (user_id,)
"SELECT id, email, display_name, avatar_url, is_admin, created_at FROM users WHERE id = ?",
(user_id,),
).fetchone()
+4 -1
View File
@@ -137,6 +137,7 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT NOT NULL UNIQUE,
display_name TEXT,
avatar_url TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
@@ -259,10 +260,12 @@ def _migrate(conn: sqlite3.Connection) -> None:
if column not in score_cols:
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
# users.avatar_url added for Google profile pictures.
# users.avatar_url (Google pictures) + is_admin (admin dashboard) added later.
user_tbl = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
if user_tbl and "avatar_url" not in user_tbl:
conn.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT")
if user_tbl and "is_admin" not in user_tbl:
conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0")
article_cols = {row["name"] for row in conn.execute("PRAGMA table_info(articles)")}
if "duplicate_of" not in article_cols:
+69
View File
@@ -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]