§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:
@@ -0,0 +1,339 @@
|
||||
"""§22.8 S6 — request-to-join a scope + the cross-collection inbox.
|
||||
|
||||
A user who knows a (gated) scope exists asks to join it, naming a desired role;
|
||||
the request is recorded and fanned out to that scope's Owners *across the
|
||||
subtree* (the cross-collection inbox, §22.11). An Owner accepts — which writes
|
||||
the `memberships` row via memberships.grant — or declines, and the requester is
|
||||
§15-notified either way.
|
||||
|
||||
Built by analogy to test_contributions_vertical.py (the per-RFC contribute flow)
|
||||
and test_s4_invitations_vertical.py (the scope/membership world-builders).
|
||||
|
||||
World: project "ohm" owns collections "model" (document, gated) and "features"
|
||||
(bdd, gated). eve is project Owner; dan is collection Owner of features only;
|
||||
zoe is a global Owner; ada is a deployment admin. ben is a plain granted account
|
||||
(no scope role) — the would-be joiner.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# World-builders (mirror the S4 vertical)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _project(pid: str, visibility: str = "gated", content_repo: str = "meta") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, datetime('now'))",
|
||||
(pid, pid.capitalize(), content_repo, visibility),
|
||||
)
|
||||
|
||||
|
||||
def _collection(cid: str, project_id: str, *, ctype: str = "document",
|
||||
visibility: str = "gated") -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO collections "
|
||||
"(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))",
|
||||
(cid, project_id, ctype, cid, visibility, cid.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None:
|
||||
db.conn().execute(
|
||||
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, user_id, role),
|
||||
)
|
||||
|
||||
|
||||
def _membership(user_id: int):
|
||||
rows = db.conn().execute(
|
||||
"SELECT scope_type, scope_id, role FROM memberships WHERE user_id = ?",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return {(r["scope_type"], r["scope_id"], r["role"]) for r in rows}
|
||||
|
||||
|
||||
def _join_requests(scope_type: str, scope_id: str):
|
||||
rows = db.conn().execute(
|
||||
"SELECT id, requester_user_id, requested_role, status, granted_role "
|
||||
"FROM join_requests WHERE scope_type = ? AND scope_id = ?",
|
||||
(scope_type, scope_id),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _join_notif_recipients(event_kind: str = "join_request_on_scope") -> set[int]:
|
||||
return {
|
||||
r["recipient_user_id"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT recipient_user_id FROM notifications WHERE event_kind = ?",
|
||||
(event_kind,),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _seed_world() -> None:
|
||||
_project("ohm", "gated")
|
||||
_collection("model", "ohm", ctype="document")
|
||||
_collection("features", "ohm", ctype="bdd")
|
||||
provision_user_row(user_id=2, login="ben", role="contributor") # the joiner
|
||||
provision_user_row(user_id=4, login="dan", role="contributor") # collection Owner (features)
|
||||
provision_user_row(user_id=5, login="eve", role="contributor") # project Owner
|
||||
provision_user_row(user_id=6, login="zoe", role="contributor") # global Owner
|
||||
provision_user_row(user_id=7, login="ada", role="admin") # deployment admin
|
||||
_grant("project", "ohm", 5, "owner")
|
||||
_grant("collection", "features", 4, "owner")
|
||||
_grant("global", "*", 6, "owner")
|
||||
|
||||
|
||||
def _login(client, uid: int, login: str, role: str = "contributor") -> None:
|
||||
sign_in_as(client, user_id=uid, gitea_login=login, display_name=login.capitalize(), role=role)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request → cross-collection fan-out
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_request_to_join_collection_fans_out_to_subtree_owners(app_with_fake_gitea):
|
||||
"""A request to join a collection lands a row and notifies every Owner whose
|
||||
reach covers it — the collection's Owner, the project's Owner, a global
|
||||
Owner, and the deployment admin (the cross-collection inbox) — never the
|
||||
requester."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r = client.post(
|
||||
"/api/scopes/collection/features/join-requests",
|
||||
json={"role": "contributor", "message": "I work on BDD corpora."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["status"] == "pending"
|
||||
|
||||
reqs = _join_requests("collection", "features")
|
||||
assert len(reqs) == 1
|
||||
assert reqs[0]["requester_user_id"] == 2
|
||||
assert reqs[0]["requested_role"] == "contributor"
|
||||
assert reqs[0]["status"] == "pending"
|
||||
|
||||
# Owners across the subtree are notified; ben (requester) is not.
|
||||
recips = _join_notif_recipients()
|
||||
assert {4, 5, 6, 7}.issubset(recips) # dan, eve, zoe, ada
|
||||
assert 2 not in recips
|
||||
|
||||
|
||||
def test_request_to_join_project_reaches_project_and_global_owners(app_with_fake_gitea):
|
||||
"""A project-scope request reaches the project's Owners + global Owners +
|
||||
admin, but NOT a collection-only Owner (their reach doesn't cover the
|
||||
project)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r = client.post(
|
||||
"/api/scopes/project/ohm/join-requests",
|
||||
json={"role": "owner"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
recips = _join_notif_recipients()
|
||||
assert {5, 6, 7}.issubset(recips) # eve (project), zoe (global), ada (admin)
|
||||
assert 4 not in recips # dan is only a collection Owner
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Accept → writes membership + notifies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_owner_accept_writes_membership_and_notifies(app_with_fake_gitea):
|
||||
"""The collection Owner accepts; a `memberships` row is written at the
|
||||
requested scope/role and the requester gets a join_request_accepted inbox
|
||||
row."""
|
||||
from app import auth
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post(
|
||||
"/api/scopes/collection/features/join-requests",
|
||||
json={"role": "contributor"},
|
||||
)
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
# dan (collection Owner of features) accepts.
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["granted_role"] == "contributor"
|
||||
|
||||
# ben now holds the collection role and can contribute there.
|
||||
assert ("collection", "features", "contributor") in _membership(2)
|
||||
ben = auth.SessionUser(
|
||||
user_id=2, gitea_id=2, gitea_login="ben", display_name="Ben",
|
||||
email="ben@test", avatar_url="", role="contributor", permission_state="granted",
|
||||
)
|
||||
assert auth.can_contribute_in_collection(ben, "features") is True
|
||||
assert auth.can_contribute_in_collection(ben, "model") is False
|
||||
|
||||
# the row is closed; the requester is notified.
|
||||
assert _join_requests("collection", "features")[0]["status"] == "accepted"
|
||||
_login(client, 2, "ben")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
accepted = [n for n in inbox if n["event_kind"] == "join_request_accepted"]
|
||||
assert accepted, inbox
|
||||
assert "Features" in accepted[0]["summary"]
|
||||
|
||||
|
||||
def test_owner_may_narrow_role_on_accept(app_with_fake_gitea):
|
||||
"""A request for Owner may be accepted as RFC Contributor — the Owner narrows
|
||||
the grant; the membership row carries the granted (not requested) role."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "owner"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
_login(client, 5, "eve") # project Owner — reach covers the collection
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept",
|
||||
json={"role": "contributor"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert ("collection", "features", "contributor") in _membership(2)
|
||||
assert _join_requests("collection", "features")[0]["granted_role"] == "contributor"
|
||||
|
||||
|
||||
def test_owner_decline_notifies_and_grants_nothing(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(f"/api/scopes/collection/features/join-requests/{req_id}/decline")
|
||||
assert r.status_code == 200, r.text
|
||||
assert _membership(2) == set()
|
||||
assert _join_requests("collection", "features")[0]["status"] == "declined"
|
||||
|
||||
_login(client, 2, "ben")
|
||||
inbox = client.get("/api/notifications").json()["items"]
|
||||
assert any(n["event_kind"] == "join_request_declined" for n in inbox)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gates & guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_duplicate_pending_request_is_conflict(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
r1 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r2.status_code == 409, r2.text
|
||||
|
||||
|
||||
def test_existing_member_cannot_request(app_with_fake_gitea):
|
||||
"""dan already owns the collection — there is nothing to request (409)."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 4, "dan")
|
||||
r = client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
def test_non_owner_cannot_accept(app_with_fake_gitea):
|
||||
"""A plain requester (or any non-Owner) is refused the accept action."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "features")[0]["id"]
|
||||
# provision a second plain account that tries to accept
|
||||
provision_user_row(user_id=12, login="mal", role="contributor")
|
||||
_login(client, 12, "mal")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/features/join-requests/{req_id}/accept", json={}
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
assert _membership(2) == set()
|
||||
|
||||
|
||||
def test_collection_owner_cannot_act_on_sibling_collection(app_with_fake_gitea):
|
||||
"""dan owns 'features' only; a request to join 'model' is not his to act on."""
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
client.post("/api/scopes/collection/model/join-requests", json={"role": "contributor"})
|
||||
req_id = _join_requests("collection", "model")[0]["id"]
|
||||
_login(client, 4, "dan")
|
||||
r = client.post(
|
||||
f"/api/scopes/collection/model/join-requests/{req_id}/accept", json={}
|
||||
)
|
||||
assert r.status_code == 403, r.text
|
||||
|
||||
|
||||
def test_unknown_scope_404(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
_login(client, 2, "ben")
|
||||
assert client.post(
|
||||
"/api/scopes/collection/nope/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
assert client.post(
|
||||
"/api/scopes/project/nope/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
# 'global' is not a join-able scope_type.
|
||||
assert client.post(
|
||||
"/api/scopes/global/*/join-requests", json={"role": "contributor"}
|
||||
).status_code == 404
|
||||
|
||||
|
||||
def test_join_target_reports_eligibility(app_with_fake_gitea):
|
||||
app, _ = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_world()
|
||||
# ben: eligible (granted, no role).
|
||||
_login(client, 2, "ben")
|
||||
t = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t["eligible"] is True
|
||||
assert t["name"] == "Features"
|
||||
assert t["current_role"] is None
|
||||
# after requesting, already_requested flips and eligible drops.
|
||||
client.post("/api/scopes/collection/features/join-requests", json={"role": "contributor"})
|
||||
t2 = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t2["already_requested"] is True
|
||||
assert t2["eligible"] is False
|
||||
# dan: already a member → ineligible with current_role.
|
||||
_login(client, 4, "dan")
|
||||
t3 = client.get("/api/scopes/collection/features/join-target").json()
|
||||
assert t3["eligible"] is False
|
||||
assert t3["current_role"] == "owner"
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Migration 032 — the join_requests table (§22.8 S6).
|
||||
|
||||
Proves: the table exists with its CHECK constraints (scope_type ∈
|
||||
{project,collection}; role/status enums), the one-open-per-(scope,user) partial
|
||||
unique index holds, and a decided request frees a fresh ask.
|
||||
Template: test_migration_030_global_scope.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, path):
|
||||
self.database_path = path
|
||||
|
||||
|
||||
def _fresh_db():
|
||||
d = tempfile.mkdtemp()
|
||||
path = Path(d) / "t.db"
|
||||
db.run_migrations(_Cfg(str(path)))
|
||||
return db.connect(str(path))
|
||||
|
||||
|
||||
def _add_user(conn, uid, login):
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, gitea_id, gitea_login, display_name, role) "
|
||||
"VALUES (?, ?, ?, ?, 'contributor')",
|
||||
(uid, uid, login, login.capitalize()),
|
||||
)
|
||||
|
||||
|
||||
def _request(conn, scope_type="collection", scope_id="features", uid=1, role="contributor"):
|
||||
conn.execute(
|
||||
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(scope_type, scope_id, uid, role),
|
||||
)
|
||||
|
||||
|
||||
def test_join_request_row_round_trips():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
row = conn.execute("SELECT * FROM join_requests WHERE requester_user_id = 1").fetchone()
|
||||
assert row["scope_type"] == "collection"
|
||||
assert row["requested_role"] == "contributor"
|
||||
assert row["status"] == "pending"
|
||||
assert row["granted_role"] is None
|
||||
|
||||
|
||||
def test_global_scope_type_is_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn, scope_type="global", scope_id="*")
|
||||
|
||||
|
||||
def test_bad_role_and_status_rejected():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn, role="viewer")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO join_requests (scope_type, scope_id, requester_user_id, requested_role, status) "
|
||||
"VALUES ('project', 'ohm', 1, 'owner', 'maybe')"
|
||||
)
|
||||
|
||||
|
||||
def test_one_open_request_per_scope_user():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
_request(conn)
|
||||
|
||||
|
||||
def test_decided_request_frees_a_fresh_ask():
|
||||
conn = _fresh_db()
|
||||
_add_user(conn, 1, "ben")
|
||||
_request(conn)
|
||||
conn.execute("UPDATE join_requests SET status = 'declined' WHERE requester_user_id = 1")
|
||||
# a second open ask is now allowed
|
||||
_request(conn)
|
||||
n = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM join_requests WHERE requester_user_id = 1"
|
||||
).fetchone()["n"]
|
||||
assert n == 2
|
||||
Reference in New Issue
Block a user