"""§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 _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]: """§22 S4: the viewer's project-grain capabilities for role-aware UI — may they create a collection, may they manage membership (invite), and their project role. `role` maps the §22.6 legacy strings back to the unified `{owner, contributor}` vocabulary the frontend speaks.""" legacy = auth.project_member_role(viewer, project_id) role = None if viewer is not None and viewer.role in ("owner", "admin"): role = "owner" elif legacy == "project_admin": role = "owner" elif legacy == "project_contributor": role = "contributor" return { "can_create_collection": auth.can_create_collection(viewer, project_id), "can_invite": auth.can_invite_at_project(viewer, project_id), # §22.8: a signed-in, granted account with no role at the project may ask # to join it (the request-to-join affordance). Owners/members and # not-yet-granted accounts don't see it. "can_request_join": ( viewer is not None and viewer.permission_state == "granted" and auth.effective_role_at_scope(viewer, "project", project_id) is None ), "role": role, } 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) # §22.5 (S3): the directory is viewer-aware — a hidden/gated collection # is listed only for a scope-role holder who can read it; `unlisted` is # omitted from enumeration for everyone (link-only). items = [ c for c in collections_mod.list_collections(project_id, include_unlisted=True) if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"]) ] # §22 S4: surface the viewer's project-level capabilities so the # directory can render role-aware affordances (the create-first- # collection CTA, the invite control) without a second round-trip. return {"items": items, "viewer": _project_viewer_caps(viewer, 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") # §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer. auth.require_collection_readable(viewer, collection_id) # §22 S4: the viewer's collection-level capabilities drive the # propose-first empty state and the collection invite control. col = dict(col) col["viewer"] = { "can_contribute": auth.can_contribute_in_collection(viewer, collection_id), "can_invite": auth.can_invite_at_collection(viewer, collection_id), # §22.8: a signed-in, granted account with no role reaching this # collection may ask to join it. "can_request_join": ( viewer is not None and viewer.permission_state == "granted" and auth.effective_scope_role(viewer, collection_id) is None ), "role": auth.effective_scope_role(viewer, collection_id), } return col @router.post("/api/projects/{project_id}/collections") async def create_col( project_id: str, body: CreateCollectionBody, request: Request ) -> dict[str, Any]: # §B.1 (S3) authority: a deployment owner/admin or a project/global-scope # grant holder (Owner or RFC Contributor) may create a collection. The # read gate runs first so a gated project 404s a non-member. user = auth.require_contributor(request) auth.require_project_readable(user, project_id) if not auth.can_create_collection(user, project_id): raise HTTPException(403, "You may not create collections in this project") 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: if body.visibility not in registry_mod.VALID_VISIBILITY: raise HTTPException(422, f"invalid visibility {body.visibility!r}") # §22.5 (S3) strictness: a collection may be set only as strict or # stricter than its project — never more public. pvis = auth.project_visibility(project_id) if auth.visibility_rank(body.visibility) < auth.visibility_rank(pvis): raise HTTPException( 422, f"collection visibility {body.visibility!r} is looser than " f"the project's {pvis!r}; a collection may only narrow it", ) 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