Loomio sync: only invite activated members (avoid invite email before account setup)
This commit is contained in:
1 parent
38005127cb
commit
3d258e9a2d
1 file changed
+43
+43
@@ -976,6 +976,14 @@ async def sync_loomio_memberships() -> None:
|
||||
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.
|
||||
|
||||
Only members whose Cloudron account is already set up (inviteAccepted) are
|
||||
invited. Loomio's b2/memberships endpoint has no "silent invite" flag — it
|
||||
unconditionally emails a membership_created notification — so inviting a
|
||||
not-yet-activated member sends them a Loomio invite email before they've
|
||||
even created their account. Filtering to activated members prevents that;
|
||||
unactivated members are simply picked up on a later sync once they finish
|
||||
account setup.
|
||||
"""
|
||||
if not LOOMIO_API_KEY or not LOOMIO_GROUP_ID:
|
||||
logger.warning("Loomio not configured; skipping membership sync")
|
||||
@@ -987,6 +995,12 @@ async def sync_loomio_memberships() -> None:
|
||||
if keep not in emails:
|
||||
emails.append(keep)
|
||||
|
||||
# Only invite members who have completed Cloudron account setup. The
|
||||
# operator accounts (LOOMIO_ALWAYS_KEEP) bypass this filter — they must
|
||||
# stay in the group regardless of inviteAccepted (the bootstrap owner
|
||||
# account shows inviteAccepted: false forever even though it's functional).
|
||||
emails = await filter_loomio_activated(emails)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.post(
|
||||
f"{LOOMIO_BASE}/api/b2/memberships",
|
||||
@@ -1014,6 +1028,35 @@ async def sync_loomio_memberships() -> None:
|
||||
logger.warning("Loomio username fix pass failed: %s", e)
|
||||
|
||||
|
||||
async def filter_loomio_activated(emails: list[str]) -> list[str]:
|
||||
"""Return the subset of `emails` whose Cloudron account is set up, plus any
|
||||
operator accounts (LOOMIO_ALWAYS_KEEP), which must always pass through.
|
||||
|
||||
Fetches the Cloudron user list once and builds an inviteAccepted set keyed
|
||||
by email. Members who are provisioned but not yet activated are dropped so
|
||||
Loomio doesn't email them an invite before they've set up their account.
|
||||
"""
|
||||
activated: set[str] = set()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
r = await client.get(
|
||||
f"{CLOUDRON_API}/api/v1/users?per_page=100",
|
||||
headers=cloudron_headers(),
|
||||
)
|
||||
r.raise_for_status()
|
||||
for u in r.json().get("users", []):
|
||||
if u.get("inviteAccepted") and u.get("email"):
|
||||
activated.add(u["email"])
|
||||
except Exception as e:
|
||||
# If we can't determine activation state, fail open (invite everyone)
|
||||
# rather than silently dropping members from Loomio.
|
||||
logger.warning("filter_loomio_activated: cloudron users fetch failed: %s", e)
|
||||
return emails
|
||||
|
||||
keep = set(LOOMIO_ALWAYS_KEEP)
|
||||
return [e for e in emails if e in keep or e in activated]
|
||||
|
||||
|
||||
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
|
||||
conn = get_db()
|
||||
# Preserve an existing slug if the caller passed none — re-provisioning an
|
||||
|
||||
Reference in new issue
Block a user