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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user