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>
96 lines
2.7 KiB
Python
96 lines
2.7 KiB
Python
"""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
|