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:
@@ -29,6 +29,7 @@
|
|||||||
<a href="/?view=saved">Saved</a>
|
<a href="/?view=saved">Saved</a>
|
||||||
<a href="/?open=history">History</a>
|
<a href="/?open=history">History</a>
|
||||||
<a href="/?open=boundaries">Boundaries</a>
|
<a href="/?open=boundaries">Boundaries</a>
|
||||||
|
{#if auth.user.is_admin}<a href="/admin" class="admin">Admin dashboard</a>{/if}
|
||||||
</nav>
|
</nav>
|
||||||
<AccountPanel onclose={() => goto('/')} />
|
<AccountPanel onclose={() => goto('/')} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -53,4 +54,5 @@
|
|||||||
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
|
border-radius: 999px; padding: 7px 15px; font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.quick a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
.quick a:hover { border-color: var(--accent); color: var(--accent-deep); }
|
||||||
|
.quick a.admin { border-color: var(--accent); color: var(--accent-deep); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { getJSON } from '$lib/api.js';
|
||||||
|
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||||
|
|
||||||
|
let stats = $state(null);
|
||||||
|
let error = $state('');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!auth.ready) await refresh();
|
||||||
|
if (!auth.user || !auth.user.is_admin) {
|
||||||
|
goto('/', { replaceState: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stats = await getJSON('/api/admin/stats');
|
||||||
|
} catch {
|
||||||
|
error = "Couldn't load stats.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const SHARE_LABEL = {
|
||||||
|
share_ub: 'Copied UB link',
|
||||||
|
native_share: 'Native share',
|
||||||
|
copy_source: 'Copied source',
|
||||||
|
source_click: 'Source clicks (from /a)',
|
||||||
|
};
|
||||||
|
// Width % for a simple CSS bar, relative to the top value in a list.
|
||||||
|
function pct(v, max) {
|
||||||
|
return max > 0 ? Math.max(4, Math.round((v / max) * 100)) : 0;
|
||||||
|
}
|
||||||
|
const max = (rows, key) => rows.reduce((m, r) => Math.max(m, r[key]), 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="bar">
|
||||||
|
<div class="container inner">
|
||||||
|
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
|
||||||
|
<a class="back" href="/account">← Account</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container page">
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
{#if error}
|
||||||
|
<p class="muted">{error}</p>
|
||||||
|
{:else if !stats}
|
||||||
|
<p class="muted">Loading…</p>
|
||||||
|
{:else}
|
||||||
|
<p class="sub">Aggregate, anonymous — last {stats.days} days. No personal data.</p>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Last 30 days</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.returning}</span><span class="l">Returning</span></div>
|
||||||
|
<div class="stat"><span class="n">{stats.once}</span><span class="l">One-and-done</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Most-opened articles</h2>
|
||||||
|
{#if stats.top_articles.length}
|
||||||
|
<ul class="bars">
|
||||||
|
{#each stats.top_articles as a (a.id)}
|
||||||
|
<li>
|
||||||
|
<a href={'/a/' + a.id} class="lbl" title={a.title}>{a.title}</a>
|
||||||
|
<span class="bar" style="width:{pct(a.opens, max(stats.top_articles, 'opens'))}%"></span>
|
||||||
|
<span class="v">{a.opens}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}<p class="muted">No opens yet.</p>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="two">
|
||||||
|
<section>
|
||||||
|
<h2>Popular groupings</h2>
|
||||||
|
{#if stats.top_groupings.length}
|
||||||
|
<ul class="bars">
|
||||||
|
{#each stats.top_groupings as g (g.tag)}
|
||||||
|
<li>
|
||||||
|
<span class="lbl">{g.tag.replace('-', ' ')}</span>
|
||||||
|
<span class="bar" style="width:{pct(g.opens, max(stats.top_groupings, 'opens'))}%"></span>
|
||||||
|
<span class="v">{g.opens}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}<p class="muted">No data yet.</p>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Popular topics</h2>
|
||||||
|
{#if stats.top_topics.length}
|
||||||
|
<ul class="bars">
|
||||||
|
{#each stats.top_topics as t (t.topic)}
|
||||||
|
<li>
|
||||||
|
<span class="lbl">{t.topic}</span>
|
||||||
|
<span class="bar" style="width:{pct(t.opens, max(stats.top_topics, 'opens'))}%"></span>
|
||||||
|
<span class="v">{t.opens}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}<p class="muted">No data yet.</p>{/if}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Sharing</h2>
|
||||||
|
<div class="cards">
|
||||||
|
{#each Object.entries(stats.shares) as [kind, n]}
|
||||||
|
<div class="stat"><span class="n">{n}</span><span class="l">{SHARE_LABEL[kind] ?? kind}</span></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Daily trend</h2>
|
||||||
|
{#if stats.daily.length}
|
||||||
|
{@const dmax = Math.max(1, ...stats.daily.map((d) => Math.max(d.opens, d.visits)))}
|
||||||
|
<div class="trend">
|
||||||
|
{#each stats.daily as d (d.day)}
|
||||||
|
<div class="col" title={`${d.day}: ${d.visits} visits, ${d.opens} opens`}>
|
||||||
|
<span class="visits" style="height:{pct(d.visits, dmax)}%"></span>
|
||||||
|
<span class="opens" style="height:{pct(d.opens, dmax)}%"></span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<p class="legend"><span class="sw visits"></span> visits <span class="sw opens"></span> opens</p>
|
||||||
|
{:else}<p class="muted">No data yet.</p>{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
|
||||||
|
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
|
||||||
|
.logo { height: 40px; display: block; }
|
||||||
|
.back { color: var(--accent-deep); font-size: 0.9rem; }
|
||||||
|
.page { padding: 22px 20px 70px; }
|
||||||
|
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 2px; }
|
||||||
|
.sub { color: var(--muted); font-size: 0.9rem; margin: 0 0 22px; }
|
||||||
|
h2 { font-size: 1.1rem; margin: 30px 0 12px; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.cards { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.stat {
|
||||||
|
flex: 1 1 120px; background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; padding: 14px 16px; display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
|
||||||
|
.stat .l { color: var(--muted); font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
|
||||||
|
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
ul.bars { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
ul.bars li { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; position: relative; }
|
||||||
|
ul.bars .lbl {
|
||||||
|
grid-column: 1; z-index: 1; font-size: 0.86rem; color: var(--ink);
|
||||||
|
text-transform: capitalize; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
a.lbl { text-decoration: none; }
|
||||||
|
a.lbl:hover { color: var(--accent-deep); }
|
||||||
|
ul.bars .bar {
|
||||||
|
grid-column: 1 / 2; grid-row: 1; height: 100%; min-height: 26px; align-self: stretch;
|
||||||
|
background: var(--accent-soft); border-radius: 8px; z-index: 0;
|
||||||
|
}
|
||||||
|
ul.bars .v { grid-column: 2; color: var(--muted); font-size: 0.82rem; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.trend { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
|
||||||
|
.trend .col { flex: 1; display: flex; align-items: flex-end; gap: 1px; height: 100%; }
|
||||||
|
.trend .visits { flex: 1; background: var(--accent-soft); border-radius: 2px 2px 0 0; }
|
||||||
|
.trend .opens { flex: 1; background: var(--accent); border-radius: 2px 2px 0 0; }
|
||||||
|
.legend { color: var(--muted); font-size: 0.78rem; margin-top: 8px; }
|
||||||
|
.legend .sw { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; }
|
||||||
|
.legend .sw.visits { background: var(--accent-soft); }
|
||||||
|
.legend .sw.opens { background: var(--accent); }
|
||||||
|
</style>
|
||||||
@@ -59,6 +59,8 @@ SESSION_COOKIE = "ub_session"
|
|||||||
OAUTH_COOKIE = "ub_oauth"
|
OAUTH_COOKIE = "ub_oauth"
|
||||||
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds())
|
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds())
|
||||||
SESSION_SECRET = os.environ.get("GOODNEWS_SESSION_SECRET", "dev-insecure-secret")
|
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.
|
# Secure cookies in production (https); off for http (local/test) so they round-trip.
|
||||||
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
|
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
|
||||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
@@ -104,12 +106,24 @@ def _require_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row:
|
|||||||
return user
|
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:
|
def _user_out(user: sqlite3.Row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": user["id"],
|
"id": user["id"],
|
||||||
"email": user["email"],
|
"email": user["email"],
|
||||||
"display_name": user["display_name"],
|
"display_name": user["display_name"],
|
||||||
"avatar_url": user["avatar_url"],
|
"avatar_url": user["avatar_url"],
|
||||||
|
"is_admin": _is_admin(user),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -316,6 +330,7 @@ class UserOut(BaseModel):
|
|||||||
email: str
|
email: str
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
avatar_url: str | None = None
|
avatar_url: str | None = None
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SessionOut(BaseModel):
|
class SessionOut(BaseModel):
|
||||||
@@ -697,6 +712,12 @@ def create_app() -> FastAPI:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
return {"ok": True} # always identical; dedup'd by the unique key
|
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}")
|
@app.get("/api/summary/{article_id}")
|
||||||
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
def article_summary(article_id: int, background_tasks: BackgroundTasks) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
+2
-1
@@ -46,7 +46,8 @@ def normalize_email(email: str) -> str:
|
|||||||
|
|
||||||
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
|
def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None:
|
||||||
return conn.execute(
|
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()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -137,6 +137,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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:
|
if column not in score_cols:
|
||||||
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
|
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)")}
|
user_tbl = {row["name"] for row in conn.execute("PRAGMA table_info(users)")}
|
||||||
if user_tbl and "avatar_url" not in user_tbl:
|
if user_tbl and "avatar_url" not in user_tbl:
|
||||||
conn.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT")
|
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)")}
|
article_cols = {row["name"] for row in conn.execute("PRAGMA table_info(articles)")}
|
||||||
if "duplicate_of" not in article_cols:
|
if "duplicate_of" not in article_cols:
|
||||||
|
|||||||
@@ -196,6 +196,75 @@ def history(conn: sqlite3.Connection, user_id: int, limit: int = 200) -> list[di
|
|||||||
return [dict(row) for row in rows]
|
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]:
|
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)."""
|
"""Filter to ids that still exist (FK-safe inserts for save/history/import)."""
|
||||||
clean = list({int(i) for i in ids})[:1000]
|
clean = list({int(i) for i in ids})[:1000]
|
||||||
|
|||||||
@@ -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"])
|
||||||
Reference in New Issue
Block a user