From 762f12132056fbd2e9b7e697b7457b1f7abcc715 Mon Sep 17 00:00:00 2001 From: jay Date: Wed, 3 Jun 2026 18:25:46 +0000 Subject: [PATCH] Admin step B: stats endpoint + /admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- frontend/src/routes/account/+page.svelte | 2 + frontend/src/routes/admin/+page.svelte | 179 +++++++++++++++++++++++ goodnews/api.py | 21 +++ goodnews/auth.py | 3 +- goodnews/db.py | 5 +- goodnews/queries.py | 69 +++++++++ tests/test_admin.py | 57 ++++++++ 7 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/admin/+page.svelte create mode 100644 tests/test_admin.py diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 5abe03d..6f290b1 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -29,6 +29,7 @@ Saved History Boundaries + {#if auth.user.is_admin}Admin dashboard{/if} goto('/')} /> {/if} @@ -53,4 +54,5 @@ border-radius: 999px; padding: 7px 15px; font-size: 0.9rem; } .quick a:hover { border-color: var(--accent); color: var(--accent-deep); } + .quick a.admin { border-color: var(--accent); color: var(--accent-deep); } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte new file mode 100644 index 0000000..8cff0bd --- /dev/null +++ b/frontend/src/routes/admin/+page.svelte @@ -0,0 +1,179 @@ + + +
+
+ + ← Account +
+
+ +
+

Dashboard

+ {#if error} +

{error}

+ {:else if !stats} +

Loading…

+ {:else} +

Aggregate, anonymous — last {stats.days} days. No personal data.

+ +
+
{stats.visitors.today}Visitors today
+
{stats.visitors.d7}Last 7 days
+
{stats.visitors.d30}Last 30 days
+
{stats.returning}Returning
+
{stats.once}One-and-done
+
+ +
+

Most-opened articles

+ {#if stats.top_articles.length} +
    + {#each stats.top_articles as a (a.id)} +
  • + {a.title} + + {a.opens} +
  • + {/each} +
+ {:else}

No opens yet.

{/if} +
+ +
+
+

Popular groupings

+ {#if stats.top_groupings.length} +
    + {#each stats.top_groupings as g (g.tag)} +
  • + {g.tag.replace('-', ' ')} + + {g.opens} +
  • + {/each} +
+ {:else}

No data yet.

{/if} +
+ +
+

Popular topics

+ {#if stats.top_topics.length} +
    + {#each stats.top_topics as t (t.topic)} +
  • + {t.topic} + + {t.opens} +
  • + {/each} +
+ {:else}

No data yet.

{/if} +
+
+ +
+

Sharing

+
+ {#each Object.entries(stats.shares) as [kind, n]} +
{n}{SHARE_LABEL[kind] ?? kind}
+ {/each} +
+
+ +
+

Daily trend

+ {#if stats.daily.length} + {@const dmax = Math.max(1, ...stats.daily.map((d) => Math.max(d.opens, d.visits)))} +
+ {#each stats.daily as d (d.day)} +
+ + +
+ {/each} +
+

visits   opens

+ {:else}

No data yet.

{/if} +
+ {/if} +
+ + diff --git a/goodnews/api.py b/goodnews/api.py index 920af47..2f40b91 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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: diff --git a/goodnews/auth.py b/goodnews/auth.py index 278f238..96fb0b3 100644 --- a/goodnews/auth.py +++ b/goodnews/auth.py @@ -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() diff --git a/goodnews/db.py b/goodnews/db.py index cdc1d91..f478f19 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -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: diff --git a/goodnews/queries.py b/goodnews/queries.py index b50d1fa..ecc4884 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -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] diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..1d1a1e8 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,57 @@ +import pytest +from fastapi.testclient import TestClient + + +def _make(tmp_path, monkeypatch, admin_email=""): + db = tmp_path / "t.sqlite3" + monkeypatch.setenv("GOODNEWS_DB", str(db)) + monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver") + monkeypatch.setenv("GOODNEWS_ADMIN_EMAILS", admin_email) + import importlib + import goodnews.api as api + importlib.reload(api) + from goodnews.db import connect, init_db + c = connect(str(db)); init_db(c) + c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)") + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) VALUES (1,1,'http://s/1','t1','h1')") + c.execute("INSERT INTO article_scores (article_id,accepted,topic) VALUES (1,1,'science')") + c.execute("INSERT INTO article_tags (article_id,tag) VALUES (1,'science')") + c.commit(); c.close() + return api.create_app(), api + + +def _signin(app, api, email): + tc = TestClient(app) + sent = {} + import goodnews.email_send as es + orig = es.send_magic_link + es.send_magic_link = lambda to, link: sent.update(link=link) + try: + tc.post("/api/auth/email/start", json={"email": email}) + tc.post("/api/auth/email/verify", json={"token": sent["link"].split("token=")[1]}) + finally: + es.send_magic_link = orig + return tc + + +def test_admin_gating(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + assert TestClient(app).get("/api/admin/stats").status_code == 401 # anon + nonadmin = _signin(app, api, "rando@x.com") + assert nonadmin.get("/api/admin/stats").status_code == 403 # signed in, not admin + assert nonadmin.get("/api/auth/me").json()["is_admin"] is False + admin = _signin(app, api, "Boss@X.com") # case-insensitive match + assert admin.get("/api/auth/me").json()["is_admin"] is True + assert admin.get("/api/admin/stats").status_code == 200 + + +def test_admin_stats_shape(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + admin = _signin(app, api, "boss@x.com") + # log a couple of events + admin.post("/api/events", json={"kind": "visit", "visitor": "v1"}) + admin.post("/api/events", json={"kind": "open", "article_id": 1, "visitor": "v1"}) + stats = admin.get("/api/admin/stats").json() + assert set(stats) >= {"visitors", "returning", "once", "top_articles", "top_groupings", "top_topics", "shares", "daily"} + assert stats["top_articles"][0]["id"] == 1 and stats["top_articles"][0]["opens"] == 1 + assert any(g["tag"] == "science" for g in stats["top_groupings"])