"""催办 (urge / expedite) — let an applicant OR an admin nudge the people who can move a still-open request forward. Design (user-approved 2026-06-15): * Channel: in-app (the dispatch always writes a ``notification_outbox`` row, which the in-app inbox reads) + Feishu card, via the existing :class:`NotificationService` — no new outbound integration. * Rate limit: at most ONE urge per request per 24h, regardless of who urges. Enforced with a Redis key + TTL (no new table / migration — the 24h window is a textbook TTL use case, and Redis is already a first-class dependency for rate limiting + Feishu state). * Recipients: ONLY the people the request is actually waiting on right now — never the whole approval chain. When the request is in a live Feishu approval we fetch the instance's ``task_list`` and notify just the approvers whose task is currently ``PENDING`` / ``TRANSFERRED`` (resolved Feishu open_id/user_id → work_email → Person). Falling back to the requester's manager + IT admin queue owners (``global_admin``) happens ONLY when there's no Feishu flow, or Feishu can't tell us who is pending (deleted approver / transient hiccup) — i.e. when "who acts next" is otherwise unknowable. The applicant and the actor are never notified (you don't nudge yourself or the person already waiting). * Audited: every urge writes an ``me.request.urge`` audit row. Open-only: a terminal request (completed / rejected / cancelled) can't be urged — the core returns ``urged=False`` with a reason the frontend localises rather than raising, so the button can show a friendly toast. """ from __future__ import annotations from datetime import UTC, datetime from uuid import UUID, uuid4 from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.approval.feishu_client import FeishuApprovalClient from app.audit.service import AuditService from app.core.redis_client import get_redis from app.domains.me_universe.my_requests import SRC_CLOUD_GRANT from app.domains.me_universe.request_detail import ( _DETAIL_BUILDERS, UnifiedRequestDetail, _parse_item_id, build_my_request_detail, ) from app.identity.person_models import IdentityPerson from app.integrations.compat import get_feishu_config from app.notifications.service import ( LocalizedText, NotificationEnvelope, NotificationService, ) from app.scheduled_jobs.failure_notifier import list_admin_recipients URGE_TTL_SECONDS = 24 * 60 * 60 # Markdown-significant chars that let user-controlled text (request title, # names) smuggle a clickable link / emphasis into the Feishu card body. # NotificationService only neutralises ``<``/``>`` — strip the rest so a # request titled ``[紧急·点此](https://evil)`` can't render as a live link # in the manager / IT card. _MD_STRIP = str.maketrans({c: " " for c in "[](){}*`~_|"}) def _plain(text: object) -> str: return str(text or "").translate(_MD_STRIP) _OPEN_STATUSES = {"pending", "in_progress"} _REDIS_KEY = "urge:{item_id}" # Per-urge recipient ceiling — bounds the fan-out if the admin roster is # large. Manager(s) + a sensible slice of IT is plenty for a nudge. _MAX_RECIPIENTS = 12 class UrgeResult(BaseModel): urged: bool notified: int = 0 last_urged_at: str | None = None # i18n reason key (requestUrge.reason.) when ``urged`` is False. reason: str | None = None async def _resolve_pending_approvers( db: AsyncSession, detail: UnifiedRequestDetail, exclude: set[str], ) -> list[IdentityPerson]: """The Person(s) whose Feishu approval task is currently PENDING — i.e. exactly who the request is waiting on. Returns ``[]`` when there's no live Feishu flow or nobody resolvable (caller falls back).""" if not detail.feishu_instance_code: return [] try: cfg = await get_feishu_config(session=db) client = FeishuApprovalClient( app_id=str(cfg.get("app_id") or ""), app_secret=str(cfg.get("app_secret") or ""), ) emails = await client.list_pending_approver_emails( detail.feishu_instance_code, ) except Exception: # noqa: BLE001 # Feishu not configured / transient failure — degrade to the # manager + IT fallback rather than erroring the urge. return [] if not emails: return [] # Map work_email → active Person. Case-insensitive match on the # lowercased emails the client already normalised. rows = ( await db.execute( select(IdentityPerson).where( func.lower(IdentityPerson.work_email).in_(emails), IdentityPerson.deleted_at.is_(None), IdentityPerson.employment_status == "active", ) ) ).scalars().all() out: dict[str, IdentityPerson] = {} for person in rows: if str(person.id) in exclude or not person.work_email: continue out[str(person.id)] = person if len(out) >= _MAX_RECIPIENTS: break return list(out.values()) async def _resolve_cloud_grant_approver( db: AsyncSession, detail: UnifiedRequestDetail, exclude: set[str], ) -> list[IdentityPerson]: """The approver of a per-role cloud access grant is the SCOPE OWNER — NOT the requester's manager. Cloud grants are approved internally (no Feishu instance, so ``_resolve_pending_approvers`` is blind to them), which is exactly the routing ``lifecycle_service``'s ``_resolve_request_approval_recipients`` and the 我的申请 list display (``_provider_cloud_grant``) already use. Without this, a 催办 on a pending cloud grant would nudge the requester's manager + IT (who typically have no authority over the cloud scope) and SKIP the owner who actually decides it. Returns ``[]`` for an ownerless / leaver-owned scope so the caller falls back to the cloud-admin / IT queue — the same "no named owner → cloud-admin team" routing the display uses. """ from uuid import UUID as _UUID # noqa: PLC0415 from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbCloudAccessGrant, CmdbCloudScope, ) _, pk = _parse_item_id(detail.id) grant = await db.get(CmdbCloudAccessGrant, str(pk)) if grant is None or not grant.scope_id: return [] scope = await db.get(CmdbCloudScope, _UUID(str(grant.scope_id))) if scope is None or not scope.owner_person_id: return [] owner = await db.get(IdentityPerson, _UUID(str(scope.owner_person_id))) if ( owner is None or owner.deleted_at is not None or owner.employment_status in ("left", "leaver_pending") or not owner.work_email or str(owner.id) in exclude ): return [] return [owner] async def _resolve_recipients( db: AsyncSession, detail: UnifiedRequestDetail, actor: IdentityPerson, ) -> list[IdentityPerson]: """Only the people who actually need to act on the request now, minus the requester and the actor (you don't nudge yourself or the person already waiting). Preferred: the live Feishu pending-approver(s). Fallback (no Feishu flow, or Feishu can't tell us who is pending): the requester's manager + IT admin queue owners — the blanket "someone who can move it" set used only when the precise current owner is unknowable. """ exclude = {str(detail.requester_id), str(actor.id)} # The request is in a live Feishu approval → notify exactly the # approver(s) whose task is pending, not the whole chain. pending = await _resolve_pending_approvers(db, detail, exclude) if pending: return pending # Internally-approved sources have no Feishu instance, so the live # pending-approver lookup above is blind to them — resolve their real # approver explicitly instead of mis-firing the manager+IT fallback. if detail.source == SRC_CLOUD_GRANT: owner = await _resolve_cloud_grant_approver(db, detail, exclude) if owner: return owner # else: ownerless / leaver-owned scope → fall through to the # cloud-admin / IT queue, matching the display's "cloud_admin_team" # routing. out: dict[str, IdentityPerson] = {} # Requester's manager — the usual current approver. try: requester = await db.get(IdentityPerson, UUID(str(detail.requester_id))) except (TypeError, ValueError): requester = None if requester is not None and requester.manager_person_id is not None: manager = await db.get(IdentityPerson, requester.manager_person_id) if ( manager is not None and manager.deleted_at is None and manager.employment_status == "active" and manager.work_email and str(manager.id) not in exclude ): out[str(manager.id)] = manager # IT admin queue owners. for admin in await list_admin_recipients(db): if str(admin.id) in exclude or str(admin.id) in out: continue out[str(admin.id)] = admin if len(out) >= _MAX_RECIPIENTS: break return list(out.values()) async def urge_request( db: AsyncSession, *, item_id: str, actor: IdentityPerson, is_admin: bool, trace_id: str = "", ) -> UrgeResult: """Send a 催办 for one request. ``is_admin`` skips the owner check so an admin can urge any request from the approval overview; otherwise the actor must own the row (``build_my_request_detail`` enforces it, raising NotFound for a stranger's id).""" # Some overview sources (e.g. leaver_override) have no unified-detail # builder — urge can't resolve a requester/recipients for them. Return # a graceful reason instead of letting build_my_request_detail 404. source, _ = _parse_item_id(item_id) if source not in _DETAIL_BUILDERS: return UrgeResult(urged=False, reason="unsupported") detail = await build_my_request_detail( db, person_id=actor.id, item_id=item_id, enforce_owner=not is_admin, ) if detail.status not in _OPEN_STATUSES: return UrgeResult(urged=False, reason="notOpen") # Resolve recipients BEFORE claiming the rate-limit window: if nobody # is reachable (no manager + no IT admin, e.g. mid-setup), don't burn # the 24h window — the user can retry once an approver exists. recipients = await _resolve_recipients(db, detail, actor) if not recipients: return UrgeResult(urged=False, reason="noRecipient") redis = get_redis() # SECURITY: key the rate-limit on the NORMALISED id from the resolved # row (``detail.id`` = f"{source}:{row.id}"), NOT the attacker-supplied # ``item_id``. Different UUID encodings of the same pk (upper-case, # braces, urn:, no-dash) all resolve to the same row but would yield # distinct raw keys — letting an owner spam managers + IT by re-encoding # their own request id. Canonicalising via the row collapses them. key = _REDIS_KEY.format(item_id=detail.id) now_iso = datetime.now(UTC).isoformat() # Atomic claim: SET NX is the rate-limit gate. Two concurrent urges # (applicant + admin, or a double-submit) can't both pass — the loser # gets rateLimited, so the request is never double-notified. (A plain # GET-then-SET would let both through.) claimed = await redis.set(key, now_iso, nx=True, ex=URGE_TTL_SECONDS) if not claimed: existing = await redis.get(key) return UrgeResult( urged=False, last_urged_at=str(existing) if existing else None, reason="rateLimited", ) svc = NotificationService(db) title = "⏰ 催办:有一条审批待你处理" # Sanitise every user-controlled field interpolated into the card. requester = _plain(detail.requester_display or detail.requester_id) title_label = _plain(detail.title) actor_name = _plain(actor.display_name or actor.work_email) # Where does the recipient actually act? # * Feishu-decided requests (any row with a live Feishu approval # instance — DNS / 域名 / VPN / AI 工具 / 公告 …) have NO Helix # decision page; the approver approves in the Feishu「审批」app # where the native approval card already sits. Linking them to the # requester's Helix tracker (``detail.deep_link``) is misleading — # they'd land on an empty self-service page. So we drop the Helix # button and tell them to handle it in Feishu审批. # * Helix-internal requests keep the Helix deep-link. decided_in_feishu = bool(detail.feishu_instance_code) if decided_in_feishu: body = ( f"{actor_name} 催办了 " f"{requester} 提交的申请「{title_label}」,正在等待你的审批," f"请在飞书「审批」中查看并处理。" ) card_url: str | None = None else: body = ( f"{actor_name} 催办了 " f"{requester} 提交的申请「{title_label}」,当前仍在审批中," f"请尽快处理。" ) card_url = detail.deep_link notified = 0 for r in recipients: try: await svc.dispatch( NotificationEnvelope( recipient=r, title=title, body_text=body, channels=["feishu"], url=card_url, card_subtitle=LocalizedText( zh="催办提醒", en="Nudge reminder", ja="催促のお知らせ", ), related_resource_type="request_urge", ), trace_id=trace_id or str(uuid4()), actor_user_id=actor.id, ) notified += 1 except Exception: # Best-effort fan-out — one recipient's Feishu hiccup must not # abort the urge or the rate-limit stamp (the outbox row is # already persisted + retried by the notification worker). continue # Rate-limit window already claimed atomically above (SET NX). The # outbox rows are persisted regardless of per-recipient Feishu hiccups # (the worker retries delivery), so a failed dispatch still counts as # "urged today" — no separate stamp needed here. await AuditService(db).log( trace_id=trace_id or str(uuid4()), actor_type="user", action="me.request.urge", status="success", actor_user_id=actor.id, resource_type="request", resource_id=item_id, detail={ "source": detail.source, "is_admin": is_admin, "notified": notified, "title": title_label, }, ) await db.commit() return UrgeResult(urged=True, notified=notified, last_urged_at=now_iso)