"""Unified "我的申请" tracker — every request the caller SUBMITTED, in one list. ``build_my_requests`` is the read-only complement to the mobile ``build_action_inbox`` (which surfaces items waiting for ME to *act*). This surfaces items I *initiated* and want to *track*: where is my laptop request, did my cloud-scope request get approved, is my DNS change live. Before this existed an employee had to check five surfaces — the ``/my/requests`` records tab (``employee_requests`` only), the cloud scope-requests page, the DNS history page, the email-alias settings tab, and their Feishu inbox — to assemble the answer. This joins all of them. Design rules (mirrors ``app/mobile/action_inbox.py``): * READ ONLY. Each item carries a ``deep_link`` to the page that owns the request and, for still-cancellable pending items, a ``cancel_path`` (the existing per-domain cancel endpoint). The aggregator never writes and is never a source of truth — it reads each domain's own row so a decision made elsewhere shows up on the next refresh. * SCOPED BY PERSON-ID LIST. Each provider filters by a list of person ids, so the same providers back both ``build_my_requests`` (the caller only — no role gate, "my own requests") and ``build_team_requests`` (a manager's subordinate ids — manager-derived scope, read-only). * EXTENSIBLE. A new request source is a new provider function appended to ``_PROVIDERS``; the item schema is source-agnostic. Source coverage (this revision): employee_requests (asset / software / api-key / Google), cloud scope-requests, DNS record requests, VPN applications, AI-tool applications, announcement requests, domain registrations, API-key release requests. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from uuid import UUID from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.person_scope import SubordinateScope, subordinate_person_ids from app.domains.ai_tool_application.models import AiToolApplication from app.domains.announcement_request.models import AnnouncementRequest from app.domains.apikey.models import ApiKeyReleaseRequest from app.domains.asset.models import EmployeeRequest from app.domains.cmdb.models import ( CmdbCloudAccessGrant, CmdbCloudGrantRenewalRequest, CmdbCloudScope, CmdbCloudScopeRequest, CmdbDnsRecordRequest, ) from app.domains.data_access.models import DataAccessRequest from app.domains.domain_registration.models import DomainRegistration from app.domains.email_group.models import EmailGroup, EmailGroupJoinRequest from app.domains.github_org.models import GithubIdChangeRequest from app.domains.permission.models import SoftwarePermission, UserPermission from app.domains.sso.models import SsoGrant from app.domains.support.models import ( OPEN_STATUSES as SUPPORT_OPEN_STATUSES, ) from app.domains.support.models import ( STATUS_CANCELLED as SUPPORT_CANCELLED, ) from app.domains.support.models import ( STATUS_OPEN as SUPPORT_OPEN, ) from app.domains.support.models import ( SupportTicket, ) from app.domains.vpn_application.models import VpnApplication from app.identity.consumers.models import IdentityConsumerRequest from app.identity.person_models import IdentityPerson # ── Sources & categories ──────────────────────────────────────────── # Source = which backing table; category = the coarse grouping the # frontend records-tab filter exposes (hardware / software / ...). Both # are stable literals shared with the i18n keys # (``myRequests.source.`` / ``myRequests.category.``). SRC_EMPLOYEE = "employee_request" SRC_CLOUD_SCOPE = "cloud_scope" SRC_CLOUD_GRANT = "cloud_grant" SRC_DNS = "dns" SRC_VPN = "vpn" SRC_AI_TOOL = "ai_tool" SRC_ANNOUNCEMENT = "announcement" SRC_DOMAIN = "domain" SRC_APIKEY_RELEASE = "apikey_release" SRC_PERMISSION = "permission" SRC_SSO = "sso" SRC_IDENTITY_CONSUMER = "identity_consumer" SRC_SUPPORT = "support" SRC_EMAIL_GROUP_JOIN = "email_group_join" SRC_DATA_ACCESS = "data_access" SRC_CLOUD_RENEWAL = "cloud_renewal" SRC_APPROVAL_FLOW = "approval_flow" SRC_GITHUB_ID = "github_id" CAT_HARDWARE = "hardware" CAT_SOFTWARE = "software" CAT_EMAIL = "email" CAT_NETWORK = "network" CAT_CLOUD = "cloud" CAT_AI = "ai" CAT_GENERAL = "general" # Map an ``employee_requests.request_type`` to a coarse category. Anything # unmapped falls back to ``general``. _EMPLOYEE_TYPE_CATEGORY = { "laptop_replacement": CAT_HARDWARE, "computer_borrow": CAT_HARDWARE, "asset_return": CAT_HARDWARE, "offboarding_buyback": CAT_HARDWARE, "claim": CAT_HARDWARE, "self_claim": CAT_HARDWARE, "self_borrow": CAT_HARDWARE, "repair": CAT_HARDWARE, "handover": CAT_HARDWARE, "return": CAT_HARDWARE, "software_seat": CAT_SOFTWARE, "api_key_request": CAT_SOFTWARE, "google_mail_group": CAT_EMAIL, "google_email_alias": CAT_EMAIL, "google_password_reset": CAT_EMAIL, "cloud_access_request": CAT_CLOUD, } # ── Status normalisation ──────────────────────────────────────────── # Each source carries its own status vocabulary; collapse them into one # lifecycle so the tracker can filter/count uniformly. ``raw_status`` is # preserved on the item for source-specific display. ST_PENDING = "pending" ST_IN_PROGRESS = "in_progress" ST_COMPLETED = "completed" ST_REJECTED = "rejected" ST_CANCELLED = "cancelled" ST_REVOKED = "revoked" ST_FAILED = "failed" _STATUS_MAP = { "pending": ST_PENDING, "pending_approval": ST_PENDING, # identity_consumer_request native status "feishu_sent": ST_PENDING, "approved": ST_IN_PROGRESS, "feishu_approved": ST_IN_PROGRESS, "provisioning": ST_IN_PROGRESS, "executing": ST_IN_PROGRESS, # DNS request — DNSPod write in flight "active": ST_COMPLETED, "provisioned": ST_COMPLETED, "granted": ST_COMPLETED, "completed": ST_COMPLETED, "executed": ST_COMPLETED, # DNS request — record written successfully # A new-flow request is done only once IT has BUILT the flow in the # Feishu console and filled its code back in. Without this entry the # literal "registered" fell through the map unchanged — the same # unknown-status bug class the SRC_SUPPORT note below describes. "registered": ST_COMPLETED, "rejected": ST_REJECTED, "failed": ST_FAILED, "cancelled": ST_CANCELLED, "withdrawn": ST_CANCELLED, "revoked": ST_REVOKED, } # Open = still in flight (awaiting decision or being fulfilled). _OPEN_STATUSES = {ST_PENDING, ST_IN_PROGRESS} # ── Outcome layer: reason + recourse for the unhappy / terminal path ─ # Every request object answers three questions on every surface — what # happened (``status``), WHY (``outcome_reason``), and what can I do now # (``recourse``). The latter two are resolved here, once, so the tracker # list, the detail modal, the manager/team view and the MCP twins never # re-derive them and never drift. # # ``needs_attention`` = a terminal-FAILURE the requester should notice and # act on (rejected / failed). Deliberately excludes cancelled (the user # ended it themselves) and revoked (access was granted then removed by # policy / offboarding — not an action item for the original requester). _FAILURE_STATUSES = {ST_REJECTED, ST_FAILED} # Terminal states that ended without success but are not a "you must act" # failure — re-applying is the only sensible recourse, no IT escalation. _ENDED_STATUSES = {ST_CANCELLED, ST_REVOKED} # Recourse action codes the frontend renders into buttons (display order = # list order). Stable literals shared with i18n (``myRequests.recourse. # ``) and the FE recourse handler. ``retry`` is emitted only by the # provisioning surface (failed fulfilment with a re-run path), not by these # request sources; defined here so the vocabulary lives in one place. RECOURSE_REAPPLY = "reapply" # submit a fresh request like this one RECOURSE_RETRY = "retry" # re-run a failed fulfilment RECOURSE_CONTACT_IT = "contact_it" # open a support ticket / ask IT RECOURSE_GOTO_SOURCE = "goto_source" # jump to the owning page (uses deep_link) # Sources an employee can re-submit through a self-service form (the FE # "一键重新申请" prefills from the failed request). A source absent here # offers ``goto_source`` instead of ``reapply`` for its terminal rows, so # the requester is never told to re-apply where no form exists. ``support`` # (a free-text help ticket) and ``identity_consumer`` / ``announcement`` # (admin-mediated) are intentionally absent. _REAPPLIABLE_SOURCES = { SRC_EMPLOYEE, SRC_CLOUD_SCOPE, SRC_CLOUD_GRANT, SRC_DNS, SRC_VPN, SRC_AI_TOOL, SRC_DOMAIN, SRC_SSO, SRC_PERMISSION, } def resolve_recourse(source: str, normalized_status: str) -> list[str]: """Deterministic recourse codes for a terminal request. A failure (rejected / failed) offers re-apply (where a form exists) + contact-IT; an ended row (cancelled / revoked) offers only re-apply. Open / completed rows get nothing — there is nothing to recover. The frontend always also has the row's ``deep_link``; ``goto_source`` is appended only when no in-app recourse applies so a terminal row is never a dead-end. """ if normalized_status in _FAILURE_STATUSES: out: list[str] = [] if source in _REAPPLIABLE_SOURCES: out.append(RECOURSE_REAPPLY) out.append(RECOURSE_CONTACT_IT) return out if normalized_status in _ENDED_STATUSES: return [RECOURSE_REAPPLY] if source in _REAPPLIABLE_SOURCES else [RECOURSE_GOTO_SOURCE] return [] # Whole SOURCES whose lifecycle terminates at ``approved`` — the grant is # effected synchronously on Feishu approval and the row never advances to a # distinct active/provisioned state (its status enum has no such value; # verified against each model: only pending/approved/rejected/cancelled # [/revoked]). Without this the default approved→in_progress map pins every # granted SSO/VPN/AI-tool/announcement/domain request as a perpetual "处理中" # in 我的申请 — the same forever-pending bug #1108 fixed for software_seat / # api_key_request, left unfixed for these five sibling domains. _APPROVED_IS_TERMINAL_SOURCES = { SRC_SSO, SRC_VPN, SRC_AI_TOOL, SRC_ANNOUNCEMENT, SRC_DOMAIN, # An identity-consumer request is done once approved — the live # consumer is provisioned and the applicant gets a token-issue link. SRC_IDENTITY_CONSUMER, # An api-key release request's happy-path terminal status is # ``approved`` — the row never advances past it (the key teardown # happens on the parent ApiKeyAssignment). Without this it shows as a # perpetual "处理中" in 我的申请, the exact #1108 bug-class the five # sibling sources above already fixed. SRC_APIKEY_RELEASE, # An email-group join request terminates at ``approved`` — the owner # decision synchronously adds the member to Google; the row has no # further state (pending/approved/rejected/cancelled only). SRC_EMAIL_GROUP_JOIN, # A GitHub-ID change terminates at ``approved``: the decision itself # applies the change (SF + person row) and the Feishu push is a # fire-and-forget projection, not a further approval state. The row's # enum is pending/approved/rejected/cancelled and nothing else. SRC_GITHUB_ID, # 2026-08-26 — ``employee_requests`` joins the list, WHOLE-SOURCE. # # The table has no post-approval state. Every writer of # ``employee_requests.status`` sets one of pending / approved / # rejected / cancelled (approval callback, admin decide, cancel), and # the two request families that DO end at ``completed`` # (``google_*`` / ``onboarding_claim``) are stamped ``completed`` # directly and never pass through ``approved``. Production agrees: # zero rows of ANY type sit past ``approved``. # # So the previous per-type allowlist ({software_seat, # api_key_request}) was pinning every hardware request — 52 prod rows # across laptop_replacement / computer_borrow / asset_return / # offboarding_buyback — as a perpetual "处理中" that nothing could # ever clear. The excluded-on-purpose rationale ("approved means # awaiting physical fulfilment, genuinely still in flight") described # a fulfilment signal Helix does not have: approval IS the last event # the row records. Reported by employees who saw their finished # 电脑借用 / 电脑更换 stuck in 进行中 forever. # # If a request type ever grows a real post-approval state (e.g. a # ``fulfilled`` status written when IT hands the laptop over), add it # to ``_STATUS_MAP`` AND narrow this back to a per-type allowlist — # at that point ``approved`` stops being terminal for that type. SRC_EMPLOYEE, } # Per-source cap. The tracker is a triage/history surface; if one source # blows past this the dedicated per-domain page is the full list. _PER_SOURCE_CAP = 300 def _normalize_status(raw: str) -> str: return _STATUS_MAP.get(raw, raw) def cloud_grant_lifecycle_status(status: str, detail: dict | None) -> str: """Normalised lifecycle for a ``CmdbCloudAccessGrant`` row. Cloud grants reuse one ``status`` enum for both "request denied" and "was granted, later removed" — ``reject_grant`` flips a PENDING row to ``revoked`` (revoke_reason=owner_removed) + stamps ``detail.reject_reason`` (the ONLY writer of that key). Without disambiguating, a *denied* request reads as 已回收/Revoked in 我的申请 (implying access was granted), losing the rejected-vs-granted-then-revoked distinction every other request source preserves. So: revoked + ``reject_reason`` present → rejected; ``disabled``/``expired`` (absent from ``_STATUS_MAP``) → revoked terminal; else the shared map. Shared by the list provider and the detail builder so the two never drift. """ if status == "revoked" and (detail or {}).get("reject_reason") is not None: return ST_REJECTED if status in ("disabled", "expired"): return ST_REVOKED return _normalize_status(status) def cloud_grant_outcome_reason( status: str, detail: dict | None, revoke_reason: str | None, ) -> tuple[str | None, str | None]: """``(outcome_reason, reason_code)`` for a terminal cloud-grant row. A *denied* request stamps ``detail.reject_reason`` (free text from the owner/admin — see :func:`cloud_grant_lifecycle_status`); a granted-then- removed grant carries an enum ``revoke_reason`` (``owner_removed`` / ``expired`` / ``orphan_principal_swept`` …). The former is shown verbatim; the latter is returned as both the display value and a machine code the FE localises. Shared by the list provider and the detail builder so they never disagree on why a grant ended. """ reject = (detail or {}).get("reject_reason") if reject: return str(reject), None if status in ("revoked", "disabled", "expired") and revoke_reason: return str(revoke_reason), str(revoke_reason) return None, None def resolve_normalized_status(source: str, request_type: str, raw_status: str) -> str: """Single source of truth for collapsing a source-native status into the unified lifecycle, including the ``approved`` is-terminal overrides. Shared by the list aggregator (``_mk``) and the detail view (``request_detail._detail_*``) so both agree on whether an approved grant reads as completed or still-in-flight. ``request_type`` no longer participates in the decision (every source is now all-or-nothing on approved-is-terminal) but stays in the signature: it is the hook a future per-type narrowing needs — see the ``SRC_EMPLOYEE`` note in ``_APPROVED_IS_TERMINAL_SOURCES`` — and every caller already passes it. """ if source == SRC_SUPPORT: # #2229 — support tickets have their own vocabulary and it is NOT # in ``_STATUS_MAP``: ``open`` / ``triaging`` / ``waiting_requester`` # / ``resolved`` / ``closed`` all fell through the generic # ``_STATUS_MAP.get(raw, raw)`` unchanged. The list provider passed # ``status_override=_support_status(...)`` and was right; the detail # builder called this resolver and got the raw literal back, so an # OPEN ticket computed ``is_terminal=True`` (``"open"`` is not in # ``{pending, in_progress}``), rendered no timeline events beyond # "submitted", offered no recourse, and its ```` fell to # the unknown-status branch — the same ticket reading as live on one # surface and closed on the other. Owning the mapping HERE (rather # than asking every caller to remember the override) is what makes # the docstring's "single source of truth" claim true. return _support_status(raw_status) if raw_status == "approved" and source in _APPROVED_IS_TERMINAL_SOURCES: return ST_COMPLETED return _normalize_status(raw_status) class MyRequestItem(BaseModel): """One submitted request, normalised across every source table.""" id: str # f"{source}:{pk}" — stable, unique across sources source: str category: str request_type: str # granular type; frontend localises the label title: str subtitle: str | None = None status: str # normalised lifecycle raw_status: str # source-native status (for display fidelity) is_open: bool created_at: str # ISO 8601 updated_at: str | None = None deep_link: str # the page that owns this request can_cancel: bool = False cancel_path: str | None = None # full API path; set only when cancellable feishu_instance_code: str | None = None # Who submitted it. Always set. ``requester_display`` is filled only by # the manager/team view (build_team_requests) where rows span people; # the self view leaves it None (the requester is the caller). requester_id: str requester_display: str | None = None # For a still-pending request: WHO it is waiting on. ``kind`` is # ``"owner"`` (a named person — ``pending_approver_label`` carries the # display name) or ``"cloud_admin_team"`` (routed to the cloud-admin # team; label is None and the frontend i18n's the team label). Both # None for non-pending items and for sources with no resolvable # approver. Lets the requester see "等待 X 审批" instead of a bare # "Pending" with no addressee. Frontend i18n owns the team label so no # Chinese is baked into the API. pending_approver_kind: str | None = None pending_approver_label: str | None = None # Names of the people currently holding a Feishu approval task. The # unified list reads the cached snapshot only, never fan-outs a live # Feishu request per row. current_approvers: list[str] = [] # ── Outcome (the unhappy / terminal path) ─────────────────────── # WHY a terminal request ended the way it did, resolved from the # source row's own columns where available (Feishu-only reject # comments surface in the detail view, not here). None for open / # completed rows and for sources with no local reason column. outcome_reason: str | None = None # ``reason_code`` (machine reason like ``owner_removed`` / # ``requester_offboarded``) when the reason is an enum the FE # localises via ``myRequests.reasonCode.``; ``outcome_reason`` # then carries the same code as a raw fallback. Free-text reasons # leave ``reason_code`` None. outcome_reason_code: str | None = None # True when this is a failure the requester should act on (rejected / # failed) — drives the dashboard "需要处理" KPI + list highlight. needs_attention: bool = False # Recourse action codes (reapply / contact_it / goto_source); empty # for open / completed rows. See ``resolve_recourse``. recourse: list[str] = [] class MyRequestsResponse(BaseModel): items: list[MyRequestItem] counts_by_status: dict[str, int] counts_by_category: dict[str, int] total: int page: int page_size: int # Sources whose per-source ``LIMIT _PER_SOURCE_CAP`` actually bit # this call (#2193-6). Each provider is capped independently, so a # heavy submitter's oldest rows fall off the tracker — and, more # visibly, off the EXPORT, which asks for far more rows than the cap # can supply. Silent truncation reads as "this is everything"; # naming the sources lets the UI say otherwise. truncated_sources: list[str] = Field(default_factory=list) @dataclass class _Cand: """Internal candidate carrying a naive-UTC sort timestamp. MySQL/TiDB hand ``DateTime(timezone=True)`` back as naive on fresh load but aware right after commit; comparing the two raises. We keep a normalised comparable datetime here and serialise once. """ item: MyRequestItem sort_dt: datetime def _naive(dt: datetime) -> datetime: return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt def _mk( *, source: str, pk: object, category: str, request_type: str, title: str, raw_status: str, created_at: datetime, deep_link: str, requester_id: str, subtitle: str | None = None, updated_at: datetime | None = None, cancel_path: str | None = None, feishu_instance_code: str | None = None, status_override: str | None = None, pending_approver_kind: str | None = None, pending_approver_label: str | None = None, current_approvers: list[str] | None = None, outcome_reason: str | None = None, outcome_reason_code: str | None = None, ) -> _Cand: # ``status_override`` lets a provider pin a normalised lifecycle value # when the raw status alone is ambiguous. Otherwise the shared resolver # applies the per-source/per-type ``approved``-is-terminal rules. # ``raw_status`` is still preserved verbatim on the item for display # fidelity. status = status_override or resolve_normalized_status( source, request_type, raw_status ) can_cancel = status == ST_PENDING and cancel_path is not None # Outcome reason / code only carry on a terminal row — drop anything a # provider passes for a still-open request (a pending row has no # "why it ended" yet). Recourse is derived from the final status. is_terminal = status not in _OPEN_STATUSES reason = (outcome_reason or "").strip() or None if is_terminal else None reason_code = outcome_reason_code if (is_terminal and reason) else None return _Cand( item=MyRequestItem( id=f"{source}:{pk}", source=source, category=category, request_type=request_type, title=title, subtitle=subtitle, status=status, raw_status=raw_status, is_open=status in _OPEN_STATUSES, created_at=created_at.isoformat(), updated_at=updated_at.isoformat() if updated_at else None, deep_link=deep_link, can_cancel=can_cancel, cancel_path=cancel_path if can_cancel else None, feishu_instance_code=feishu_instance_code, requester_id=requester_id, pending_approver_kind=( pending_approver_kind if status == ST_PENDING else None ), pending_approver_label=( pending_approver_label if status == ST_PENDING else None ), current_approvers=( current_approvers if status == ST_PENDING else [] ) or [], outcome_reason=reason, outcome_reason_code=reason_code, needs_attention=status in _FAILURE_STATUSES, recourse=resolve_recourse(source, status), ), sort_dt=_naive(created_at), ) # ── Providers ─────────────────────────────────────────────────────── # Each provider takes a LIST of person ids and filters with ``.in_(...)`` # so the same providers back both the self view (one id) and the # manager/team view (the caller's subordinate ids). Uuid columns bind the # UUID objects directly; String(36) columns bind the stringified ids. def _str_ids(person_ids: list[UUID]) -> list[str]: return [str(p) for p in person_ids] async def _provider_employee(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(EmployeeRequest) .where(EmployeeRequest.requester_id.in_(person_ids)) .order_by(EmployeeRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() def _current_approvers(row: EmployeeRequest) -> list[str]: feishu = (row.detail or {}).get("feishu") if not isinstance(feishu, dict): return [] snapshot = feishu.get("latest_instance_detail") if not isinstance(snapshot, dict): return [] # Lazy import keeps the approval domain off the tracker module's # import path. This is cached data only: no network call per row. from app.approval.service import ApprovalService progress = ApprovalService._build_progress_from_instance( snapshot, include_raw_identifiers=False, ) return progress.current_approvers if progress else [] return [ _mk( source=SRC_EMPLOYEE, pk=r.id, category=_EMPLOYEE_TYPE_CATEGORY.get(r.request_type, CAT_GENERAL), request_type=r.request_type, title=r.request_type, # frontend maps to a localised label subtitle=r.request_number, raw_status=r.status, created_at=r.created_at, updated_at=r.processed_at, deep_link=f"/my/requests?focus={r.id}", cancel_path=f"/api/v1/assets/employee-requests/{r.id}/cancel", requester_id=str(r.requester_id), feishu_instance_code=( (r.detail or {}).get("feishu", {}).get("instance_code") if isinstance((r.detail or {}).get("feishu"), dict) else None ), current_approvers=_current_approvers(r), # ``approved`` is the terminal status for every employee_request # type — collapsed to "completed" centrally by # resolve_normalized_status (via _mk). ) for r in rows ] async def _provider_cloud_scope(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(CmdbCloudScopeRequest) .where(CmdbCloudScopeRequest.requester_person_id.in_(_str_ids(person_ids))) .order_by(CmdbCloudScopeRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() # Cloud-scope cancel is owner-console driven (no uniform request-id # cancel endpoint) — deep-link to the page that owns withdrawal. return [ _mk( source=SRC_CLOUD_SCOPE, pk=r.id, category=CAT_CLOUD, request_type="cloud_scope_request", title=r.name, subtitle=r.provider, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/cloud/scope-requests", feishu_instance_code=r.feishu_instance_code, requester_id=r.requester_person_id, # Local terminal reason: rejection_reason (denied) / # failure_reason (driver couldn't provision). Both free text. outcome_reason=r.rejection_reason or r.failure_reason, ) for r in rows ] async def _provider_cloud_grant(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: """Per-role cloud access requests the caller submitted via Helix. Distinct from ``_provider_cloud_scope`` (apply for a whole subscription/project): this is "grant me role R on existing scope S". Before this provider the request was invisible in 我的申请 — the applicant could only find it on the dedicated 我的云订阅 page — so a "Pending" grant looked like it had vanished. Only ``source='helix'`` rows: auto-``discovered`` grants (the bulk — Azure/AWS seed 50–100 per user) were never *requested* through Helix and would flood this triage surface. For a still-pending row we also resolve the approver (scope owner, else the cloud-admin team) so the requester sees who they're waiting on. """ rows = ( await db.execute( select(CmdbCloudAccessGrant) .where( CmdbCloudAccessGrant.person_id.in_(_str_ids(person_ids)), CmdbCloudAccessGrant.source == "helix", ) .order_by(CmdbCloudAccessGrant.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() if not rows: return [] # Resolve scope + owner for the pending rows so we can name the # approver. Mirrors ``_serialize_grants`` / the lifecycle service's # ``_resolve_request_approval_recipients`` routing. scope_ids = {r.scope_id for r in rows} scopes = (await db.execute( select(CmdbCloudScope).where( CmdbCloudScope.id.in_([UUID(s) for s in scope_ids]), ) )).scalars().all() scope_by_id = {str(s.id): s for s in scopes} owner_ids = {s.owner_person_id for s in scopes if s.owner_person_id} owners = (await db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([UUID(o) for o in owner_ids]), ) )).scalars().all() if owner_ids else [] owner_by_id = {str(p.id): p for p in owners} out: list[_Cand] = [] for r in rows: scope = scope_by_id.get(str(r.scope_id)) appr_kind: str | None = None appr_label: str | None = None if r.status == "pending": owner = ( owner_by_id.get(str(scope.owner_person_id)) if scope and scope.owner_person_id else None ) if ( owner is not None and owner.employment_status not in ("left", "leaver_pending") ): appr_kind = "owner" appr_label = owner.display_name or owner.work_email else: appr_kind = "cloud_admin_team" cg_reason, cg_code = cloud_grant_outcome_reason( r.status, r.detail, r.revoke_reason, ) out.append(_mk( source=SRC_CLOUD_GRANT, pk=r.id, category=CAT_CLOUD, request_type="cloud_access_request", title=( scope.display_name if scope and scope.display_name else (scope.external_id if scope else "cloud access") ), subtitle=r.role_display_name or r.role_key, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/cloud/grants", requester_id=str(r.person_id) if r.person_id else "", # Disambiguate denied (rejected) from granted-then-revoked, and # pin disabled/expired to the terminal lifecycle. status_override=cloud_grant_lifecycle_status(r.status, r.detail), pending_approver_kind=appr_kind, pending_approver_label=appr_label, outcome_reason=cg_reason, outcome_reason_code=cg_code, )) return out async def _provider_dns(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(CmdbDnsRecordRequest) .where(CmdbDnsRecordRequest.requester_person_id.in_(_str_ids(person_ids))) .order_by(CmdbDnsRecordRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_DNS, pk=r.id, category=CAT_NETWORK, request_type=f"dns_{r.action}", title=f"{r.name}.{r.zone_name} ({r.record_type})", subtitle=r.action, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/cloud/dns", cancel_path=f"/api/v1/cmdb/dns-record-requests/{r.id}/cancel", feishu_instance_code=r.feishu_instance_code, requester_id=r.requester_person_id, # Failed DNS write carries the provider error verbatim. outcome_reason=r.error_message, ) for r in rows ] async def _provider_vpn(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(VpnApplication) .where(VpnApplication.person_id.in_(_str_ids(person_ids))) .order_by(VpnApplication.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_VPN, pk=r.id, category=CAT_NETWORK, request_type="vpn_application", title=r.vpn_node, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/requests", cancel_path=f"/api/v1/vpn-applications/{r.id}/cancel", feishu_instance_code=r.feishu_instance_code, requester_id=r.person_id, # ``revoke_reason`` is free text (admin-revoke / offboard-during- # pending), so surface it as the reason — not a localisable code. outcome_reason=r.revoke_reason, ) for r in rows ] async def _provider_ai_tool(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(AiToolApplication) .where(AiToolApplication.person_id.in_(_str_ids(person_ids))) .order_by(AiToolApplication.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_AI_TOOL, pk=r.id, category=CAT_AI, request_type="ai_tool_application", title=r.ai_name, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/requests", cancel_path=f"/api/v1/ai-tool-applications/{r.id}/cancel", feishu_instance_code=r.feishu_instance_code, requester_id=r.person_id or "", ) for r in rows ] async def _provider_announcement(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(AnnouncementRequest) .where(AnnouncementRequest.person_id.in_(_str_ids(person_ids))) .order_by(AnnouncementRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_ANNOUNCEMENT, pk=r.id, category=CAT_GENERAL, request_type="announcement_request", title=(r.description or "")[:80] or "announcement_request", raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/requests", cancel_path=f"/api/v1/announcement-requests/{r.id}/cancel", feishu_instance_code=r.feishu_instance_code, requester_id=r.person_id, ) for r in rows ] async def _provider_approval_flow( db: AsyncSession, person_ids: list[UUID], ) -> list[_Cand]: """「公司还没有这个流程」 — a request for an approval flow that does not exist yet. Deliberately NOT in ``_APPROVED_IS_TERMINAL_SOURCES``, unlike every other ``CAT_GENERAL`` source here: ``approved`` means IT agreed and has **not built it yet**, so the requester is still waiting. Only ``registered`` — flow built in the Feishu console, code filled back in — is done. Pinning approved as terminal would show 「已完成」 to somebody who still has no flow to submit. """ from app.domains.approval_registry.models import ApprovalFlowRequest rows = ( await db.execute( select(ApprovalFlowRequest) .where(ApprovalFlowRequest.requester_person_id.in_(person_ids)) .order_by(ApprovalFlowRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_APPROVAL_FLOW, pk=r.id, category=CAT_GENERAL, request_type="approval_flow_request", title=r.proposed_name, subtitle=(r.purpose or "")[:80] or None, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/requests", cancel_path=f"/api/v1/me/approval-flows/requests/{r.id}/cancel", requester_id=str(r.requester_person_id), outcome_reason=r.decision_note, ) for r in rows ] async def _provider_domain(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(DomainRegistration) .where(DomainRegistration.person_id.in_(_str_ids(person_ids))) .order_by(DomainRegistration.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_DOMAIN, pk=r.id, category=CAT_NETWORK, request_type="domain_registration", title=r.domain_name, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, # Re-registration is submitted on the unified /my/requests page # (EmployeeRequestPage, Network category), NOT the read-only # /my/cloud/domains list — so the reapply recourse lands where # the user can actually re-submit (结果闭环 review fix). deep_link="/my/requests", cancel_path=f"/api/v1/domain-registrations/{r.id}/cancel", feishu_instance_code=r.feishu_instance_code, requester_id=r.person_id, # revoke_reason is free text here → surface as reason, not a code. outcome_reason=r.revoke_reason, ) for r in rows ] async def _provider_identity_consumer( db: AsyncSession, person_ids: list[UUID], ) -> list[_Cand]: rows = ( await db.execute( select(IdentityConsumerRequest) .where( IdentityConsumerRequest.requester_person_id.in_(person_ids), IdentityConsumerRequest.deleted_at.is_(None), ) .order_by(IdentityConsumerRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_IDENTITY_CONSUMER, pk=r.id, category=CAT_GENERAL, request_type="identity_consumer_request", title=r.name, subtitle=r.slug, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/requests", cancel_path=f"/api/v1/identity/consumer-requests/{r.id}/cancel", feishu_instance_code=r.feishu_instance_code, requester_id=str(r.requester_person_id), ) for r in rows ] async def resolve_apikey_release_approver( db: AsyncSession, requester_id: UUID | str | None, ) -> tuple[str | None, str | None]: """``(pending_approver_kind, pending_approver_label)`` for an API Key release request. Mirrors :meth:`ApiKeyService._notify_release_pending` routing: the request waits on the requester's **active manager**; if the manager link is broken (NULL / left / deleted — real for C-level / contractor rows) it falls back to the **IT-admin team**. Returns ``('owner', )`` for the manager case (the frontend renders "等待 X 审批"), ``('it_admin_team', None)`` for the fallback (frontend i18n's the team label), or ``(None, None)`` only when the requester id can't be resolved. Shared by the list provider above and :func:`app.domains.me_universe.request_detail._detail_apikey_release` so both surfaces name the same approver. """ if not requester_id: return None, None try: rid = UUID(str(requester_id)) except (TypeError, ValueError): return None, None requester = await db.get(IdentityPerson, rid) if requester is None: return None, None if 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 or "active").lower() == "active" ): return "owner", (manager.display_name or manager.work_email) return "it_admin_team", None async def _provider_apikey_release(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(ApiKeyReleaseRequest) .where(ApiKeyReleaseRequest.requester_id.in_(person_ids)) .order_by(ApiKeyReleaseRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() # Release cancel is keyed on assignment_id (not request id) — surface # the source page rather than risk a wrong inline call. out: list[_Cand] = [] for r in rows: # For a still-pending release, name who it's waiting on (the # requester's manager, else the IT-admin team) so "待审批" is # never a "waiting on … nobody-knows" dead-end. appr_kind: str | None = None appr_label: str | None = None if r.status == "pending": appr_kind, appr_label = await resolve_apikey_release_approver( db, r.requester_id, ) out.append(_mk( source=SRC_APIKEY_RELEASE, pk=r.id, category=CAT_SOFTWARE, request_type="apikey_release_request", title=r.reason_code, # frontend localises subtitle=r.reason_note, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/me/api-keys", requester_id=str(r.requester_id) if r.requester_id else "", pending_approver_kind=appr_kind, pending_approver_label=appr_label, )) return out async def _provider_permission(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(UserPermission, SoftwarePermission.display_name) .join(SoftwarePermission, UserPermission.permission_id == SoftwarePermission.id) .where(UserPermission.user_id.in_(person_ids)) .order_by(UserPermission.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).all() return [ _mk( source=SRC_PERMISSION, pk=up.id, category=CAT_SOFTWARE, request_type="software_permission", title=display_name or "software_permission", raw_status=up.status, created_at=up.created_at, updated_at=up.updated_at, deep_link="/my/requests", # Permission requests are NOT Feishu-routed; withdraw is a # local state flip on the pending row. cancel_path=f"/api/v1/permissions/requests/{up.id}/cancel", requester_id=str(up.user_id), ) for up, display_name in rows ] async def _provider_sso(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(SsoGrant) .where(SsoGrant.person_id.in_(_str_ids(person_ids))) .order_by(SsoGrant.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() return [ _mk( source=SRC_SSO, pk=r.id, category=CAT_SOFTWARE, request_type="sso_grant", title=r.system_name, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/my/requests", cancel_path=f"/api/v1/sso/grants/{r.id}/cancel", feishu_instance_code=r.feishu_instance_code, requester_id=str(r.person_id), # revoke_reason is free text → reason only, no localisable code. outcome_reason=r.revoke_reason, ) for r in rows ] def _support_status(raw: str) -> str: if raw == SUPPORT_OPEN: return ST_PENDING if raw in ("resolved", "closed"): return ST_COMPLETED if raw == SUPPORT_CANCELLED: return ST_CANCELLED # triaging / in_progress / waiting_requester return ST_IN_PROGRESS async def _provider_support(db: AsyncSession, person_ids: list[UUID]) -> list[_Cand]: rows = ( await db.execute( select(SupportTicket) .where(SupportTicket.requester_id.in_(person_ids)) .order_by(SupportTicket.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() cands: list[_Cand] = [] for r in rows: # Cancellable from the tracker only while brand-new (open); once IT is # working it, cancel happens on the ticket page. cancel_path = ( f"/api/v1/support/tickets/{r.id}/cancel" if r.status in SUPPORT_OPEN_STATUSES else None ) cands.append(_mk( source=SRC_SUPPORT, pk=r.id, category=CAT_GENERAL, request_type="support_ticket", title=r.title, subtitle=r.ticket_number, raw_status=r.status, status_override=_support_status(r.status), created_at=r.created_at, updated_at=r.updated_at, deep_link=f"/me/support/{r.id}", cancel_path=cancel_path, requester_id=str(r.requester_id), )) return cands async def _provider_email_group_join( db: AsyncSession, person_ids: list[UUID], ) -> list[_Cand]: """Mail-group join requests the caller submitted (in-Helix approval). Terminates at ``approved`` (owner decision adds the member to Google synchronously) — pinned terminal via ``_APPROVED_IS_TERMINAL_SOURCES``. Cancellable while pending via the self-service cancel endpoint. The group email is joined in for a readable title. """ rows = ( await db.execute( select(EmailGroupJoinRequest, EmailGroup.email, EmailGroup.name) .join(EmailGroup, EmailGroup.id == EmailGroupJoinRequest.email_group_id) .where(EmailGroupJoinRequest.requester_id.in_(person_ids)) .order_by(EmailGroupJoinRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).all() return [ _mk( source=SRC_EMAIL_GROUP_JOIN, pk=req.id, category=CAT_EMAIL, request_type="email_group_join", title=group_email, subtitle=group_name, raw_status=req.status, created_at=req.created_at, updated_at=req.updated_at, # ``/email-groups`` is not a route; the mail-group surface # is ``/company/mail-groups``. deep_link="/company/mail-groups", cancel_path=( f"/api/v1/email-groups/join-requests/{req.id}/cancel" ), requester_id=str(req.requester_id), ) for req, group_email, group_name in rows ] async def _provider_github_id( db: AsyncSession, person_ids: list[UUID], ) -> list[_Cand]: """GitHub ID 修改单(站内审批,审批人是本人的 HR Ops / HRBP)。 ``person_id`` 是 ``String(36)``,所以按字符串绑 —— 与本文件里其它 String 列的 provider 同一处理(见 ``_str_ids`` 的注释)。 """ rows = ( await db.execute( select(GithubIdChangeRequest) .where(GithubIdChangeRequest.person_id.in_(_str_ids(person_ids))) .order_by(GithubIdChangeRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) ) ).scalars().all() approver_names = await _resolve_person_names( db, [r.approver_person_id for r in rows], ) return [ _mk( source=SRC_GITHUB_ID, pk=r.id, category=CAT_GENERAL, request_type="github_id_change", title=r.new_github_id, subtitle=r.old_github_id, raw_status=r.status, created_at=r.created_at, updated_at=r.updated_at, deep_link="/me/settings?tab=profile", cancel_path=f"/api/v1/me/github-id/requests/{r.id}/cancel", requester_id=str(r.person_id), pending_approver_kind="hr_ops", # NULL approver = nobody was derivable (no HR Ops, no HRBP); # the HR backstop queue owns it. Leaving the label empty would # render "waiting on …" with a blank name, which reads as a bug. pending_approver_label=( approver_names.get(r.approver_person_id or "") or None ), outcome_reason=r.decision_note, ) for r in rows ] async def _resolve_person_names( db: AsyncSession, ids: list[str | None], ) -> dict[str, str]: wanted = {i for i in ids if i} if not wanted: return {} rows = ( await db.execute( select( IdentityPerson.id, IdentityPerson.display_name, IdentityPerson.work_email, ).where(IdentityPerson.id.in_(wanted)) ) ).all() return {str(pid): (dn or we or str(pid)) for pid, dn, we in rows} async def _provider_data_access( db: AsyncSession, person_ids: list[UUID], ) -> list[_Cand]: rows = (await db.execute( select(DataAccessRequest) .where(DataAccessRequest.requester_person_id.in_(person_ids)) .order_by(DataAccessRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) )).scalars().all() status_map = { "pending_feishu": ST_PENDING, "approved": ST_COMPLETED, "rejected": ST_REJECTED, "stale_requires_resubmit": ST_FAILED, "cancelled": ST_CANCELLED, } return [ _mk( source=SRC_DATA_ACCESS, pk=row.id, category=CAT_GENERAL, request_type=row.approval_flow_key, title=row.principal_email_snapshot, subtitle=row.entitlement_snapshot, raw_status=row.status, status_override=status_map.get(row.status, row.status), created_at=row.created_at, updated_at=row.updated_at, deep_link=f"/my/data-access/requests/{row.id}", feishu_instance_code=row.feishu_instance_code, requester_id=str(row.requester_person_id), outcome_reason=row.last_reconcile_error, ) for row in rows ] async def _provider_cloud_renewal( db: AsyncSession, person_ids: list[UUID], ) -> list[_Cand]: rows = (await db.execute( select(CmdbCloudGrantRenewalRequest) .where( CmdbCloudGrantRenewalRequest.requested_by_person_id.in_( _str_ids(person_ids), ), ) .order_by(CmdbCloudGrantRenewalRequest.created_at.desc()) .limit(_PER_SOURCE_CAP) )).scalars().all() scope_ids = {row.scope_id for row in rows} scopes = ( await db.execute(select(CmdbCloudScope).where(CmdbCloudScope.id.in_(scope_ids))) ).scalars().all() if scope_ids else [] scope_names = { str(scope.id): scope.display_name or scope.external_id for scope in scopes } status_map = { "pending_owner": ST_PENDING, "pending_admin": ST_PENDING, "approved": ST_COMPLETED, "rejected": ST_REJECTED, "cancelled": ST_CANCELLED, "lapsed": ST_CANCELLED, } out: list[_Cand] = [] seen: set[str] = set() for row in rows: logical_id = row.request_group_id or row.id if logical_id in seen: continue seen.add(logical_id) out.append(_mk( source=SRC_CLOUD_RENEWAL, pk=logical_id, category=CAT_CLOUD, request_type="cloud_grant_renewal", title=scope_names.get(row.scope_id, row.scope_id), subtitle=f"{row.requested_ttl_days}d", raw_status=row.status, status_override=status_map.get(row.status, row.status), created_at=row.created_at, updated_at=row.updated_at, deep_link="/my/cloud/grants", requester_id=str(row.requested_by_person_id), outcome_reason=row.decision_note, )) return out _PROVIDERS = [ _provider_employee, _provider_email_group_join, _provider_github_id, _provider_data_access, _provider_cloud_scope, _provider_cloud_grant, _provider_cloud_renewal, _provider_dns, _provider_vpn, _provider_ai_tool, _provider_announcement, _provider_approval_flow, _provider_domain, _provider_apikey_release, _provider_permission, _provider_sso, _provider_identity_consumer, _provider_support, ] def _matches_query(item: MyRequestItem, needle: str) -> bool: """Case-insensitive substring match across the human-searchable fields. Providers fold the domain identifiers (request number, DNS name, scope name, domain name, …) into ``title`` / ``subtitle``, so matching those two plus ``request_type`` / ``source`` covers every source without per-provider knowledge here. """ hay = ( f"{item.title}\n{item.subtitle or ''}\n{item.request_type}\n{item.source}" f"\n{item.requester_display or ''}" ).lower() return needle in hay def _assemble( candidates: list[_Cand], *, status: str | None, category: str | None, q: str | None, page: int, page_size: int, truncated_sources: list[str] | None = None, ) -> MyRequestsResponse: """Shared count → filter → sort → paginate for both builders. ``counts_*`` are computed off the full candidate set so the FE filter chips stay stable regardless of the active filter. """ counts_by_status: dict[str, int] = {} counts_by_category: dict[str, int] = {} for c in candidates: counts_by_status[c.item.status] = counts_by_status.get(c.item.status, 0) + 1 counts_by_category[c.item.category] = ( counts_by_category.get(c.item.category, 0) + 1 ) if status: if status == "open": candidates = [c for c in candidates if c.item.is_open] else: candidates = [c for c in candidates if c.item.status == status] if category: candidates = [c for c in candidates if c.item.category == category] needle = (q or "").strip().lower() if needle: candidates = [c for c in candidates if _matches_query(c.item, needle)] candidates.sort(key=lambda c: c.sort_dt, reverse=True) total = len(candidates) start = (page - 1) * page_size page_items = [c.item for c in candidates[start : start + page_size]] return MyRequestsResponse( items=page_items, counts_by_status=counts_by_status, counts_by_category=counts_by_category, total=total, page=page, page_size=page_size, truncated_sources=sorted(truncated_sources or []), ) async def build_my_requests( db: AsyncSession, *, person_id: UUID, status: str | None = None, category: str | None = None, q: str | None = None, page: int = 1, page_size: int = 20, ) -> MyRequestsResponse: """Assemble the caller's unified submitted-request tracker. ``status`` filters by normalised lifecycle (``pending`` / ``in_progress`` / ``completed`` / ``rejected`` / ``cancelled`` / ``revoked`` / ``failed``) or the meta value ``open`` (pending + in_progress). ``category`` filters by coarse grouping. ``q`` is a case-insensitive substring search over title / subtitle / request_type / source — it powers both the tracker search box and the global-search "我的申请" group. """ candidates: list[_Cand] = [] truncated: list[str] = [] for provider in _PROVIDERS: got = await provider(db, [person_id]) # A provider that returned exactly its cap almost certainly had # more (#2193-6) — report it rather than let the list look # complete. if len(got) >= _PER_SOURCE_CAP and got: truncated.append(got[0].item.source) candidates.extend(got) return _assemble( candidates, status=status, category=category, q=q, page=page, page_size=page_size, truncated_sources=truncated, ) async def build_team_requests( db: AsyncSession, *, manager: IdentityPerson, status: str | None = None, category: str | None = None, q: str | None = None, page: int = 1, page_size: int = 20, ) -> MyRequestsResponse: """Manager view: every request submitted by the caller's reports. Reuses the same providers with the manager's subordinate ids (``SubordinateScope.MANAGED`` — direct + transitive reports, excluding ``left``, keeping the offboarding-window ``leaver_pending`` so a manager still sees a departing report's open requests). Each item's ``requester_display`` is filled from a single batched name lookup. A manager with no reports gets an empty result (not 403) — mirrors the other ``/me/team/*`` endpoints. Read-only: managers see, they don't decide their reports' requests. """ sub_ids = await subordinate_person_ids( db, manager.id, scope=SubordinateScope.MANAGED, ) if not sub_ids: return MyRequestsResponse( items=[], counts_by_status={}, counts_by_category={}, total=0, page=page, page_size=page_size, ) candidates: list[_Cand] = [] truncated: list[str] = [] for provider in _PROVIDERS: got = await provider(db, sub_ids) # #2244: the same per-source cap, over a manager's WHOLE reporting # subtree — so this view hits 300 rows far more easily than the # single-person one that got the flag in #2193-6. Without it the # manager view (and its export) reads a silently truncated slice # as "this is everything". if len(got) >= _PER_SOURCE_CAP and got: truncated.append(got[0].item.source) candidates.extend(got) # Single batched name lookup — every requester is one of sub_ids. name_rows = ( await db.execute( select(IdentityPerson.id, IdentityPerson.display_name) .where(IdentityPerson.id.in_(sub_ids)) ) ).all() names = {str(pid): name for pid, name in name_rows} for c in candidates: c.item.requester_display = names.get(c.item.requester_id) return _assemble( candidates, status=status, category=category, q=q, page=page, page_size=page_size, truncated_sources=truncated, )