"""§22 S2 — collection directory + create-collection. GET /api/projects/:id/collections — list the project's visible collections. GET /api/projects/:id/collections/:cid — one collection's settings. POST /api/projects/:id/collections — create a collection. Authorized by a deployment owner/admin (S2; scoped {owner, contributor} roles at the collection axis land in S3). The bot commits a `.collection.yaml` to the content repo, then the registry mirror upserts the collections row — §22.2 keeps the registry the source of truth. """ from __future__ import annotations import re from typing import Any import yaml from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel from . import ( auth, collections as collections_mod, projects as projects_mod, registry as registry_mod, ) from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError _SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") class CreateCollectionBody(BaseModel): collection_id: str type: str name: str | None = None visibility: str | None = None initial_state: str | None = None def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: router = APIRouter() @router.get("/api/projects/{project_id}/collections") async def list_cols(project_id: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) # §22.5 read gate: a gated project 404s a non-member. auth.require_project_readable(viewer, project_id) return {"items": collections_mod.list_collections(project_id)} @router.get("/api/projects/{project_id}/collections/{collection_id}") async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) auth.require_project_readable(viewer, project_id) col = collections_mod.get_collection(collection_id) if col is None or col["project_id"] != project_id: raise HTTPException(404, "Not found") return col @router.post("/api/projects/{project_id}/collections") async def create_col( project_id: str, body: CreateCollectionBody, request: Request ) -> dict[str, Any]: # S2 authority: deployment owner/admin (the scoped-role surface is S3). user = auth.require_admin(request) auth.require_project_readable(user, project_id) cid = body.collection_id.strip().lower() if not _SLUG_RE.match(cid) or cid == "default": raise HTTPException(422, "collection id must be a slug and not 'default'") if body.type not in registry_mod.VALID_TYPES: raise HTTPException(422, f"invalid type {body.type!r}") if body.visibility is not None and body.visibility not in registry_mod.VALID_VISIBILITY: raise HTTPException(422, f"invalid visibility {body.visibility!r}") if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE: raise HTTPException(422, f"invalid initial_state {body.initial_state!r}") if collections_mod.get_collection(cid) is not None: raise HTTPException(409, f"collection `{cid}` already exists") content_repo = projects_mod.content_repo(project_id) if not content_repo: raise HTTPException(409, "project has no content repo") manifest: dict[str, Any] = {"type": body.type} if body.name: manifest["name"] = body.name if body.visibility: manifest["visibility"] = body.visibility if body.initial_state: manifest["initial_state"] = body.initial_state manifest_yaml = yaml.safe_dump(manifest, sort_keys=False) try: await bot.create_collection( user.as_actor(), org=config.gitea_org, content_repo=content_repo, collection_id=cid, manifest_yaml=manifest_yaml, ) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") # §22.2: re-read the registry so the new manifest becomes a row. await registry_mod.refresh_registry(config, gitea) col = collections_mod.get_collection(cid) if col is None: raise HTTPException(500, "collection committed but not mirrored") return col return router