§22 S6: request-to-join + cross-collection inbox (§22.8) — v0.46.0
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>
This commit is contained in:
@@ -27,6 +27,7 @@ from . import (
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_invitations,
|
||||
api_join_requests,
|
||||
api_memberships,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
@@ -158,6 +159,10 @@ def make_router(
|
||||
# §22 S4 (C.2): the scope-role invitation surface — Owners grant
|
||||
# {owner, contributor} at project/collection scope to existing accounts.
|
||||
router.include_router(api_memberships.make_router())
|
||||
# §22.8 S6: request-to-join + the cross-collection inbox — a user asks into a
|
||||
# scope (naming a role); the scope's Owners across the subtree accept (writing
|
||||
# the membership row) or decline.
|
||||
router.include_router(api_join_requests.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
|
||||
@@ -57,6 +57,14 @@ def _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]:
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -97,6 +105,13 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
||||
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
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
|
||||
A gated project or collection is invisible to non-members (§22.5), so joining is
|
||||
by invite (an Owner grants directly — `api_memberships.py`) *or* by request: a
|
||||
user who knows a scope exists asks to join it, naming a desired role. This module
|
||||
is the request side:
|
||||
|
||||
* ``GET /api/scopes/{scope_type}/{scope_id}/join-target`` — what the join
|
||||
form needs (the scope's name, the viewer's eligibility + whether they already
|
||||
have a pending ask + their current role).
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests`` — submit the ask
|
||||
(desired role + optional message); lands a row + one §15 notification per
|
||||
Owner across the scope's subtree (the cross-collection inbox, §22.11).
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/accept`` —
|
||||
Owner: accept, which writes the `memberships` row via ``memberships.grant``
|
||||
(the §22.8 "accepting writes the membership row"), then notifies the requester.
|
||||
* ``POST /api/scopes/{scope_type}/{scope_id}/join-requests/{id}/decline`` —
|
||||
Owner: decline; the request closes and the requester is notified.
|
||||
|
||||
Mirrors ``api_contributions.py`` (the per-RFC contribute-request flow) but at the
|
||||
scope grain: the target is a ``(scope_type, scope_id)`` pair drawn from the
|
||||
``memberships`` scope vocabulary (minus ``global`` — a deployment isn't a thing
|
||||
one discovers and joins), and accept grants a scope role rather than minting an
|
||||
RFC invitation.
|
||||
|
||||
The request POST deliberately does **not** require the scope be *readable*: the
|
||||
whole point of request-to-join is to ask into a *gated* scope you were told about
|
||||
but cannot see (§22.8). It is gated only on "you're signed in, granted, and not
|
||||
already a member". Accept/decline are gated on Owner reach over the scope
|
||||
(``auth.can_invite_at_project`` / ``auth.can_invite_at_collection``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import (
|
||||
auth,
|
||||
collections as collections_mod,
|
||||
db,
|
||||
memberships as memberships_mod,
|
||||
notify,
|
||||
)
|
||||
|
||||
_MESSAGE_MAX = 4000
|
||||
|
||||
|
||||
class JoinRequestBody(BaseModel):
|
||||
role: str
|
||||
message: str | None = Field(default=None, max_length=_MESSAGE_MAX)
|
||||
|
||||
|
||||
class DecideBody(BaseModel):
|
||||
# On accept, the Owner may grant a role narrower than the one requested; a
|
||||
# missing value grants exactly the requested role.
|
||||
role: str | None = None
|
||||
|
||||
|
||||
def _project_name(project_id: str) -> str | None:
|
||||
row = db.conn().execute(
|
||||
"SELECT name FROM projects WHERE id = ?", (project_id,)
|
||||
).fetchone()
|
||||
return row["name"] if row and row["name"] else None
|
||||
|
||||
|
||||
def _resolve_scope(scope_type: str, scope_id: str) -> dict[str, Any]:
|
||||
"""Resolve a `(scope_type, scope_id)` target to its display facts, or 404 if
|
||||
it doesn't exist. Returns `{project_id, scope_name, project_name}`. The
|
||||
`scope_type` itself must be one of the join-able scopes."""
|
||||
if scope_type == "project":
|
||||
row = db.conn().execute(
|
||||
"SELECT id, name FROM projects WHERE id = ?", (scope_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
name = row["name"] or scope_id
|
||||
return {"project_id": scope_id, "scope_name": name, "project_name": name}
|
||||
if scope_type == "collection":
|
||||
col = collections_mod.get_collection(scope_id)
|
||||
if col is None:
|
||||
raise HTTPException(404, "Not found")
|
||||
pid = col["project_id"]
|
||||
return {
|
||||
"project_id": pid,
|
||||
"scope_name": col.get("name") or scope_id,
|
||||
"project_name": _project_name(pid),
|
||||
}
|
||||
raise HTTPException(404, "Not found")
|
||||
|
||||
|
||||
def _require_join_owner(viewer, scope_type: str, scope_id: str) -> None:
|
||||
"""The accept/decline gate: an Owner whose reach covers the scope (§22.8 'the
|
||||
scope's Owners across the subtree'). Reuses the S4 invite gates."""
|
||||
ok = (
|
||||
auth.can_invite_at_collection(viewer, scope_id)
|
||||
if scope_type == "collection"
|
||||
else auth.can_invite_at_project(viewer, scope_id)
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(403, "Only an Owner of this scope can act on join requests")
|
||||
|
||||
|
||||
def _require_request(scope_type: str, scope_id: str, request_id: int):
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, scope_type, scope_id, requester_user_id, requested_role,
|
||||
message, status
|
||||
FROM join_requests
|
||||
WHERE id = ? AND scope_type = ? AND scope_id = ?
|
||||
""",
|
||||
(request_id, scope_type, scope_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Join request not found")
|
||||
return row
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET — what the join form needs to render + gate itself.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/scopes/{scope_type}/{scope_id}/join-target")
|
||||
async def join_target(scope_type: str, scope_id: str, request: Request) -> dict[str, Any]:
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
viewer = auth.current_user(request)
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
already_requested = False
|
||||
current_role = auth.effective_role_at_scope(viewer, scope_type, scope_id)
|
||||
|
||||
if viewer is None:
|
||||
eligible, reason = False, "Sign in to request to join."
|
||||
elif viewer.permission_state != "granted":
|
||||
eligible, reason = False, "Your beta access request is in review."
|
||||
elif current_role is not None:
|
||||
eligible, reason = False, f"You already hold {('Owner' if current_role == 'owner' else 'RFC Contributor')} here."
|
||||
else:
|
||||
already_requested = bool(
|
||||
db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM join_requests
|
||||
WHERE scope_type = ? AND scope_id = ? AND requester_user_id = ?
|
||||
AND status = 'pending' LIMIT 1
|
||||
""",
|
||||
(scope_type, scope_id, viewer.user_id),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
return {
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"name": facts["scope_name"],
|
||||
"project_id": facts["project_id"],
|
||||
"eligible": eligible and not already_requested,
|
||||
"reason": reason,
|
||||
"already_requested": already_requested,
|
||||
"current_role": current_role,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — submit a request to join.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests")
|
||||
async def create_join_request(
|
||||
scope_type: str, scope_id: str, body: JoinRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
|
||||
role = (body.role or "").strip().lower()
|
||||
if role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
# Already a member of the scope (at this or a broader grain)? Then there
|
||||
# is nothing to request — a clear 409 rather than a useless self-request.
|
||||
if auth.effective_role_at_scope(viewer, scope_type, scope_id) is not None:
|
||||
raise HTTPException(409, "You already hold a role in this scope.")
|
||||
|
||||
message = (body.message or "").strip() or None
|
||||
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO join_requests
|
||||
(scope_type, scope_id, requester_user_id, requested_role, message)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(scope_type, scope_id, viewer.user_id, role, message),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# The partial unique index — one open request per (scope, user).
|
||||
raise HTTPException(409, "You already have a pending request to join this scope.")
|
||||
request_id = cur.lastrowid
|
||||
|
||||
# One actionable notification per Owner across the subtree; stamp the
|
||||
# first onto the row as the inbox-action handle (any Owner may act).
|
||||
notif_ids = notify.fan_out_join_request(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
project_id=facts["project_id"],
|
||||
project_name=facts["project_name"],
|
||||
requester_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
requested_role=role,
|
||||
message=message,
|
||||
)
|
||||
if notif_ids:
|
||||
db.conn().execute(
|
||||
"UPDATE join_requests SET notification_id = ? WHERE id = ?",
|
||||
(notif_ids[0], request_id),
|
||||
)
|
||||
|
||||
return {"id": request_id, "scope_type": scope_type, "scope_id": scope_id, "status": "pending"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — Owner accepts → write the membership row.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/accept")
|
||||
async def accept_join_request(
|
||||
scope_type: str, scope_id: str, request_id: int, body: DecideBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
_require_join_owner(viewer, scope_type, scope_id)
|
||||
|
||||
req = _require_request(scope_type, scope_id, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
# The Owner may narrow the requested role on accept; default to what was
|
||||
# asked for. (Both are within the Owner's grant reach at this scope.)
|
||||
granted_role = (body.role or req["requested_role"] or "").strip().lower()
|
||||
if granted_role not in memberships_mod.VALID_ROLES:
|
||||
raise HTTPException(422, f"invalid role {body.role!r}")
|
||||
|
||||
memberships_mod.grant(
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
user_id=req["requester_user_id"],
|
||||
role=granted_role,
|
||||
granted_by=viewer.user_id,
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE join_requests
|
||||
SET status = 'accepted', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?, granted_role = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, granted_role, request_id),
|
||||
)
|
||||
notify.notify_join_decided(
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
granted_role=granted_role,
|
||||
accepted=True,
|
||||
)
|
||||
return {"ok": True, "status": "accepted", "granted_role": granted_role}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — Owner declines.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/scopes/{scope_type}/{scope_id}/join-requests/{request_id}/decline")
|
||||
async def decline_join_request(
|
||||
scope_type: str, scope_id: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
facts = _resolve_scope(scope_type, scope_id)
|
||||
_require_join_owner(viewer, scope_type, scope_id)
|
||||
|
||||
req = _require_request(scope_type, scope_id, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE join_requests
|
||||
SET status = 'declined', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, request_id),
|
||||
)
|
||||
notify.notify_join_decided(
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_name=facts["scope_name"],
|
||||
granted_role=None,
|
||||
accepted=False,
|
||||
)
|
||||
return {"ok": True, "status": "declined"}
|
||||
|
||||
return router
|
||||
@@ -574,6 +574,42 @@ def effective_scope_role(user: SessionUser | None, collection_id: str) -> str |
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def _effective_project_role(user: SessionUser | None, project_id: str) -> str | None:
|
||||
"""The most-permissive role the user holds *over a project* — folding the
|
||||
global tier (deployment owner/admin, or a `scope_type='global'` grant) and a
|
||||
`scope_type='project'` grant on this project. Unlike `effective_scope_role`
|
||||
(which keys on a collection), this answers the project grain directly, for the
|
||||
§22.8 request-to-join membership check. Subject to the §6 admission floor."""
|
||||
if user is None or user.permission_state != "granted":
|
||||
return None
|
||||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||||
return "owner"
|
||||
row = db.conn().execute(
|
||||
"SELECT role FROM memberships "
|
||||
"WHERE user_id = ? AND ("
|
||||
" scope_type = 'global'"
|
||||
" OR (scope_type = 'project' AND scope_id = ?)) "
|
||||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||||
(user.user_id, project_id),
|
||||
).fetchone()
|
||||
return row["role"] if row else None
|
||||
|
||||
|
||||
def effective_role_at_scope(
|
||||
user: SessionUser | None, scope_type: str, scope_id: str
|
||||
) -> str | None:
|
||||
"""The most-permissive scope role the user holds over a `(scope_type,
|
||||
scope_id)` target — the scope-grain twin of `effective_scope_role`. A
|
||||
`collection` target folds global → project → collection (the existing
|
||||
resolver); a `project` target folds global → project. Returns None when no
|
||||
grant reaches the scope. Drives the §22.8 "already a member?" gate."""
|
||||
if scope_type == "collection":
|
||||
return effective_scope_role(user, scope_id)
|
||||
if scope_type == "project":
|
||||
return _effective_project_role(user, scope_id)
|
||||
return None
|
||||
|
||||
|
||||
def collection_visibility(collection_id: str) -> str:
|
||||
"""The collection's own §22.5 visibility. A missing row reads as 'gated' —
|
||||
an unknown collection is invisible rather than open."""
|
||||
|
||||
@@ -392,6 +392,95 @@ def notify_contribution_decided(
|
||||
)
|
||||
|
||||
|
||||
def fan_out_join_request(
|
||||
*,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
project_id: str | None,
|
||||
project_name: str | None,
|
||||
requester_user_id: int,
|
||||
request_id: int,
|
||||
requested_role: str,
|
||||
message: str | None,
|
||||
) -> list[int]:
|
||||
"""§22.8: a user asked to join a scope. Land one actionable notification per
|
||||
Owner across the scope's subtree (the cross-collection inbox, §22.11) and
|
||||
return their ids (the caller stamps the first onto the request row as the
|
||||
inbox-action handle — any of them can act on it).
|
||||
|
||||
Personal-direct: each Owner is a named subject able to act, so it rides the
|
||||
`email_personal_direct` gate like the other owner-facing personal events. The
|
||||
requested role + message ride in the payload so the inbox row shows the full
|
||||
ask inline. Actor is the requester per §15.9.
|
||||
"""
|
||||
requester = db.conn().execute(
|
||||
"SELECT display_name FROM users WHERE id = ?", (requester_user_id,)
|
||||
).fetchone()
|
||||
display = (requester["display_name"] if requester else None) or "Someone"
|
||||
details = {
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"project_id": project_id or "",
|
||||
"project_name": project_name or "",
|
||||
"requested_role": requested_role,
|
||||
"requester_user_id": requester_user_id,
|
||||
"requester_display": display,
|
||||
"message": message or "",
|
||||
}
|
||||
notif_ids: list[int] = []
|
||||
for recipient_id in _scope_owner_user_ids(scope_type, scope_id):
|
||||
if recipient_id == requester_user_id:
|
||||
continue
|
||||
notif_ids.append(
|
||||
_emit_one(
|
||||
recipient_user_id=recipient_id,
|
||||
event_kind="join_request_on_scope",
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=requester_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details=details,
|
||||
)
|
||||
)
|
||||
return notif_ids
|
||||
|
||||
|
||||
def notify_join_decided(
|
||||
*,
|
||||
requester_user_id: int,
|
||||
decider_user_id: int,
|
||||
request_id: int,
|
||||
scope_type: str,
|
||||
scope_id: str,
|
||||
scope_name: str | None,
|
||||
granted_role: str | None,
|
||||
accepted: bool,
|
||||
) -> None:
|
||||
"""§22.8: tell the requester an Owner accepted (writing their `memberships`
|
||||
row) or declined their request to join. The scope + granted role ride in the
|
||||
payload so the inbox row names where they were let in without a second fetch."""
|
||||
_emit_one(
|
||||
recipient_user_id=requester_user_id,
|
||||
event_kind=("join_request_accepted" if accepted else "join_request_declined"),
|
||||
category=CATEGORY_PERSONAL,
|
||||
actor_user_id=decider_user_id,
|
||||
rfc_slug=None,
|
||||
branch_name=None,
|
||||
pr_number=None,
|
||||
details={
|
||||
"request_id": request_id,
|
||||
"scope_type": scope_type,
|
||||
"scope_id": scope_id,
|
||||
"scope_name": scope_name or scope_id,
|
||||
"granted_role": granted_role or "",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
@@ -667,6 +756,41 @@ def _admin_user_ids() -> set[int]:
|
||||
}
|
||||
|
||||
|
||||
def _scope_owner_user_ids(scope_type: str, scope_id: str) -> set[int]:
|
||||
"""The Owners who administer a scope *across the subtree* (§22.8 / §22.11) —
|
||||
the recipients of a request-to-join, aggregated upward so the request reaches
|
||||
everyone who could grant it. For a `collection`: its collection-scope Owners,
|
||||
its project's Owners, the global Owners, and deployment owners/admins. For a
|
||||
`project`: its project-scope Owners plus global Owners and deployment
|
||||
owners/admins. (Mirrors the upward fold in `auth.can_invite_at_*`.)"""
|
||||
# Deployment owners/admins are global Owners by §B.1; explicit
|
||||
# scope_type='global' Owner grants join them.
|
||||
ids: set[int] = set(_admin_user_ids())
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships WHERE scope_type = 'global' AND role = 'owner'"
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
def _owners_at(stype: str, sid: str) -> None:
|
||||
for r in db.conn().execute(
|
||||
"SELECT user_id AS id FROM memberships "
|
||||
"WHERE scope_type = ? AND scope_id = ? AND role = 'owner'",
|
||||
(stype, sid),
|
||||
):
|
||||
ids.add(r["id"])
|
||||
|
||||
if scope_type == "collection":
|
||||
_owners_at("collection", scope_id)
|
||||
prow = db.conn().execute(
|
||||
"SELECT project_id FROM collections WHERE id = ?", (scope_id,)
|
||||
).fetchone()
|
||||
if prow and prow["project_id"]:
|
||||
_owners_at("project", prow["project_id"])
|
||||
elif scope_type == "project":
|
||||
_owners_at("project", scope_id)
|
||||
return ids
|
||||
|
||||
|
||||
def _proposer_user_id(rfc_slug: str) -> set[int]:
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
@@ -914,6 +1038,23 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str |
|
||||
if scope_type == "global":
|
||||
return f"{actor} granted you {role_label} across the whole deployment."
|
||||
return f"{actor} granted you {role_label} on project {project_label}."
|
||||
if event_kind == "join_request_on_scope":
|
||||
# §22.8: owner-facing, actionable. Names who wants in, where, and as
|
||||
# what; the inbox row renders Accept/Decline beneath this line.
|
||||
role_label = "Owner" if extras.get("requested_role") == "owner" else "RFC Contributor"
|
||||
scope_type = extras.get("scope_type")
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "a scope"
|
||||
where = (
|
||||
f"collection {scope_label}" if scope_type == "collection" else f"project {scope_label}"
|
||||
)
|
||||
return f"{actor} asked to join {where} as {role_label}."
|
||||
if event_kind == "join_request_accepted":
|
||||
role_label = "Owner" if extras.get("granted_role") == "owner" else "RFC Contributor"
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} accepted your request to join {scope_label} — you're in as {role_label}."
|
||||
if event_kind == "join_request_declined":
|
||||
scope_label = extras.get("scope_name") or extras.get("scope_id") or "the scope"
|
||||
return f"{actor} declined your request to join {scope_label}."
|
||||
if event_kind == "new_beta_request":
|
||||
# v0.9.0: framework-scoped, not RFC-scoped. The actor (the
|
||||
# requester) and the captured full name + email read as
|
||||
|
||||
Reference in New Issue
Block a user