§13: make graduation's RFC number optional; add retire (soft delete)
Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).
Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).
Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -829,6 +829,57 @@ def make_router(config: Config) -> APIRouter:
|
||||
items_blocked.append(payload)
|
||||
return {"ready": items_ready, "blocked": items_blocked}
|
||||
|
||||
# ----- §13.7: retired (soft-deleted) entries — site owners only -----
|
||||
|
||||
@router.get("/api/admin/retired-rfcs")
|
||||
async def retired_rfcs(request: Request) -> dict[str, Any]:
|
||||
# Retired entries are hidden from every browsing surface (§13.7);
|
||||
# this owner-gated list is how a site owner discovers them to
|
||||
# un-retire. Admins do not get this surface — un-retire authority is
|
||||
# site-owner-only, so neither is the list that feeds it.
|
||||
viewer = auth.require_admin(request)
|
||||
if viewer.role != "owner":
|
||||
raise HTTPException(403, "Site owner role required")
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT slug, title, rfc_id, owners_json, tags_json,
|
||||
proposed_at, updated_at
|
||||
FROM cached_rfcs
|
||||
WHERE state = 'retired'
|
||||
ORDER BY updated_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
# The state it would return to on un-retire, mirroring
|
||||
# api_graduation._prior_state_before_retire's audit lookup.
|
||||
prior = db.conn().execute(
|
||||
"""
|
||||
SELECT details FROM actions
|
||||
WHERE rfc_slug = ? AND action_kind = 'retire'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(r["slug"],),
|
||||
).fetchone()
|
||||
restored_state = None
|
||||
if prior and prior["details"]:
|
||||
try:
|
||||
restored_state = json.loads(prior["details"]).get("prior_state")
|
||||
except (ValueError, TypeError):
|
||||
restored_state = None
|
||||
if restored_state not in ("super-draft", "active"):
|
||||
restored_state = "active" if r["rfc_id"] else "super-draft"
|
||||
items.append({
|
||||
"slug": r["slug"],
|
||||
"title": r["title"],
|
||||
"id": r["rfc_id"],
|
||||
"owners": json.loads(r["owners_json"] or "[]"),
|
||||
"tags": json.loads(r["tags_json"] or "[]"),
|
||||
"retired_at": r["updated_at"],
|
||||
"restores_to": restored_state,
|
||||
})
|
||||
return {"items": items}
|
||||
|
||||
# ----- User search (typeahead for §15.8 mute add) -----
|
||||
|
||||
@router.get("/api/users/search")
|
||||
|
||||
Reference in New Issue
Block a user