Source Retire lifecycle (Phase 1: status + content_visible, active mirrored)
Per Codex's plan — introduce a lifecycle without a risky "change the source of
truth everywhere" moment.
* Schema: sources.status (active|paused|retired) + content_visible; migration
backfills status from active (active=1→active, else paused), content_visible=1.
* `active` is kept as a SYNCED MIRROR: status active→active=1, paused/retired→0,
so the scheduler/CLI/legacy code keep working unchanged.
* Retire stops polling but keeps articles visible (non-destructive). Hiding is a
separate, reversible lever: content_visible=0 drops a source's articles from
the public feed + brief (read AND build), behind a confirm. Personal saved/
history are untouched.
* API: /sources/{id}/status (validates, mirrors active) + /visibility, replacing
/active. source_health returns status + content_visible.
* Admin: status column (active/paused/retired + "hidden"), Retired filter,
Pause/Resume · Retire/Restore · Hide/Show actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+36
-19
@@ -57,31 +57,48 @@ def test_admin_stats_shape(tmp_path, monkeypatch):
|
||||
assert any(g["tag"] == "science" for g in stats["top_groupings"])
|
||||
|
||||
|
||||
def test_source_actions_pause_resume_flag(tmp_path, monkeypatch):
|
||||
def _src(tc, sid=1):
|
||||
return next(s for s in tc.get("/api/admin/stats").json()["sources"] if s["id"] == sid)
|
||||
|
||||
|
||||
def test_source_lifecycle_status_and_visibility(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
|
||||
# pause → status paused + active mirror 0
|
||||
assert tc.post("/api/admin/sources/1/status", json={"status": "paused"}).json()["active"] == 0
|
||||
assert _src(tc)["status"] == "paused" and _src(tc)["active"] == 0
|
||||
# retire → status retired, still active=0, articles stay visible
|
||||
tc.post("/api/admin/sources/1/status", json={"status": "retired"})
|
||||
assert _src(tc)["status"] == "retired" and _src(tc)["active"] == 0
|
||||
assert _src(tc)["content_visible"] == 1 # retire does NOT hide content
|
||||
# restore → active again, mirror 1
|
||||
tc.post("/api/admin/sources/1/status", json={"status": "active"})
|
||||
assert _src(tc)["status"] == "active" and _src(tc)["active"] == 1
|
||||
# hide content → feed excludes it; show → back
|
||||
tc.post("/api/admin/sources/1/visibility", json={"visible": False})
|
||||
assert _src(tc)["content_visible"] == 0
|
||||
from goodnews import queries
|
||||
import sqlite3, os
|
||||
c = sqlite3.connect(os.environ["GOODNEWS_DB"]); c.row_factory = sqlite3.Row
|
||||
assert queries.feed(c) == [] # hidden source's article drops out of the feed
|
||||
tc.post("/api/admin/sources/1/visibility", json={"visible": True})
|
||||
c2 = sqlite3.connect(os.environ["GOODNEWS_DB"]); c2.row_factory = sqlite3.Row
|
||||
assert len(queries.feed(c2)) == 1 # back in the feed
|
||||
# validation + 404
|
||||
assert tc.post("/api/admin/sources/1/status", json={"status": "bogus"}).status_code == 422
|
||||
assert tc.post("/api/admin/sources/999/status", json={"status": "paused"}).status_code == 404
|
||||
|
||||
|
||||
def test_source_actions_gated(tmp_path, monkeypatch):
|
||||
def test_source_flag_and_gating(tmp_path, monkeypatch):
|
||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
||||
tc = _signin(app, api, "boss@x.com")
|
||||
tc.post("/api/admin/sources/1/review", json={"flag": True, "reason": "spammy lately"})
|
||||
assert _src(tc)["review_flag"] == 1 and _src(tc)["review_reason"] == "spammy lately"
|
||||
tc.post("/api/admin/sources/1/review", json={"flag": False})
|
||||
assert _src(tc)["review_flag"] == 0 and _src(tc)["review_reason"] is None
|
||||
anon = TestClient(app)
|
||||
assert anon.post("/api/admin/sources/1/active", json={"active": False}).status_code == 401
|
||||
assert anon.post("/api/admin/sources/1/status", json={"status": "paused"}).status_code == 401
|
||||
assert anon.post("/api/admin/sources/1/visibility", json={"visible": False}).status_code == 401
|
||||
assert anon.post("/api/admin/sources/1/review", json={"flag": True}).status_code == 401
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user