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
|
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)
|
and removes anyone not in the list. Operator accounts (LOOMIO_ALWAYS_KEEP)
|
||||||
are always included so they are never removed.
|
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:
|
if not LOOMIO_API_KEY or not LOOMIO_GROUP_ID:
|
||||||
logger.warning("Loomio not configured; skipping membership sync")
|
logger.warning("Loomio not configured; skipping membership sync")
|
||||||
@@ -987,6 +995,12 @@ async def sync_loomio_memberships() -> None:
|
|||||||
if keep not in emails:
|
if keep not in emails:
|
||||||
emails.append(keep)
|
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:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.post(
|
r = await client.post(
|
||||||
f"{LOOMIO_BASE}/api/b2/memberships",
|
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)
|
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:
|
def store_member(email: str, key_token: str, cloudron_user_id: str, slug: str = "") -> None:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
# Preserve an existing slug if the caller passed none — re-provisioning an
|
# Preserve an existing slug if the caller passed none — re-provisioning an
|
||||||
|
|||||||
Reference in new issue
Block a user