From 070b40584e32feeed851c3ccfcf1d3d309d24a43 Mon Sep 17 00:00:00 2001 From: jay Date: Thu, 11 Jun 2026 21:39:13 -0400 Subject: [PATCH] Candidates: inline rename (fix a name typo without reject + re-add) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A staged candidate could only be renamed by rejecting and re-adding it, which churns the queue and discards the preview just to fix a typo. Add an inline Rename on each candidate: a "Rename" pill swaps the name for an input (Enter saves ยท Esc cancels), POST /api/admin/candidates/{id}/rename โ†’ sources.rename_candidate(). Empty clears the name (promote then derives one from the feed host). Preview is preserved; the fixed name carries into promotion. Tests: test_candidate_rename (rename in place keeps preview, promotes with the new name, gated + 404). 225 pytest + 11 vitest green. Co-Authored-By: Claude Opus 4.8 --- frontend/src/routes/admin/+page.svelte | 26 +++++++++++++++++++++++--- goodnews/api.py | 14 ++++++++++++++ goodnews/sources.py | 13 +++++++++++++ tests/test_admin.py | 18 ++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index ef4f61e..a2acab4 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -293,6 +293,12 @@ Object.assign(c, res, { _deep: false }); } catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; } } + function startRename(c) { c._editName = c.name || ''; c._editing = true; c._err = ''; } + async function saveRename(c) { + try { + Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/rename`, { name: (c._editName || '').trim() }), { _editing: false }); + } catch (e) { c._err = e?.message || 'Rename failed.'; } + } async function promoteCandidate(c) { c._err = ''; try { @@ -570,8 +576,16 @@ {#each pendingCandidates as c (c.id)}
  • - {c.name || c.feed_url} - {c.status} + {#if c._editing} + (e.key === 'Enter' ? saveRename(c) : e.key === 'Escape' ? (c._editing = false) : null)} /> + + + {:else} + {c.name || c.feed_url} + + {c.status} + {/if}
    {c.feed_url}
    {#if c.preview} @@ -1102,9 +1116,15 @@ .addrow input:focus { outline: none; border-color: var(--accent); } ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } - .chead { display: flex; align-items: baseline; gap: 10px; } + .chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } .chead .cname { font-weight: 600; color: var(--ink); } .chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; } + .chead .crename { font: inherit; font-size: 0.9rem; font-weight: 600; padding: 4px 9px; + border: 1px solid var(--accent); border-radius: 8px; background: var(--bg); color: var(--ink); } + .chead .crename:focus { outline: none; } + .act.mini { font-size: 0.72rem; padding: 2px 8px; color: var(--accent-deep); + background: none; border: 1px solid var(--line); border-radius: 999px; cursor: pointer; } + .act.mini:hover { border-color: var(--accent); } .curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; } .cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; } .cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; } diff --git a/goodnews/api.py b/goodnews/api.py index 96eca76..a72add4 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -444,6 +444,10 @@ class CandidatePromoteBody(BaseModel): poll_interval_minutes: int = 180 +class CandidateRenameBody(BaseModel): + name: str = "" + + class DigestBody(BaseModel): enabled: bool = True @@ -1197,6 +1201,16 @@ def create_app() -> FastAPI: row = sources.save_candidate(conn, url, preview=preview) return _candidate_dict(row) + @app.post("/api/admin/candidates/{cid}/rename") + def admin_candidate_rename(cid: int, body: CandidateRenameBody, request: Request) -> dict: + with get_conn() as conn: + _require_admin(conn, request) + try: + row = sources.rename_candidate(conn, cid, (body.name or "").strip() or None) + except ValueError: + raise HTTPException(status_code=404, detail="candidate not found") + return _candidate_dict(row) + @app.post("/api/admin/candidates/{cid}/promote") def admin_candidate_promote(cid: int, body: CandidatePromoteBody, request: Request) -> dict: with get_conn() as conn: diff --git a/goodnews/sources.py b/goodnews/sources.py index adbdb88..13f1511 100644 --- a/goodnews/sources.py +++ b/goodnews/sources.py @@ -153,6 +153,19 @@ def list_candidates(conn: sqlite3.Connection, status: str | None = None) -> list return conn.execute("SELECT * FROM source_candidates ORDER BY updated_at DESC").fetchall() +def rename_candidate(conn: sqlite3.Connection, candidate_id: int, name: str | None) -> sqlite3.Row: + """Fix a staged candidate's display name without re-fetching it. An empty + name clears it (promote then derives one from the feed host).""" + cur = conn.execute( + "UPDATE source_candidates SET name = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (name or None, candidate_id), + ) + conn.commit() + if cur.rowcount == 0: + raise ValueError(f"no candidate with id {candidate_id}") + return conn.execute("SELECT * FROM source_candidates WHERE id = ?", (candidate_id,)).fetchone() + + 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 = ?", diff --git a/tests/test_admin.py b/tests/test_admin.py index 545230c..aea1fe7 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -169,6 +169,24 @@ def test_candidate_deep_preview_and_dedup(tmp_path, monkeypatch): assert tc.post(f"/api/admin/candidates/{cand['id']}/promote", json={}).status_code == 409 +def test_candidate_rename(tmp_path, monkeypatch): + app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") + monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 3, "accepted": 2}) + tc = _signin(app, api, "boss@x.com") + cand = tc.post("/api/admin/candidates", json={"feed_url": "http://site/feed", "name": "Wrong Naem"}).json() + cid = cand["id"] + # Fix the typo in place โ€” no reject/re-add, preview is preserved. + fixed = tc.post(f"/api/admin/candidates/{cid}/rename", json={"name": "Right Name"}).json() + assert fixed["name"] == "Right Name" and fixed["preview"]["accepted"] == 2 + assert any(c["id"] == cid and c["name"] == "Right Name" for c in tc.get("/api/admin/candidates").json()) + # The fixed name carries through to promotion. + res = tc.post(f"/api/admin/candidates/{cid}/promote", json={}).json() + assert res["source"]["name"] == "Right Name" + # Gated; unknown id โ†’ 404. + assert TestClient(app).post(f"/api/admin/candidates/{cid}/rename", json={"name": "x"}).status_code == 401 + assert tc.post("/api/admin/candidates/99999/rename", json={"name": "x"}).status_code == 404 + + def test_candidate_reject_and_gating(tmp_path, monkeypatch): app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com") monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 1, "accepted": 0})