"""One-time NexusCRM seat cleanup — deactivate all current seats. "把现在的在 NexusCRM 上所有坐席都删除干净" — Salesforce-style CRMs can't hard-delete a User, so this deactivates every active **real** seat (``IsActive=false``, reversible, frees the license). Salesforce platform / integration / synthetic accounts (the Connected App's own run-as user, Automated Process, Watchdog test users, migration placeholders) are ALWAYS excluded — deactivating them would break the connector or the platform. Requires the IT Connected App to have **write** scope (``api``). With a read-only (``api.read``) token the first deactivate returns INSUFFICIENT_SCOPE and the script aborts having changed nothing. Loads credentials from the seeded ``nexuscrm-primary`` integration row (run ``scripts/seed_nexuscrm_integration.py`` first). Usage (from server/ so the app package is importable): cd server # DRY RUN (default) — prints who WOULD be deactivated, changes nothing: CREDENTIAL_ENCRYPTION_KEY=... uv run python ../scripts/nexuscrm_wipe_seats.py # EXECUTE — actually deactivate: CREDENTIAL_ENCRYPTION_KEY=... uv run python ../scripts/nexuscrm_wipe_seats.py --execute """ from __future__ import annotations import asyncio import os import sys from pathlib import Path def _ensure_path() -> None: here = Path(__file__).resolve() server = here.parent.parent / "server" if server.exists() and str(server) not in sys.path: sys.path.insert(0, str(server)) _ensure_path() async def _main() -> None: execute = "--execute" in sys.argv slug = os.environ.get("HELIX_NEXUSCRM_SLUG", "nexuscrm-primary").strip() or "nexuscrm-primary" from app.core.database import async_session_factory from app.integrations.driver_base import ProvisioningContext async with async_session_factory() as session: # Build the driver straight from the seeded integration so creds + # instance_url come from one source of truth. from sqlalchemy import select from app.integrations.drivers.nexuscrm_software import ( NexusCrmSoftwareDriver, ) from app.integrations.models import Integration from app.integrations.service import _resolve row = (await session.execute( select(Integration).where(Integration.slug == slug) )).scalar_one_or_none() if row is None: print(f"error: integration slug={slug!r} not found; run " "seed_nexuscrm_integration.py first", file=sys.stderr) sys.exit(2) resolved = await _resolve(session, row) driver = NexusCrmSoftwareDriver( integration_id=str(row.id), config=resolved.config, ) members = await driver.list_full_members() real = [m for m in members if not m["is_system"]] skipped = [m for m in members if m["is_system"]] print(f"active users: {len(members)}") print(f" excluded (platform/integration/synthetic): {len(skipped)}") for m in skipped: print(f" SKIP {m['email']:<46} {m['display_name']!r}") print(f" real seats to deactivate: {len(real)}") for m in real: print(f" {'DEACTIVATE' if execute else 'WOULD'} " f"{m['email']:<46} {m['display_name']!r}") if not execute: print("\nDRY RUN — nothing changed. Re-run with --execute to deactivate.") return print("\nEXECUTING…") ok = 0 failed: list[str] = [] for i, m in enumerate(real, 1): email = m["email"] if not email: failed.append(f"{m['id']}: no email") continue res = await driver.revoke_seat(ProvisioningContext(user_email=email)) if res.ok: ok += 1 else: failed.append(f"{email}: {res.error_message}") # A read-only token fails on the FIRST write the same way for # every row — abort early so we don't hammer 131 useless calls. # The driver surfaces the scope failure as a "read-only" message # (the INSUFFICIENT_SCOPE token lives only in raw_response), so # match on that text. if res.error_message and "read-only" in res.error_message.lower(): print(f" [{i}] {email}: write blocked (read-only token). " "Grant the Connected App 'api' write scope and retry.") break print(f" [{i}] {email}: FAILED — {res.error_message}") if i % 25 == 0: print(f" …{i}/{len(real)} processed ({ok} ok)") print(f"\ndone. deactivated={ok} failed={len(failed)}") for f in failed[:20]: print(f" FAIL {f}") if __name__ == "__main__": asyncio.run(_main())