d687a65470
Direct-commit to the sidecar (D7), contributor+ gated (INV-4), schema-validated at the write boundary, lazy-migrates a legacy entry, re-ingests. GET RFC now returns the per-entry meta mapping and a can_edit_meta capability. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
3.5 KiB
Python
88 lines
3.5 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),
|
|
}
|
|
|
|
return router
|