+ 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"])