"""Notify the global IT asset admin (孟凡生) when a *regional* asset admin operates on an asset. Wiring — a single ``AuditService.log`` post-log hook (registered via :func:`register`, see ``app.audit.service.register_audit_log_hook``). Every asset-item custody/lifecycle mutation flows through ``AuditService.log(action="asset.")`` immediately before the router commit, so one hook there catches all of them — current and future endpoints alike — without editing the ~80 ``AuditService(db)`` call sites in ``asset/router.py``. The hook runs in the request's async context and transaction, so it may await queries and dispatch the notification atomically with the operation. Trigger — ALL must hold: * ``action`` ∈ :data:`_WATCHED_ACTIONS` (asset-item custody/lifecycle only; reference-data CRUD — brand / model / supplier / legal-entity / procurement / software-catalog — plus exports and employee self-service requests are intentionally excluded); * ``status == "success"`` (denied / failed rows never notify); * the actor is a *regional* asset admin: holds ``asset_admin`` and does NOT have global asset scope (``global_admin`` / ``asset_global_admin``). System / cron actors (``actor_user_id is None``) and global-scope actors are skipped, so the oversight admin is never pinged for their own actions or the platform's automation. Recipients — the configured oversight recipients (``asset.oversight_recipient_person_ids`` platform setting, a JSON list of person-id strings) when set, otherwise all active holders of the ``asset_global_admin`` role. The acting admin is always excluded, and recipients missing a ``work_email`` are dropped (they'd only spawn an outbox retry storm). The whole behaviour can be turned off with the ``asset.oversight_notify_enabled`` platform setting (default on). Message — for single-person dispatch ops (``asset.assign`` / ``asset.claim`` / ``asset.change_assignee``) the message describes the real-world event and its recipient ("将『X』派发给了 张三") rather than the button pressed; the assignee is resolved from the post-op ``AssetItem.assigned_to`` (falling back to the audit detail). All other lifecycle ops keep the generic "对『X』执行了操作:" phrasing. """ from __future__ import annotations import logging import uuid from typing import TYPE_CHECKING from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.rbac import ( ASSET_ADMIN_ROLE, ASSET_GLOBAL_ADMIN_ROLE, has_global_asset_scope, has_role, ) if TYPE_CHECKING: from app.audit.models import AuditLog from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) _ENABLED_SETTING = "asset.oversight_notify_enabled" _RECIPIENTS_SETTING = "asset.oversight_recipient_person_ids" _TEMPLATE_KEY = "asset.oversight.operation" # Asset-item custody / lifecycle operations a regional admin performs. # Deliberately excludes reference-data CRUD (brand / category / model / # location / supplier / legal_entity / procurement / software_catalog / # license_plan / depreciation_policy / office_address / business_payment), # exports, employee self-service requests, and the automatic # reserve-for-hire actions (system-driven, not a manual admin op). _WATCHED_ACTIONS = frozenset({ "asset.assign", "asset.unassign", "asset.transfer", "asset.change_assignee", "asset.claim", "asset.return", "asset.borrow", "asset.borrow_return", "asset.borrow_extension", "asset.repair", "asset.repair_complete", "asset.dispose", "asset.mark_held_by_leaver", "asset.recover_from_leaver", "asset.recycle_to_office", "asset.release_from_office", "asset.create", "asset.update", "asset.delete", "asset.restore", "asset.intake", "asset.bulk_borrow", "asset.bulk_claim", "asset.bulk_intake", "asset.bulk_recycle_to_office", "asset.bulk_restore", "asset.bulk_update", }) # Human-readable action labels (zh baseline, en for en/ja recipients). _ACTION_LABELS: dict[str, tuple[str, str]] = { "asset.assign": ("分配", "assigned"), "asset.unassign": ("解除分配", "unassigned"), "asset.transfer": ("转移", "transferred"), "asset.change_assignee": ("变更使用人", "reassigned"), "asset.claim": ("派发", "dispatched"), "asset.return": ("归还", "returned"), "asset.borrow": ("借用", "lent out"), "asset.borrow_return": ("借用归还", "returned from loan"), "asset.borrow_extension": ("借用延期", "extended a loan on"), "asset.repair": ("送修", "sent for repair"), "asset.repair_complete": ("维修完成", "completed repair on"), "asset.dispose": ("报废", "disposed"), "asset.mark_held_by_leaver": ("标记离职占用", "flagged as held by a leaver"), "asset.recover_from_leaver": ("离职回收", "recovered from a leaver"), "asset.recycle_to_office": ("回收至办公室", "recycled to office"), "asset.release_from_office": ("从办公室释放", "released from office"), "asset.create": ("新建", "created"), "asset.update": ("编辑", "updated"), "asset.delete": ("删除", "deleted"), "asset.restore": ("恢复", "restored"), "asset.intake": ("入库", "took into stock"), "asset.bulk_borrow": ("批量借用", "bulk-lent"), "asset.bulk_claim": ("批量派发", "bulk-dispatched"), "asset.bulk_intake": ("批量入库", "bulk intake of"), "asset.bulk_recycle_to_office": ("批量回收至办公室", "bulk-recycled to office"), "asset.bulk_restore": ("批量恢复", "bulk-restored"), "asset.bulk_update": ("批量编辑", "bulk-updated"), } # Actions that hand a *single* asset to a specific person. For these the # message describes the real-world event ("将『X』派发给了 张三") instead # of "对『X』执行了操作:派发" — 孟凡生's ask was to see *what was done and # to whom*, not which button was pressed. The verb pair is (zh, en); the # assignee is resolved from the post-op ``AssetItem.assigned_to`` (falling # back to the audit detail). Bulk claims have no single assignee, so they # stay on the generic phrasing. _ASSIGN_ACTIONS = frozenset({ "asset.assign", "asset.claim", "asset.change_assignee", }) _ASSIGN_VERBS: dict[str, tuple[str, str]] = { "asset.assign": ("派发", "assigned"), "asset.claim": ("派发", "dispatched"), "asset.change_assignee": ("改派", "reassigned"), } def register() -> None: """Attach the oversight notifier to the audit post-log hook chain. Idempotent — safe to call from both ``app.main`` and ``app.worker``. """ from app.audit.service import register_audit_log_hook register_audit_log_hook(_on_audit_log) def _is_en(person: IdentityPerson) -> bool: lang = (getattr(person, "preferred_language", None) or "").lower() return lang.startswith(("en", "ja")) def _coerce_id_list(raw: object) -> list[uuid.UUID]: """Normalise the recipients setting → list of UUIDs. Accepts a JSON list, a single string, or a comma-separated string; ignores anything that isn't a valid UUID. """ items: list[str] if raw is None: return [] if isinstance(raw, list): items = [str(x) for x in raw] elif isinstance(raw, str): items = [s for s in (p.strip() for p in raw.split(",")) if s] else: return [] out: list[uuid.UUID] = [] for s in items: try: out.append(uuid.UUID(s)) except (ValueError, AttributeError, TypeError): continue return out async def _actor_role_names(db: AsyncSession, actor_user_id: uuid.UUID) -> list[str]: from app.auth.models import AuthRole, AuthUserRole rows = ( await db.execute( select(AuthRole.name) .join(AuthUserRole, AuthUserRole.role_id == AuthRole.id) .where(AuthUserRole.user_id == actor_user_id) ) ).scalars().all() return list(rows) async def _resolve_recipients( db: AsyncSession, *, exclude_id: uuid.UUID ) -> list[IdentityPerson]: """Configured oversight recipient(s) if set, else all active ``asset_global_admin`` holders. Actor excluded; work_email required.""" from app.auth.models import AuthRole, AuthUserRole from app.identity.person_models import IdentityPerson from app.integrations.service import get_platform_setting configured = _coerce_id_list( await get_platform_setting(_RECIPIENTS_SETTING, None, session=db) ) if configured: rows = ( await db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(configured)) ) ).scalars().all() else: rows = ( await db.execute( select(IdentityPerson) .join(AuthUserRole, AuthUserRole.user_id == IdentityPerson.id) .join(AuthRole, AuthRole.id == AuthUserRole.role_id) .where(AuthRole.name == ASSET_GLOBAL_ADMIN_ROLE) .distinct() ) ).scalars().all() seen: set[uuid.UUID] = set() recipients: list[IdentityPerson] = [] for p in rows: if p.id == exclude_id or p.id in seen: continue if p.deleted_at is not None or p.employment_status != "active": continue if not p.work_email: continue seen.add(p.id) recipients.append(p) return recipients async def _on_audit_log(entry: AuditLog, db: AsyncSession) -> None: """Post-log hook: notify oversight admins on a regional-admin asset op.""" # Cheap early exits first — this runs on EVERY audit write platform-wide. if entry.action not in _WATCHED_ACTIONS: return if entry.status != "success": return if entry.actor_user_id is None: return from app.integrations.service import get_platform_setting # Kill switch — default on (unset → True). Any stored falsy JSON # value (``false`` / ``0``) disables the fan-out. enabled = await get_platform_setting(_ENABLED_SETTING, True, session=db) if not enabled: return actor_id = entry.actor_user_id roles = await _actor_role_names(db, actor_id) # Only regional asset admins: hold asset_admin, no global asset scope. if not has_role(roles, ASSET_ADMIN_ROLE) or has_global_asset_scope(roles): return recipients = await _resolve_recipients(db, exclude_id=actor_id) if not recipients: return from app.identity.person_models import IdentityPerson from app.notifications.service import ( NotificationEnvelope, NotificationService, ) actor = await db.get(IdentityPerson, actor_id) actor_name = ( (actor.display_name if actor else None) or (actor.work_email.split("@", 1)[0] if actor and actor.work_email else None) or "某资产管理员" ) # Resolve the operated asset for a human-readable label. resource_id # is the asset UUID for per-asset ops; bulk ops may not carry one. asset = None asset_uuid = None try: asset_uuid = uuid.UUID(entry.resource_id) if entry.resource_id else None except (ValueError, TypeError): asset_uuid = None if asset_uuid is not None: from app.domains.asset.models import AssetItem asset = await db.get(AssetItem, asset_uuid) asset_tag = ( (asset.asset_tag if asset else None) or (entry.detail or {}).get("asset_tag") ) asset_label = ( (asset.asset_name or asset.model or asset.asset_tag) if asset else None ) or asset_tag office = (asset.location if asset else None) or (entry.detail or {}).get("office") zh_action, en_action = _ACTION_LABELS.get( entry.action, (entry.action.removeprefix("asset."), entry.action.removeprefix("asset.")) ) # For dispatch/reassign ops, resolve *who the asset went to* so the # message reads "将『X』派发给了 张三". The post-op asset row is the # source of truth (assigned_to = the new holder); fall back to the # assignee id the router stamped into the audit detail. assignee_name: str | None = None if entry.action in _ASSIGN_ACTIONS: assignee_id: uuid.UUID | None = None if asset is not None and asset.assigned_to is not None: assignee_id = asset.assigned_to else: raw = (entry.detail or {}).get("to_user_id") or ( entry.detail or {} ).get("assigned_to") try: assignee_id = uuid.UUID(raw) if raw else None except (ValueError, TypeError): assignee_id = None if assignee_id is not None: assignee = await db.get(IdentityPerson, assignee_id) if assignee is not None: assignee_name = assignee.display_name or ( assignee.work_email.split("@", 1)[0] if assignee.work_email else None ) is_dispatch = entry.action in _ASSIGN_ACTIONS and bool(assignee_name) zh_verb, en_verb = _ASSIGN_VERBS.get(entry.action, ("派发", "dispatched")) notif = NotificationService(db) for person in recipients: if _is_en(person): what = f"『{asset_label}』" if asset_label else "an asset" if is_dispatch: title = f"[Asset dispatch] {actor_name} {en_verb} {what} to {assignee_name}" body_lines = [ f"Regional asset admin {actor_name} {en_verb} {what} to {assignee_name}." ] else: title = f"[Asset op] {actor_name} {en_action} {what}" body_lines = [ f"Regional asset admin {actor_name} {en_action} {what}." ] if asset_tag: body_lines.append(f"Asset tag: {asset_tag}") if office: body_lines.append(f"Location: {office}") else: what = f"『{asset_label}』" if asset_label else "某资产" if is_dispatch: title = f"[资产{zh_verb}] {actor_name} 将{what}{zh_verb}给 {assignee_name}" body_lines = [ f"各地资产管理员 {actor_name} 将{what}{zh_verb}给了 {assignee_name}。" ] else: title = f"[资产操作] {actor_name} {zh_action}了{what}" body_lines = [f"各地资产管理员 {actor_name} 对{what}执行了操作:{zh_action}。"] if asset_tag: body_lines.append(f"资产编号:{asset_tag}") if office: body_lines.append(f"所在地:{office}") env = NotificationEnvelope( recipient=person, title=title, body_text="\n".join(body_lines), channels=["feishu", "email"], # Non-templated key: keeps the raw title/body above and still # classifies into the actionable CAT_ASSETS bucket (red badge). template_key=_TEMPLATE_KEY, context={ "actor_name": actor_name, "action": entry.action, "asset_tag": asset_tag or "", "asset_label": asset_label or "", "office": office or "", "assignee_name": assignee_name or "", }, related_resource_type="asset", related_resource_id=asset_uuid, ) try: await notif.dispatch( env, trace_id=str(entry.trace_id), actor_user_id=actor_id, ) except Exception: # noqa: BLE001 # Outbox row already persisted; the retry worker recovers. # A notification hiccup must not roll back the asset operation. logger.exception( "failed to dispatch asset-oversight notification to %s", person.id, ) __all__ = ["register"]