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:
+32
-8
@@ -386,8 +386,12 @@ class FeedbackReplyBody(BaseModel):
|
||||
html: str = "" # raw editor HTML — sanitized server-side before use
|
||||
|
||||
|
||||
class SourceActiveBody(BaseModel):
|
||||
active: bool = True
|
||||
class SourceStatusBody(BaseModel):
|
||||
status: str = "active" # active | paused | retired
|
||||
|
||||
|
||||
class SourceVisibilityBody(BaseModel):
|
||||
visible: bool = True
|
||||
|
||||
|
||||
class SourceReviewBody(BaseModel):
|
||||
@@ -882,17 +886,37 @@ def create_app() -> FastAPI:
|
||||
).fetchone()
|
||||
return {"ok": True, "reply": dict(reply)}
|
||||
|
||||
@app.post("/api/admin/sources/{sid}/active")
|
||||
def admin_source_active(sid: int, body: SourceActiveBody, request: Request) -> dict:
|
||||
# Pause/resume a feed. Pausing only stops future polling — its already-
|
||||
# accepted articles stay live (the feed query doesn't filter on active).
|
||||
@app.post("/api/admin/sources/{sid}/status")
|
||||
def admin_source_status(sid: int, body: SourceStatusBody, request: Request) -> dict:
|
||||
# Lifecycle: active | paused | retired. Stops/resumes polling only —
|
||||
# existing articles stay visible (use /visibility to hide). `active` is
|
||||
# kept mirrored so the scheduler/CLI keep working off the legacy flag.
|
||||
if body.status not in ("active", "paused", "retired"):
|
||||
raise HTTPException(status_code=422, detail="invalid status")
|
||||
active = 1 if body.status == "active" else 0
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
cur = conn.execute("UPDATE sources SET active = ? WHERE id = ?", (1 if body.active else 0, sid))
|
||||
cur = conn.execute(
|
||||
"UPDATE sources SET status = ?, active = ? WHERE id = ?", (body.status, active, sid)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="source not found")
|
||||
conn.commit()
|
||||
return {"ok": True, "active": body.active}
|
||||
return {"ok": True, "status": body.status, "active": active}
|
||||
|
||||
@app.post("/api/admin/sources/{sid}/visibility")
|
||||
def admin_source_visibility(sid: int, body: SourceVisibilityBody, request: Request) -> dict:
|
||||
# Pull a source's existing articles out of (or back into) the public feed
|
||||
# and brief. Reversible; never deletes anything.
|
||||
with get_conn() as conn:
|
||||
_require_admin(conn, request)
|
||||
cur = conn.execute(
|
||||
"UPDATE sources SET content_visible = ? WHERE id = ?", (1 if body.visible else 0, sid)
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="source not found")
|
||||
conn.commit()
|
||||
return {"ok": True, "visible": body.visible}
|
||||
|
||||
@app.post("/api/admin/sources/{sid}/review")
|
||||
def admin_source_review(sid: int, body: SourceReviewBody, request: Request) -> dict:
|
||||
|
||||
@@ -139,6 +139,7 @@ def _candidate_articles(
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE s.accepted = 1
|
||||
AND src.content_visible = 1
|
||||
AND a.duplicate_of IS NULL
|
||||
AND date(COALESCE(a.published_at, a.discovered_at)) <= date(?)
|
||||
AND date(COALESCE(a.published_at, a.discovered_at)) > date(?, '-' || ? || ' days')
|
||||
|
||||
@@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS sources (
|
||||
trust_score INTEGER NOT NULL DEFAULT 5,
|
||||
pr_risk_score INTEGER NOT NULL DEFAULT 3,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
content_visible INTEGER NOT NULL DEFAULT 1,
|
||||
poll_interval_minutes INTEGER NOT NULL DEFAULT 60,
|
||||
notes TEXT,
|
||||
last_success_at TEXT,
|
||||
@@ -317,6 +319,14 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
||||
if column not in source_cols:
|
||||
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
|
||||
|
||||
# Lifecycle: status (active/paused/retired) + content_visible. `active` is
|
||||
# kept as a synced mirror so legacy code (scheduler/CLI) keeps working.
|
||||
if "status" not in source_cols:
|
||||
conn.execute("ALTER TABLE sources ADD COLUMN status TEXT NOT NULL DEFAULT 'active'")
|
||||
conn.execute("UPDATE sources SET status = CASE WHEN active = 1 THEN 'active' ELSE 'paused' END")
|
||||
if "content_visible" not in source_cols:
|
||||
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
# feedback.read_at (admin inbox read/unread) added later.
|
||||
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
||||
if fb_cols and "read_at" not in fb_cols:
|
||||
|
||||
+3
-2
@@ -72,7 +72,7 @@ def feed(
|
||||
items (e.g. 'discovery' for a Wonder lane) fall outside any over-fetch window.
|
||||
Word-boundary avoid-terms remain a Python pass on the caller side.
|
||||
"""
|
||||
clauses = ["a.duplicate_of IS NULL"]
|
||||
clauses = ["a.duplicate_of IS NULL", "src.content_visible = 1"]
|
||||
params: list = []
|
||||
if accepted_only:
|
||||
clauses.append("s.accepted = 1")
|
||||
@@ -157,7 +157,7 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
|
||||
JOIN articles a ON a.id = bi.article_id
|
||||
JOIN sources src ON src.id = a.source_id
|
||||
LEFT JOIN article_scores s ON s.article_id = a.id
|
||||
WHERE b.brief_date = ?
|
||||
WHERE b.brief_date = ? AND src.content_visible = 1
|
||||
ORDER BY bi.rank
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -295,6 +295,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
|
||||
"""
|
||||
SELECT
|
||||
s.id, s.name, s.default_category AS category, s.active,
|
||||
s.status, s.content_visible,
|
||||
s.consecutive_failures AS failures, s.review_flag, s.review_reason,
|
||||
s.poll_interval_minutes AS interval_minutes,
|
||||
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
|
||||
|
||||
Reference in New Issue
Block a user