Source management console: pause/resume, flag/clear, decision metrics

Turn the Sources tab into a real management console (per Codex):

* source_health now lists ALL sources (active + paused) with backing metrics:
  served / accepted_total / total_articles / duplicates + acceptance & duplicate
  rates + review_reason, alongside last success/attempt, next poll, failures.
* Admin endpoints (gated, 404 on missing): POST sources/{id}/active (pause/
  resume) and /review (flag/clear with reason).
* Pausing only stops future polling — the feed query has no active filter, so a
  paused source's accepted articles stay live.
* Frontend: metric table + Paused filter + per-row Pause/Resume & Flag/Clear
  (optimistic, revert on failure). Attention 'resting' now scoped to active.

Retire/Delete intentionally deferred (distinct lifecycle state, later).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-08 14:04:40 -04:00
parent f90324c5a6
commit 84bc5b0267
5 changed files with 160 additions and 25 deletions
+42
View File
@@ -55,3 +55,45 @@ def test_admin_stats_shape(tmp_path, monkeypatch):
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"])
def test_source_actions_pause_resume_flag(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
tc = _signin(app, api, "boss@x.com")
# pause
assert tc.post("/api/admin/sources/1/active", json={"active": False}).json()["active"] is False
st = tc.get("/api/admin/stats").json()
src = next(s for s in st["sources"] if s["id"] == 1)
assert src["active"] == 0 # paused source still listed (manageable)
# resume
tc.post("/api/admin/sources/1/active", json={"active": True})
# flag with reason, then clear
tc.post("/api/admin/sources/1/review", json={"flag": True, "reason": "spammy lately"})
src = next(s for s in tc.get("/api/admin/stats").json()["sources"] if s["id"] == 1)
assert src["review_flag"] == 1 and src["review_reason"] == "spammy lately"
tc.post("/api/admin/sources/1/review", json={"flag": False})
src = next(s for s in tc.get("/api/admin/stats").json()["sources"] if s["id"] == 1)
assert src["review_flag"] == 0 and src["review_reason"] is None
# 404 on missing
assert tc.post("/api/admin/sources/999/active", json={"active": False}).status_code == 404
def test_source_actions_gated(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
anon = TestClient(app)
assert anon.post("/api/admin/sources/1/active", json={"active": False}).status_code == 401
assert anon.post("/api/admin/sources/1/review", json={"flag": True}).status_code == 401
def test_source_health_includes_metrics(tmp_path, monkeypatch):
import sqlite3
from goodnews import queries
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
import os
c = sqlite3.connect(os.environ["GOODNEWS_DB"]); c.row_factory = sqlite3.Row
sh = queries.source_health(c)
s = sh[0]
for key in ("active", "served", "accepted_total", "total_articles", "duplicates",
"acceptance_rate", "duplicate_rate", "review_reason", "next_due_at"):
assert key in s, f"missing {key}"
assert s["served"] == 1 and s["acceptance_rate"] == 100