"""Network module user-facing notifications kill switch. A single boolean platform setting gates the three system-push cron handlers that target end users (domain expiry alert, DNS owner recertify, DNS pre-leaver handover). The handlers consult :func:`network_user_notifications_enabled` at entry and short-circuit with ``status="skipped"`` when the switch is ``false``. Default is ``false`` (seeded by migration 174) so that re-deploying into an unconfigured TiDB / fresh local MySQL ALSO keeps notifications silent until the operator deliberately flips the row. User-initiated approval flows (DNS request → Feishu approval, domain registration → Feishu approval) are NOT gated by this switch — those are the human approval path the user explicitly invoked and skipping them would break the feature, not just suppress noise. """ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession from app.integrations.models import PlatformSetting SETTING_KEY = "network.user_notifications_enabled" async def network_user_notifications_enabled(db: AsyncSession) -> bool: """Return ``True`` only when the operator has explicitly enabled network-module user-facing notifications. Safe default: ``False`` — a missing / malformed row keeps the three crons silent. The switch lives in ``platform_settings`` so an admin can toggle it via the Settings UI or direct SQL without a redeploy. """ row = await db.get(PlatformSetting, SETTING_KEY) if row is None: return False raw = row.value # ``PlatformSetting.value`` is a JSON column. ``is True`` is identity, # not equality — admins typing ``true`` (string) / ``1`` (int) through # the generic settings editor would silently leave the switch off # despite clicking enable. Accept the obvious truthy shapes too; # migration 174 seeds it as a JSON boolean so the happy path is # unchanged. if isinstance(raw, bool): return raw if isinstance(raw, str): return raw.strip().lower() in ("true", "1", "yes", "on") if isinstance(raw, (int, float)): return bool(raw) return False # ── 网络负责人 (network owner) ──────────────────────────────────────── # A single configurable person designated as the catch-all owner for # network resources (domains / DNS records / certificates) that have no # explicit ``owner_person_id``, and the fallback recipient for their # expiry alerts. Stored as a JSON string (the person id) in # ``platform_settings``; seeded best-effort to 孟凡生 by migration 266. # # This is deliberately a designated PERSON, not a new RBAC role: the # requirement is "one human is the network owner of last resort", which # a single setting models cleanly without rippling a 9th role through # the entire capability matrix. Editable via the admin Settings UI. NETWORK_OWNER_KEY = "network.owner_person_id" async def resolve_network_owner_id(db: AsyncSession) -> str | None: """Return the configured 网络负责人 person id, or ``None`` if unset / configured to a person who is gone or no longer active. Callers that need a fallback owner for an owner-less resource use this; when it returns ``None`` they must degrade gracefully (the admin inventory surfaces owner-less rows regardless), never silently drop the obligation. """ row = await db.get(PlatformSetting, NETWORK_OWNER_KEY) if row is None: return None raw = row.value person_id = raw.strip() if isinstance(raw, str) else None if not person_id: return None # Confirm the designated person still exists and is active — a # departed network owner must not keep receiving alerts. from app.identity.person_models import IdentityPerson person = await db.get(IdentityPerson, person_id) if person is None or person.employment_status != "active": return None return person_id