Organizational memberships: OC org contributors (type ORGANIZATION) never expose an email, so all three automated paths missed them (webhook email lookup, nightly sweep, /join). Add: members.org_slug link column + fetch_org_backers() (org BACKER/Membership nodes); sweep keeps linked accounts active while the org's dues are active and deactivates on lapse (fail-safe keeps active if the org fetch errors); webhook re-activates linked accounts on org dues and emails the operator for unlinked orgs; POST /admin/org-link (link + full provision incl. Cloudron signup link) and GET /admin/org-links (live dues status); admin overview gains an 'org' field.
This commit is contained in:
1 parent
5425efee1f
commit
72614f8f19
1 file changed
+213
-2
+213
-2
@@ -483,6 +483,14 @@ def get_db() -> sqlite3.Connection:
|
|||||||
# exhausted within a cycle.
|
# exhausted within a cycle.
|
||||||
if "credit_balance" not in cols:
|
if "credit_balance" not in cols:
|
||||||
conn.execute("ALTER TABLE members ADD COLUMN credit_balance REAL NOT NULL DEFAULT 0")
|
conn.execute("ALTER TABLE members ADD COLUMN credit_balance REAL NOT NULL DEFAULT 0")
|
||||||
|
# Migration: org_slug — for ORGANIZATION-backed memberships. OC orgs never
|
||||||
|
# expose an email to our integration, so the org's designated contact is
|
||||||
|
# linked manually (POST /admin/org-link): org_slug holds the OC org slug
|
||||||
|
# whose Membership-tier dues keep this account active. The nightly sweep
|
||||||
|
# keeps the account active while the org is a BACKER on the Membership
|
||||||
|
# tier and deactivates it when the org's dues lapse.
|
||||||
|
if "org_slug" not in cols:
|
||||||
|
conn.execute("ALTER TABLE members ADD COLUMN org_slug TEXT")
|
||||||
# Credit ledger: audit trail for every credit movement (purchases via the
|
# Credit ledger: audit trail for every credit movement (purchases via the
|
||||||
# OC webhook, admin adjustments, spends drawn against credits). Money needs
|
# OC webhook, admin adjustments, spends drawn against credits). Money needs
|
||||||
# an audit trail.
|
# an audit trail.
|
||||||
@@ -688,6 +696,52 @@ async def fetch_members() -> list[dict]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_org_backers() -> dict[str, dict]:
|
||||||
|
"""Organization-backed memberships on the Membership tier.
|
||||||
|
|
||||||
|
OC Organizations never expose an email to our integration (the
|
||||||
|
`... on Individual { email }` fragment doesn't match them, and OC does not
|
||||||
|
return org emails even to collective admins), so these members are LINKED
|
||||||
|
to portal accounts via members.org_slug (POST /admin/org-link). Keyed by
|
||||||
|
org slug: {slug: {name, role, tier}}.
|
||||||
|
"""
|
||||||
|
if not OC_PERSONAL_TOKEN:
|
||||||
|
return {}
|
||||||
|
query = (
|
||||||
|
'{ collective(slug: "%s") { members(limit: 100) { nodes { role tier { name } account { name slug type } } } } }'
|
||||||
|
% OC_COLLECTIVE_SLUG
|
||||||
|
)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
r = await client.post(
|
||||||
|
OC_GRAPHQL_URL,
|
||||||
|
headers={"Personal-Token": OC_PERSONAL_TOKEN},
|
||||||
|
json={"query": query},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.warning("OC org-member fetch failed: %s", r.status_code)
|
||||||
|
return {}
|
||||||
|
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
|
||||||
|
result: dict[str, dict] = {}
|
||||||
|
for n in nodes:
|
||||||
|
role = n.get("role", "")
|
||||||
|
acct = n.get("account", {}) or {}
|
||||||
|
if (acct.get("type") or "") != "ORGANIZATION":
|
||||||
|
continue
|
||||||
|
if role not in ("BACKER", "ADMIN"):
|
||||||
|
continue
|
||||||
|
tier = ((n.get("tier") or {}).get("name") or "").strip()
|
||||||
|
if role != "ADMIN" and "membership" not in tier.lower() and tier != "":
|
||||||
|
continue
|
||||||
|
slug = acct.get("slug") or ""
|
||||||
|
if slug:
|
||||||
|
result[slug] = {
|
||||||
|
"name": acct.get("name") or slug,
|
||||||
|
"role": role,
|
||||||
|
"tier": tier,
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def send_email(to: str, subject: str, text_body: str, html_body: str | None = None) -> bool:
|
def send_email(to: str, subject: str, text_body: str, html_body: str | None = None) -> bool:
|
||||||
"""Send an email via Cloudron's sendmail addon (SMTP).
|
"""Send an email via Cloudron's sendmail addon (SMTP).
|
||||||
|
|
||||||
@@ -1935,6 +1989,48 @@ async def opencollective_webhook(request: Request, token: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Webhook: failed to pin reset date for %s: %s",
|
logger.warning("Webhook: failed to pin reset date for %s: %s",
|
||||||
member["email"], e)
|
member["email"], e)
|
||||||
|
elif slug:
|
||||||
|
# Organization-backed membership: OC orgs never expose an email
|
||||||
|
# to our integration, so fetch_members can't see them. Linked
|
||||||
|
# orgs (members.org_slug) get their account re-activated on
|
||||||
|
# dues; unlinked orgs ping the operator to link them.
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT email, name FROM members WHERE org_slug = ?", (slug,)
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if row:
|
||||||
|
org_email, org_name = row
|
||||||
|
conn = get_db()
|
||||||
|
conn.execute("UPDATE members SET active = 1 WHERE email = ?", (org_email,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info("Webhook: org dues for %s — linked account %s re-activated",
|
||||||
|
slug, org_email)
|
||||||
|
if first_payment:
|
||||||
|
try:
|
||||||
|
await provision_member(org_email, org_name, slug)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Webhook: org re-provision failed for %s: %s",
|
||||||
|
org_email, e)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Webhook: Membership payment from ORGANIZATION %s (%s) — "
|
||||||
|
"no linked account; add via POST /admin/org-link",
|
||||||
|
name, slug,
|
||||||
|
)
|
||||||
|
send_email(
|
||||||
|
"info@inference.coop",
|
||||||
|
"Action needed: organizational membership without a linked account",
|
||||||
|
f"Open Collective reports a Membership-tier payment from the\n"
|
||||||
|
f"organization \"{name}\" (slug: {slug}).\n\n"
|
||||||
|
"Organizations don't expose an email to our integration, so the\n"
|
||||||
|
"membership needs to be linked to a portal account manually:\n\n"
|
||||||
|
" POST /admin/org-link\n"
|
||||||
|
" {\"org_slug\": \"" + slug + "\", \"email\": \"<their preferred email>\"}\n\n"
|
||||||
|
"Until linked, the organization cannot onboard.\n\n"
|
||||||
|
"— The portal",
|
||||||
|
)
|
||||||
elif slug and first_payment and event_type == "order.processed":
|
elif slug and first_payment and event_type == "order.processed":
|
||||||
# One-time donation (no tier or non-membership tier): NOT a
|
# One-time donation (no tier or non-membership tier): NOT a
|
||||||
# membership. Log it so we can see it, but don't provision.
|
# membership. Log it so we can see it, but don't provision.
|
||||||
@@ -2249,6 +2345,87 @@ async def admin_provision(request: Request):
|
|||||||
return {"status": "provisioned", "email": email, "user_id": user_id}
|
return {"status": "provisioned", "email": email, "user_id": user_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/org-link")
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def admin_org_link(request: Request):
|
||||||
|
"""Link an OC organizational membership to a portal account.
|
||||||
|
|
||||||
|
OC Organizations never expose an email to our integration, so org-backed
|
||||||
|
memberships are linked manually: body {"org_slug": "...", "email": "...",
|
||||||
|
"name": "..."}. Creates (or reuses) the members row for the email with
|
||||||
|
org_slug set, and provisions the full pipeline — Cloudron user, LiteLLM
|
||||||
|
key, and the welcome email containing the Cloudron account-setup link, so
|
||||||
|
the organization's contact can complete signup. The nightly sweep keeps
|
||||||
|
the account active while the org holds the Membership tier and deactivates
|
||||||
|
it when the org's dues lapse (dues monitoring, automatic).
|
||||||
|
"""
|
||||||
|
_verify_admin_token(request)
|
||||||
|
|
||||||
|
body = await request.json()
|
||||||
|
org_slug = (body.get("org_slug") or "").strip().lower()
|
||||||
|
email = (body.get("email") or "").strip().lower()
|
||||||
|
name = (body.get("name") or org_slug).strip()
|
||||||
|
if not org_slug or not email or "@" not in email:
|
||||||
|
raise HTTPException(400, "org_slug and a valid email are required")
|
||||||
|
|
||||||
|
org = (await fetch_org_backers()).get(org_slug)
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
existing = conn.execute("SELECT email FROM members WHERE org_slug = ?", (org_slug,)).fetchone()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO members (email, name, slug, active, org_slug) VALUES (?, ?, ?, 1, ?) "
|
||||||
|
"ON CONFLICT(email) DO UPDATE SET org_slug = excluded.org_slug, active = 1",
|
||||||
|
(email, name, org_slug, org_slug),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
provisioned = False
|
||||||
|
if org: # only provision while the org's membership is active
|
||||||
|
try:
|
||||||
|
await provision_member(email, name, org_slug)
|
||||||
|
provisioned = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("org-link: provision failed for %s: %s", email, e)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "linked",
|
||||||
|
"org_slug": org_slug,
|
||||||
|
"email": email,
|
||||||
|
"org_active": org is not None,
|
||||||
|
"tier": (org or {}).get("tier"),
|
||||||
|
"provisioned": provisioned,
|
||||||
|
"replaced_existing_link": bool(existing),
|
||||||
|
"note": "the nightly sweep deactivates this account automatically if the org's dues lapse",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/admin/org-links")
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def admin_org_links(request: Request):
|
||||||
|
"""List organization-linked accounts with their live OC dues status."""
|
||||||
|
_verify_admin_token(request)
|
||||||
|
|
||||||
|
org_backers = await fetch_org_backers()
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT email, name, org_slug, active FROM members WHERE org_slug IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
links = []
|
||||||
|
for email, name, org_slug, active in rows:
|
||||||
|
org = org_backers.get(org_slug)
|
||||||
|
links.append({
|
||||||
|
"org_slug": org_slug,
|
||||||
|
"org_name": (org or {}).get("name") or name,
|
||||||
|
"email": email,
|
||||||
|
"account_active": bool(active),
|
||||||
|
"dues_active": org is not None,
|
||||||
|
"tier": (org or {}).get("tier"),
|
||||||
|
})
|
||||||
|
return {"links": links}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/admin/set-balance")
|
@app.post("/admin/set-balance")
|
||||||
@limiter.limit("20/minute")
|
@limiter.limit("20/minute")
|
||||||
async def admin_set_balance(request: Request):
|
async def admin_set_balance(request: Request):
|
||||||
@@ -2402,7 +2579,7 @@ async def admin_overview(request: Request):
|
|||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT email, active, balance, cloudron_user_id FROM members ORDER BY email"
|
"SELECT email, active, balance, cloudron_user_id, org_slug FROM members ORDER BY email"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -2468,7 +2645,7 @@ async def admin_overview(request: Request):
|
|||||||
admins = []
|
admins = []
|
||||||
total_spend = 0.0
|
total_spend = 0.0
|
||||||
total_all_time = 0.0
|
total_all_time = 0.0
|
||||||
for email, active, balance, user_id in rows:
|
for email, active, balance, user_id, org_slug in rows:
|
||||||
team = teams.get(f"member:{email}", {})
|
team = teams.get(f"member:{email}", {})
|
||||||
spend = float(team.get("spend") or 0.0)
|
spend = float(team.get("spend") or 0.0)
|
||||||
member_all_time = float(all_time_spend.get(f"member:{email}", 0.0))
|
member_all_time = float(all_time_spend.get(f"member:{email}", 0.0))
|
||||||
@@ -2491,6 +2668,7 @@ async def admin_overview(request: Request):
|
|||||||
"activated": invite_accepted.get(user_id, False),
|
"activated": invite_accepted.get(user_id, False),
|
||||||
"active": bool(active),
|
"active": bool(active),
|
||||||
"manual": email.lower() in MANUAL_MEMBERS,
|
"manual": email.lower() in MANUAL_MEMBERS,
|
||||||
|
"org": org_slug or None,
|
||||||
"balance": float(balance) if balance is not None else MEMBER_BUDGET,
|
"balance": float(balance) if balance is not None else MEMBER_BUDGET,
|
||||||
"credit_balance": credits,
|
"credit_balance": credits,
|
||||||
"credits_remaining": max(0.0, credits - eff["credits_used"]),
|
"credits_remaining": max(0.0, credits - eff["credits_used"]),
|
||||||
@@ -2959,6 +3137,39 @@ async def reconcile_memberships() -> dict:
|
|||||||
provisioned_count = 0
|
provisioned_count = 0
|
||||||
deactivated_count = 0
|
deactivated_count = 0
|
||||||
|
|
||||||
|
# 0. Organization-backed memberships: dues paid by an OC Organization.
|
||||||
|
# Linked accounts (members.org_slug) stay active while their org holds a
|
||||||
|
# BACKER/ADMIN role on the Membership tier; lapsed orgs deactivate like
|
||||||
|
# individuals. Fail-safe: if the org fetch fails, keep linked accounts
|
||||||
|
# active (never deactivate on our own fetch failure).
|
||||||
|
try:
|
||||||
|
org_backers = await fetch_org_backers()
|
||||||
|
org_fetch_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Sweep: org fetch failed (keeping linked accounts active): %s", e)
|
||||||
|
org_backers = {}
|
||||||
|
org_fetch_ok = False
|
||||||
|
if org_backers or not org_fetch_ok:
|
||||||
|
conn = get_db()
|
||||||
|
org_rows = conn.execute(
|
||||||
|
"SELECT email, name, org_slug FROM members WHERE org_slug IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
for org_email, org_name, org_slug in org_rows:
|
||||||
|
org = org_backers.get(org_slug)
|
||||||
|
if org_fetch_ok and not org:
|
||||||
|
continue # org lapsed → falls through to deactivation below
|
||||||
|
if org_email not in active_emails:
|
||||||
|
active.append({
|
||||||
|
"email": org_email,
|
||||||
|
"name": org_name or (org["name"] if org else org_slug),
|
||||||
|
"slug": org_slug,
|
||||||
|
"role": (org or {}).get("role", "BACKER"),
|
||||||
|
"tier": ((org or {}).get("tier", "") or "") + " (org)",
|
||||||
|
})
|
||||||
|
active_emails.add(org_email)
|
||||||
|
logger.info("Sweep: org-linked member %s active via org %s", org_email, org_slug)
|
||||||
|
|
||||||
# 1. Provision missing members.
|
# 1. Provision missing members.
|
||||||
for m in active:
|
for m in active:
|
||||||
email = m["email"]
|
email = m["email"]
|
||||||
|
|||||||
Reference in new issue
Block a user