fcc3c84d76
Ships the request side of joining a gated scope, completing the §22.8 pair
(S4 shipped the invite half). A user who knows a project/collection exists
asks to join it naming a desired role; the request fans out to that scope's
Owners across the subtree (the cross-collection inbox, §22.11), who accept
(writing the memberships row via memberships.grant) or decline. Built by
analogy to §28 contribution_requests + the S4 memberships surface.
Backend
- migration 032: join_requests (scope_type ∈ {project,collection}, scope_id,
requester, requested_role, message, status, granted_role); one-open-per
(scope, requester) partial unique index. Additive — no rebuild.
- api_join_requests.py: GET join-target / POST join-requests / POST
{id}/accept / {id}/decline under /api/scopes/{scope_type}/{scope_id}/.
Accept grants via memberships.grant; the request POST does not require the
scope be readable (that is how one joins a gated scope).
- notify: fan_out_join_request (subtree-Owner enumeration via
_scope_owner_user_ids), notify_join_decided, 3 render_summary cases.
- auth.effective_role_at_scope — scope-grain twin of effective_scope_role,
folding global → project for a project target.
- api_collections: viewer.can_request_join on the project + collection blocks.
Frontend
- api.js join verbs; JoinRequestModal; "Request to join" affordance in the
collection directory + catalog footer; JoinRequestRow in the inbox.
Tests: backend test_join_requests_vertical (11) + test_migration_032 (5);
frontend api.joinrequests + CollectionDirectory cases. 546 backend / 36
frontend green.
Per docs/design/2026-06-05-three-tier-projects-collections.md Part E (S6) and
SPEC.md §22.8 / §22.11. Closes the request-to-join item flagged open at
0.45.0; per-type surfaces (§22.4a items 1 & 3) remain the last S6 item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
8.3 KiB
Python
183 lines
8.3 KiB
Python
"""§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
|