Supervised source candidates: stage, list, promote, reject
- New source_candidates staging table (status suggested/quarantined/rejected/ promoted, preview_json snapshot) so untrusted/suggested feeds stay out of the real ingestion path until reviewed. - sources.py: save_candidate (re-preview never revives a curator's rejection), list_candidates, reject_candidate, promote_candidate (copies into sources, inactive by default — active on approval; never automatic). - CLI: suggest-source / list-candidates / promote-candidate / reject-candidate. - API: read-only GET /api/candidates (writes stay CLI-only — no unauthenticated public write surface yet). - Fix deprecated ElementTree truth-value test in _parse_rss. - Tests: candidate lifecycle (save/list/promote/reject, status preservation, name derivation) — 51 total. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
def load_sources(path: Path | str) -> list[dict]:
|
||||
@@ -53,3 +55,101 @@ def upsert_sources(conn: sqlite3.Connection, source_defs: list[dict]) -> int:
|
||||
conn.commit()
|
||||
return count
|
||||
|
||||
|
||||
# --- Supervised source candidates (staging before the real sources table) ----
|
||||
|
||||
|
||||
def save_candidate(
|
||||
conn: sqlite3.Connection,
|
||||
feed_url: str,
|
||||
preview: dict | None = None,
|
||||
name: str | None = None,
|
||||
homepage_url: str | None = None,
|
||||
status: str = "quarantined",
|
||||
notes: str | None = None,
|
||||
) -> sqlite3.Row:
|
||||
"""Stage a suggested feed (with an optional preview snapshot) for review.
|
||||
|
||||
Re-previewing an existing candidate refreshes its snapshot but never changes
|
||||
a status a curator already set (e.g. a rejected feed stays rejected).
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO source_candidates (
|
||||
feed_url, homepage_url, name, status, preview_json, notes, last_previewed_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(feed_url) DO UPDATE SET
|
||||
preview_json = excluded.preview_json,
|
||||
name = COALESCE(excluded.name, source_candidates.name),
|
||||
homepage_url = COALESCE(excluded.homepage_url, source_candidates.homepage_url),
|
||||
notes = COALESCE(excluded.notes, source_candidates.notes),
|
||||
last_previewed_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(feed_url, homepage_url, name, status, json.dumps(preview) if preview else None, notes),
|
||||
)
|
||||
conn.commit()
|
||||
return conn.execute("SELECT * FROM source_candidates WHERE feed_url = ?", (feed_url,)).fetchone()
|
||||
|
||||
|
||||
def list_candidates(conn: sqlite3.Connection, status: str | None = None) -> list[sqlite3.Row]:
|
||||
if status:
|
||||
return conn.execute(
|
||||
"SELECT * FROM source_candidates WHERE status = ? ORDER BY updated_at DESC", (status,)
|
||||
).fetchall()
|
||||
return conn.execute("SELECT * FROM source_candidates ORDER BY updated_at DESC").fetchall()
|
||||
|
||||
|
||||
def reject_candidate(conn: sqlite3.Connection, candidate_id: int) -> bool:
|
||||
cur = conn.execute(
|
||||
"UPDATE source_candidates SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(candidate_id,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
def promote_candidate(
|
||||
conn: sqlite3.Connection,
|
||||
candidate_id: int,
|
||||
active: bool = False,
|
||||
default_category: str | None = None,
|
||||
trust_score: int = 5,
|
||||
pr_risk_score: int = 3,
|
||||
poll_interval_minutes: int = 180,
|
||||
) -> int:
|
||||
"""Copy a reviewed candidate into the real sources table.
|
||||
|
||||
Inactive by default (active-on-approval): a promoted feed is wired up but
|
||||
won't be polled until explicitly activated. Never called automatically.
|
||||
"""
|
||||
cand = conn.execute("SELECT * FROM source_candidates WHERE id = ?", (candidate_id,)).fetchone()
|
||||
if cand is None:
|
||||
raise ValueError(f"no candidate with id {candidate_id}")
|
||||
|
||||
name = cand["name"] or urlsplit(cand["feed_url"]).netloc or cand["feed_url"]
|
||||
upsert_sources(
|
||||
conn,
|
||||
[
|
||||
{
|
||||
"name": name,
|
||||
"feed_url": cand["feed_url"],
|
||||
"homepage_url": cand["homepage_url"],
|
||||
"default_category": default_category,
|
||||
"trust_score": trust_score,
|
||||
"pr_risk_score": pr_risk_score,
|
||||
"active": active,
|
||||
"poll_interval_minutes": poll_interval_minutes,
|
||||
"notes": f"promoted from candidate {candidate_id}",
|
||||
}
|
||||
],
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE source_candidates SET status = 'promoted', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(candidate_id,),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT id FROM sources WHERE feed_url = ?", (cand["feed_url"],)).fetchone()
|
||||
return int(row["id"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user