Add Loomio membership sync (reconcile group to active OC members via /api/b2/memberships)
This commit is contained in:
1 parent
5c28176d30
commit
57991cd9f7
1 file changed
+71
+71
@@ -70,6 +70,19 @@ MEMBERS_GROUP_ID = os.environ.get("MEMBERS_GROUP_ID", "")
|
|||||||
# being deleted) so they lose access but can reclaim their data on reactivation.
|
# being deleted) so they lose access but can reclaim their data on reactivation.
|
||||||
INACTIVE_GROUP_ID = os.environ.get("INACTIVE_GROUP_ID", "")
|
INACTIVE_GROUP_ID = os.environ.get("INACTIVE_GROUP_ID", "")
|
||||||
|
|
||||||
|
# Loomio integration (forum.inference.coop). The portal reconciles the Loomio
|
||||||
|
# group to the current active-member list via the User API (/api/b2).
|
||||||
|
LOOMIO_BASE = os.environ.get("LOOMIO_BASE", "https://forum.inference.coop")
|
||||||
|
LOOMIO_API_KEY = os.environ.get("LOOMIO_API_KEY", "")
|
||||||
|
LOOMIO_GROUP_ID = os.environ.get("LOOMIO_GROUP_ID", "")
|
||||||
|
# Operator accounts that must always remain in the Loomio group (never removed
|
||||||
|
# by the remove_absent reconciliation), comma-separated.
|
||||||
|
LOOMIO_ALWAYS_KEEP = [
|
||||||
|
e.strip()
|
||||||
|
for e in os.environ.get("LOOMIO_ALWAYS_KEEP", "info@inference.coop,bot@inference.coop").split(",")
|
||||||
|
if e.strip()
|
||||||
|
]
|
||||||
|
|
||||||
# Persistent store (SQLite) for email → LiteLLM key token mapping.
|
# Persistent store (SQLite) for email → LiteLLM key token mapping.
|
||||||
# Lives in /app/data (Cloudron localstorage addon persists this).
|
# Lives in /app/data (Cloudron localstorage addon persists this).
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db")
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/members.db")
|
||||||
@@ -160,6 +173,62 @@ async def is_active_member(slug: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_member_emails() -> list[str]:
|
||||||
|
"""Return the list of active financial contributor emails from Open Collective."""
|
||||||
|
query = (
|
||||||
|
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email slug } } } } }'
|
||||||
|
% OC_COLLECTIVE_SLUG
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(OC_GRAPHQL_URL, json={"query": query})
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning("OC member list fetch failed: %s", r.status_code)
|
||||||
|
return []
|
||||||
|
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
|
||||||
|
emails = []
|
||||||
|
for n in nodes:
|
||||||
|
role = n.get("role", "")
|
||||||
|
acct = n.get("account", {}) or {}
|
||||||
|
email = acct.get("email")
|
||||||
|
if role in ("BACKER", "ADMIN") and email:
|
||||||
|
emails.append(email)
|
||||||
|
return emails
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_loomio_memberships() -> None:
|
||||||
|
"""Reconcile the Loomio group to the current active-member list.
|
||||||
|
|
||||||
|
Uses POST /api/b2/memberships with remove_absent=1, which adds new members
|
||||||
|
and removes anyone not in the list. Operator accounts (LOOMIO_ALWAYS_KEEP)
|
||||||
|
are always included so they are never removed.
|
||||||
|
"""
|
||||||
|
if not LOOMIO_API_KEY or not LOOMIO_GROUP_ID:
|
||||||
|
logger.warning("Loomio not configured; skipping membership sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
emails = await get_active_member_emails()
|
||||||
|
# Always keep operator accounts in the group.
|
||||||
|
for keep in LOOMIO_ALWAYS_KEEP:
|
||||||
|
if keep not in emails:
|
||||||
|
emails.append(keep)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
f"{LOOMIO_BASE}/api/b2/memberships",
|
||||||
|
headers={"Authorization": f"Bearer {LOOMIO_API_KEY}"},
|
||||||
|
json={"group_id": int(LOOMIO_GROUP_ID), "emails": emails, "remove_absent": 1},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning("Loomio membership sync failed: %s %s", r.status_code, r.text[:200])
|
||||||
|
return
|
||||||
|
result = r.json()
|
||||||
|
logger.info(
|
||||||
|
"Loomio sync: added=%s removed=%s",
|
||||||
|
result.get("added_emails", []),
|
||||||
|
result.get("removed_emails", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
|
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -400,6 +469,7 @@ async def opencollective_webhook(request: Request, token: str):
|
|||||||
await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID)
|
await cloudron_set_group(member["cloudron_user_id"], INACTIVE_GROUP_ID)
|
||||||
# Mark inactive in our DB (key injector will refuse requests)
|
# Mark inactive in our DB (key injector will refuse requests)
|
||||||
deactivate_member(member["email"])
|
deactivate_member(member["email"])
|
||||||
|
await sync_loomio_memberships()
|
||||||
logger.info("Deactivated member %s (slug=%s)", member["email"], slug)
|
logger.info("Deactivated member %s (slug=%s)", member["email"], slug)
|
||||||
return JSONResponse({"status": "deactivated", "email": member["email"]})
|
return JSONResponse({"status": "deactivated", "email": member["email"]})
|
||||||
return JSONResponse({"status": "deactivated", "note": "no matching member"})
|
return JSONResponse({"status": "deactivated", "note": "no matching member"})
|
||||||
@@ -591,6 +661,7 @@ async def oauth_callback(request: Request):
|
|||||||
key_token = await litellm_create_key(email, MEMBER_BUDGET)
|
key_token = await litellm_create_key(email, MEMBER_BUDGET)
|
||||||
store_member(email, key_token, user_id, slug)
|
store_member(email, key_token, user_id, slug)
|
||||||
await cloudron_send_invite(user_id, email)
|
await cloudron_send_invite(user_id, email)
|
||||||
|
await sync_loomio_memberships()
|
||||||
|
|
||||||
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
|
logger.info("Provisioned member %s (user_id=%s)", email, user_id)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user