Candidates: inline rename (fix a name typo without reject + re-add)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -293,6 +293,12 @@
|
|||||||
Object.assign(c, res, { _deep: false });
|
Object.assign(c, res, { _deep: false });
|
||||||
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._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) {
|
async function promoteCandidate(c) {
|
||||||
c._err = '';
|
c._err = '';
|
||||||
try {
|
try {
|
||||||
@@ -570,8 +576,16 @@
|
|||||||
{#each pendingCandidates as c (c.id)}
|
{#each pendingCandidates as c (c.id)}
|
||||||
<li>
|
<li>
|
||||||
<div class="chead">
|
<div class="chead">
|
||||||
<span class="cname">{c.name || c.feed_url}</span>
|
{#if c._editing}
|
||||||
<span class="cstatus">{c.status}</span>
|
<input class="crename" bind:value={c._editName} placeholder="Source name"
|
||||||
|
onkeydown={(e) => (e.key === 'Enter' ? saveRename(c) : e.key === 'Escape' ? (c._editing = false) : null)} />
|
||||||
|
<button class="act" onclick={() => saveRename(c)}>Save</button>
|
||||||
|
<button class="act" onclick={() => (c._editing = false)}>Cancel</button>
|
||||||
|
{:else}
|
||||||
|
<span class="cname">{c.name || c.feed_url}</span>
|
||||||
|
<button class="act mini" onclick={() => startRename(c)}>Rename</button>
|
||||||
|
<span class="cstatus">{c.status}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="curl">{c.feed_url}</div>
|
<div class="curl">{c.feed_url}</div>
|
||||||
{#if c.preview}
|
{#if c.preview}
|
||||||
@@ -1102,9 +1116,15 @@
|
|||||||
.addrow input:focus { outline: none; border-color: var(--accent); }
|
.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 { 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; }
|
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 .cname { font-weight: 600; color: var(--ink); }
|
||||||
.chead .cstatus { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
.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; }
|
.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 { 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; }
|
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
|
||||||
|
|||||||
@@ -444,6 +444,10 @@ class CandidatePromoteBody(BaseModel):
|
|||||||
poll_interval_minutes: int = 180
|
poll_interval_minutes: int = 180
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRenameBody(BaseModel):
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DigestBody(BaseModel):
|
class DigestBody(BaseModel):
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
@@ -1197,6 +1201,16 @@ def create_app() -> FastAPI:
|
|||||||
row = sources.save_candidate(conn, url, preview=preview)
|
row = sources.save_candidate(conn, url, preview=preview)
|
||||||
return _candidate_dict(row)
|
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")
|
@app.post("/api/admin/candidates/{cid}/promote")
|
||||||
def admin_candidate_promote(cid: int, body: CandidatePromoteBody, request: Request) -> dict:
|
def admin_candidate_promote(cid: int, body: CandidatePromoteBody, request: Request) -> dict:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
@@ -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()
|
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:
|
def reject_candidate(conn: sqlite3.Connection, candidate_id: int) -> bool:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"UPDATE source_candidates SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
"UPDATE source_candidates SET status = 'rejected', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
|||||||
@@ -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
|
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):
|
def test_candidate_reject_and_gating(tmp_path, monkeypatch):
|
||||||
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
|
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})
|
monkeypatch.setattr(api.feeds, "preview_feed", lambda url, **k: {"url": url, "sampled": 1, "accepted": 0})
|
||||||
|
|||||||
Reference in New Issue
Block a user