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
+57
View File
@@ -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"])