"""Seed the ``zoom`` identity-sync target (account lifecycle). Revision ID: 428 Revises: 427 Create Date: 2026-07-24 What this migration does ------------------------ Idempotent seed of the ``zoom`` identity-sync target — the account lifecycle half of the Zoom integration ("give every in-scope employee a baseline Zoom account on hire, close it on leave"). This makes Zoom a first-class target in the sync center (``/admin/identity-sync``) alongside Feishu / NetSuite / AWS IdC / Navan / 汇联易, decoupled from the decommissioned Authing → Zoom SSO cascade. Reuses the existing ``zoom-primary`` Server-to-Server OAuth integration (same S2S app that backs the paid-seat flow) — no new integration row, no new credentials. Mapping is read from the ``zoom`` preset with an inline fallback so the migration never imports-then-raises. Seeded ``enabled=0`` on purpose: flipping it on triggers the first sweep, which would CREATE a baseline Basic account for every in-scope employee (~500 rows) in one pass. Basic accounts are free, but that is a visible bulk action — the admin enables it deliberately in the UI (and can narrow the filter first), rather than having a deploy auto-arm it. If the integration can't be resolved (fresh dev DB without Zoom configured) the row is still seeded with a NULL integration + ``enabled=0`` so a credential-less target never fires. ``on_offboard=deactivate`` (离职 = 停用, never delete) and ``write_mode=create_update`` (create-missing on hire, maintain in scope). Re-running on an already-seeded DB is a no-op (slug-uniqueness probe). """ from __future__ import annotations import json import logging import uuid import sqlalchemy as sa from alembic import op logger = logging.getLogger(__name__) revision = "428" down_revision = "427" branch_labels = None depends_on = None _TARGET_SLUG = "zoom-account-lifecycle" def _row_id(bind, table: str, where: str, params: dict) -> str | None: row = bind.execute( sa.text(f"SELECT id FROM {table} WHERE {where} LIMIT 1"), params, ).fetchone() return row[0] if row else None def _resolve_zoom_integration(bind) -> str | None: """Find the Zoom software_provisioning integration. Prefer an enabled row, but fall back to the seeded-but-disabled ``zoom-primary`` row so the target links to it and goes live the moment credentials are set + the integration is enabled.""" row = bind.execute( sa.text( "SELECT id FROM integrations " "WHERE kind = 'software_provisioning' AND provider = 'zoom' " "ORDER BY enabled DESC, is_primary DESC, created_at LIMIT 1" ) ).fetchone() return row[0] if row else None def _preset_seed() -> dict: """Read the live preset so the seeded config matches it.""" try: from app.domains.identity_sync.presets import get_preset p = get_preset("zoom") if p is None: raise RuntimeError("preset 'zoom' not found") return p.to_seed() except Exception as exc: # noqa: BLE001 — migration MUST NOT raise logger.warning( "migration 428: preset load failed (%r); using inline fallback", exc, ) return { "kind": "zoom", "write_mode": "create_update", "mapping_config": {"rules": [ {"source_template": "{work_email}", "transform": "lower", "target_field": "email"}, {"source_template": "{first_name}", "target_field": "first_name"}, {"source_template": "{last_name}", "target_field": "last_name"}, ]}, "filter_config": { "include_offboarded": False, "departments": [], "include_subtree": True, "countries": [], "company_codes": [], "employment_statuses": ["active", "on_leave"], "job_title_includes": [], "employee_no_pattern": r"^P\d+$", "person_ids": [], "mode": "and", }, "schedule_config": { "trigger": "cron", "cron_expr": "0 2 * * *", "timezone": "UTC", }, "runner_options": {"zoom_plan_type": "basic"}, } def upgrade() -> None: bind = op.get_bind() if _row_id(bind, "identity_sync_targets", "slug = :s", {"s": _TARGET_SLUG}): return # already seeded integ_id = _resolve_zoom_integration(bind) seed = _preset_seed() if integ_id is None: logger.warning( "migration 428: no zoom software_provisioning integration found " "— seeding Zoom lifecycle target with NULL integration; configure " "+ enable via admin UI (the zoom-primary connector must exist)." ) bind.execute( sa.text( "INSERT INTO identity_sync_targets " "(id, name, slug, description, kind, integration_id, " " enabled, on_offboard, write_mode, " " mapping_config, filter_config, schedule_config, runner_options, " " created_at, updated_at) " "VALUES " "(:id, :name, :slug, :desc, 'zoom', :integ, " " 0, 'deactivate', :write_mode, " " :mapping, :filter, :schedule, :runner_opts, NOW(), NOW())" ), { "id": str(uuid.uuid4()), "name": "Zoom Meetings 账号生命周期", "slug": _TARGET_SLUG, "desc": ( "入职给 in-scope 员工建基线 Basic 账号,离职停用(不删)。" "复用 zoom-primary S2S OAuth 连接器,与坐席申请流互补(永不" "改动现有档位)。取代已下线的 Authing → Zoom 供给链;登录仍" "走 Google SSO。seed enabled=0 —— 开启后首次全量会给存量在职" "员工批量建号,由管理员在 UI 里确认后开启。" ), "integ": integ_id, "write_mode": str(seed.get("write_mode") or "create_update"), "mapping": json.dumps(seed["mapping_config"], ensure_ascii=False), "filter": json.dumps(seed["filter_config"], ensure_ascii=False), "schedule": json.dumps(seed["schedule_config"], ensure_ascii=False), "runner_opts": json.dumps(seed["runner_options"], ensure_ascii=False), }, ) def downgrade() -> None: bind = op.get_bind() bind.execute( sa.text("DELETE FROM identity_sync_targets WHERE slug = :s"), {"s": _TARGET_SLUG}, )