Files
rfc-app/backend/app/api_metadata.py
T
Ben Stull 46c957cff5 feat(slice4): Owner-gated collection migrate endpoint (§22.4a PUC-5)
POST .../collections/{cid}/migrate — now safe to ship since all write paths
are sidecar-aware. Idempotent, one commit per collection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:01:18 -07:00

113 lines
4.8 KiB
Python

"""§22.4a SLICE-4/5 — entry metadata edit endpoints.
`POST .../rfcs/<slug>/meta` writes schema-defined metadata to an entry's sidecar
with a direct commit (D7: direct commit for authorized roles), validated against
the collection's field schema at the write boundary (INV-4), lazy-migrating a
legacy entry to a clean body-only `.md` on first edit. The Owner-gated
`metadata.migrate_collection` operator endpoint also lives here (SLICE-4 carried
work); SLICE-5's bulk endpoint will join it.
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from . import (auth, cache, collections as collections_mod,
metadata as metadata_mod, metadata_schema,
projects as projects_mod)
from .bot import Bot
from .config import Config
from .gitea import Gitea, GiteaError
class MetaEditBody(BaseModel):
values: dict[str, Any]
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
router = APIRouter()
def _content_repo() -> tuple[str, str]:
return config.gitea_org, (projects_mod.default_content_repo(config) or "")
def _md_path(collection_id: str, slug: str) -> str:
sub = collections_mod.subfolder_of(collection_id) or ""
rfcs_dir = f"{sub}/rfcs" if sub else "rfcs"
return f"{rfcs_dir}/{slug}.md"
@router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}/meta")
async def edit_meta(
project_id: str, collection_id: str, slug: str,
body: MetaEditBody, request: Request,
) -> dict[str, Any]:
viewer = auth.current_user(request)
if collections_mod.project_of_collection(collection_id) != project_id:
raise HTTPException(404, "Collection not in project")
# INV-4: contributor+ on the collection (returns False for anonymous).
if not auth.can_contribute_in_collection(viewer, collection_id):
raise HTTPException(403, "Contributor access required to edit metadata")
col = collections_mod.get_collection(collection_id)
fields = (col or {}).get("fields") or {}
if not fields:
raise HTTPException(422, "Collection declares no editable fields")
if not body.values:
raise HTTPException(422, "Provide at least one field value")
unknown = [k for k in body.values if k not in fields]
if unknown:
raise HTTPException(422, f"Unknown field(s): {', '.join(sorted(unknown))}")
org, repo = _content_repo()
md_path = _md_path(collection_id, slug)
st = await metadata_mod.read_entry_from_git(gitea, org, repo, md_path)
if st is None:
raise HTTPException(404, f"{md_path} not found")
new_entry = metadata_mod.apply_values(st.entry, body.values)
problems = metadata_schema.validate(
metadata_mod.metadata_dict(new_entry), fields)
if problems:
raise HTTPException(422, {"problems": [p.as_dict() for p in problems]})
files = metadata_mod.write_entry_files(md_path, new_entry, st)
try:
await bot.commit_entry_files(
viewer.as_actor(), org=org, repo=repo, files=files,
message=f"Edit metadata: {slug}", branch="main")
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
await cache.refresh_meta_repo(config, gitea)
return {
"ok": True, "slug": slug,
"meta": metadata_mod.metadata_dict(new_entry),
}
@router.post("/api/projects/{project_id}/collections/{collection_id}/migrate")
async def migrate(
project_id: str, collection_id: str, request: Request
) -> dict[str, Any]:
"""§22.4a PUC-5: migrate a collection's legacy-frontmatter entries to
clean body-only `.md` + sidecars, one commit per collection. Owner-gated
operator action. Safe to ship now that every entry write path is
sidecar-aware (SLICE-4 carried work). Idempotent."""
viewer = auth.current_user(request)
if collections_mod.project_of_collection(collection_id) != project_id:
raise HTTPException(404, "Collection not in project")
if not auth.is_collection_superuser(viewer, collection_id):
raise HTTPException(403, "Owner access required to migrate a collection")
org, repo = _content_repo()
subfolder = collections_mod.subfolder_of(collection_id) or ""
try:
result = await metadata_mod.migrate_collection(
gitea, org=org, repo=repo, subfolder=subfolder,
actor=viewer.as_actor())
except GiteaError as e:
raise HTTPException(502, f"Gitea: {e.detail}")
if result["committed"]:
await cache.refresh_meta_repo(config, gitea)
return result
return router