"""Mail-group management REST API (HR / Global Admin). The admin-tier surface behind the 邮件组管理 menu entry. Every route is gated by ``can_manage_email_groups`` in :class:`~app.domains.email_group.manage_service.EmailGroupManageService`; the router additionally carries the runtime leaver gate, symmetric with the other two email-group routers. GET /email-groups/manage/overview — estate KPI strip GET /email-groups/manage/groups — every group, filterable GET /email-groups/manage/groups/export.xlsx — export that list GET /email-groups/manage/audit-log — operation log GET /email-groups/manage/audit-log/actions — action filter catalog GET /email-groups/manage/audit-log/export.xlsx GET /email-groups/manage/cleanup-candidates GET /email-groups/manage/cleanup-candidates/export.xlsx Read-only by design. Mutations live on ``admin_router.py`` (static-group writes) or on the surfaces that already own them (``router.py``, ``dynamic_router.py``) so there is exactly one implementation of each write and one place its guards are enforced. The one exception is ``POST /cleanup`` — the batch delete — which lives here next to the candidate list it consumes, because reading the pool and acting on it share the pool predicate and separating them is how the two drift apart. """ from __future__ import annotations from datetime import UTC, date, datetime, time from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.dependencies import ( require_active_employee_for_mutations as _leaver_runtime_gate, ) from app.core.exporting import xlsx_export_response from app.core.idempotent_route import idempotent from app.core.response import paginated_response, success_response from app.domains.email_group.cleanup_service import ( MAX_CANDIDATE_PAGE, MAX_CLEANUP_BATCH, EmailGroupCleanupService, ) from app.domains.email_group.manage_actions import EMAIL_GROUP_ACTIONS from app.domains.email_group.manage_service import EmailGroupManageService router = APIRouter( prefix="/api/v1/email-groups/manage", tags=["email_groups_manage"], dependencies=[Depends(_leaver_runtime_gate)], ) # Export caps. The estate is ~760 groups and the log grows without # bound, so the group export takes everything while the log export is # capped at a page size an xlsx reader can actually open. _GROUP_EXPORT_CAP = 2000 _LOG_EXPORT_CAP = 5000 def _roles(user: AuthUser) -> list[str]: return list(user.role_names or []) def _day_bound(value: date | None, *, end: bool) -> datetime | None: """``YYYY-MM-DD`` → a UTC-aware datetime at the day's edge. Start bound is 00:00:00, end bound 23:59:59.999999, so a range is inclusive on both ends in calendar-day terms. Mirrors ``router._parse_day_bound``. """ if value is None: return None return datetime.combine(value, time.max if end else time.min, tzinfo=UTC) def _csv_list(raw: str | None) -> list[str] | None: """Split a comma-separated query param into a clean list.""" if not raw: return None items = [s.strip() for s in raw.split(",") if s.strip()] return items or None # ── Overview ── @router.get("/overview", summary="Mail-group estate counters (HR / IT Admin)") async def get_overview( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupManageService(db) data = await service.overview(actor_roles=_roles(current_user)) return success_response(data=data, request=request) # ── Group list ── @router.get("/groups", summary="List every mail group (HR / IT Admin)") async def list_groups( request: Request, search: str | None = Query(None, max_length=255), suffix: str | None = Query( None, max_length=253, description="Exact domain after @ (e.g. pingcap.com)", ), kind: str = Query( "all", pattern="^(all|static|dynamic|unknown)$", description=( "static = Cloud Identity says not rule-driven; dynamic = " "rule-driven; unknown = detection never ran determinately" ), ), members: str = Query( "all", pattern="^(all|zero|nonzero|unsynced)$", ), owners: str = Query("all", pattern="^(all|none|some)$"), include_deleted: bool = Query(False), sort: str = Query( "email", pattern="^(email|name|members|created_at|last_synced_at)$", ), order: str = Query("asc", pattern="^(asc|desc)$"), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupManageService(db) rows, total = await service.list_groups( actor_roles=_roles(current_user), search=search, suffix=suffix, kind=kind, members=members, owners=owners, include_deleted=include_deleted, sort=sort, order=order, page=page, page_size=page_size, ) return paginated_response( data=rows, total=total, page=page, page_size=page_size, request=request, ) @router.get( "/groups/export.xlsx", summary="Export the filtered mail-group list (HR / IT Admin)", ) async def export_groups( request: Request, search: str | None = Query(None, max_length=255), suffix: str | None = Query(None, max_length=253), kind: str = Query("all", pattern="^(all|static|dynamic|unknown)$"), members: str = Query("all", pattern="^(all|zero|nonzero|unsynced)$"), owners: str = Query("all", pattern="^(all|none|some)$"), include_deleted: bool = Query(False), sort: str = Query( "email", pattern="^(email|name|members|created_at|last_synced_at)$", ), order: str = Query("asc", pattern="^(asc|desc)$"), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupManageService(db) rows, total = await service.list_groups( actor_roles=_roles(current_user), search=search, suffix=suffix, kind=kind, members=members, owners=owners, include_deleted=include_deleted, sort=sort, order=order, page=1, page_size=_GROUP_EXPORT_CAP, max_page_size=_GROUP_EXPORT_CAP, ) flat = [ { "email": r["email"], "name": r["name"], "kind": r["kind"], "status": r["status"], "direct_members_count": r["direct_members_count"], "owner_count": r["owner_count"], "owners": ", ".join( o["display_name"] or o["work_email"] or "" for o in r["owners"] ), "aliases": ", ".join(r["aliases"]), "description": r["description"], "membership_query": r["membership_query"], "created_at": r["created_at"], "last_synced_at": r["last_synced_at"], } for r in rows ] columns = [ ("email", "邮箱"), ("name", "名称"), ("kind", "类型"), ("status", "状态"), ("direct_members_count", "直接成员数"), ("owner_count", "负责人数"), ("owners", "负责人"), ("aliases", "别名"), ("description", "描述"), ("membership_query", "动态规则"), ("created_at", "创建时间"), ("last_synced_at", "最后同步"), ] return await xlsx_export_response( db, request, current_user, columns=columns, rows=flat, total_matched=total, filename_stem="email-groups", sheet_title="邮件组", audit_action="email_group.groups.export", audit_resource_type="email_group", filters={ "search": search, "suffix": suffix, "kind": kind, "members": members, "owners": owners, "include_deleted": include_deleted, }, ) # ── Operation log ── # # NOTE: the two literal sub-paths are registered BEFORE nothing else # claims them, but they sit under ``/audit-log/...`` while the list is # ``/audit-log`` exactly — no path param can shadow them. @router.get( "/groups/{group_id}", summary="One mail group, freshly serialized (HR / IT Admin)", ) async def get_group( group_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """One group by id, independent of any list filter. Exists so the per-group admin drawer does not depend on the parent list's FILTERED page. Deriving the drawer's group from those rows means every write inside the drawer can push its own row out of the active filter (grant an owner while filtering 无负责人, add a member while filtering 成员为 0, rename while a search term is active) — and the drawer then either blanks out with silently no-op buttons or, worse, falls back to the PRE-WRITE snapshot, from which the display-edit dialog re-submits the old name and renames the group back on Google. **Declared AFTER ``/groups/export.xlsx`` on purpose.** FastAPI matches routes in declaration order, so a ``/groups/{group_id}`` registered first swallows ``/groups/export.xlsx`` — and because ``group_id`` is typed ``UUID``, the export would 422 on "export.xlsx is not a valid uuid" rather than 404, which reads like a broken export instead of a routing mistake. The gate lives in the SERVICE (``get_group``), not here: a router body check is skipped on an ``@idempotent`` replay and invisible to any future second caller, and this response carries the full owner roster for an arbitrary id including tombstones. """ data = await EmailGroupManageService(db).get_group( group_id, actor_roles=_roles(current_user), ) return success_response(data=data, request=request) @router.get( "/audit-log/actions", summary="Catalog of mail-group audit actions (HR / IT Admin)", ) async def list_audit_actions( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The action filter's option list. Served from the hand-kept registry rather than ``SELECT DISTINCT`` so an action nobody has performed yet is still filterable and a retired one disappears — see ``manage_actions`` for the reasoning. """ service = EmailGroupManageService(db) service.require_manage(_roles(current_user)) return success_response( data=[ {"action": action, "category": category} for action, category in EMAIL_GROUP_ACTIONS.items() ], request=request, ) def _audit_log_query_params( group_id: UUID | None = Query( None, description="Only this group's timeline.", ), search: str | None = Query( None, max_length=255, description=( "Match the GROUP by email / name / alias (including " "soft-deleted groups). Ignored when group_id is given." ), ), actions: str | None = Query( None, max_length=2000, description="Comma-separated audit actions (OR'd).", ), category: str | None = Query( None, pattern="^(group|member|request|dynamic|sync|cleanup|export|other)$", description=( "Action category. Ignored when `actions` is given. " "`other` selects rows whose action is not in the registry " "— without it, applying any category filter would hide " "every unregistered action instead of narrowing to one." ), ), actor_person_id: UUID | None = Query(None), actor_type: str | None = Query(None, pattern="^(user|system)$"), status: str | None = Query(None, max_length=16), date_from: date | None = Query(None), date_to: date | None = Query(None), ) -> dict: """Shared filter block for the log list + its export.""" return { "group_id": group_id, "search": search, "actions": _csv_list(actions), "category": category, "actor_person_id": actor_person_id, "actor_type": actor_type, "status": status, "date_from": _day_bound(date_from, end=False), "date_to": _day_bound(date_to, end=True), } @router.get("/audit-log", summary="Mail-group operation log (HR / IT Admin)") async def list_audit_log( request: Request, filters: dict = Depends(_audit_log_query_params), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=200), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupManageService(db) rows, total = await service.audit_log( actor_roles=_roles(current_user), page=page, page_size=page_size, **filters, ) return paginated_response( data=rows, total=total, page=page, page_size=page_size, request=request, ) @router.get( "/audit-log/export.xlsx", summary="Export the mail-group operation log (HR / IT Admin)", ) async def export_audit_log( request: Request, filters: dict = Depends(_audit_log_query_params), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): import json service = EmailGroupManageService(db) rows, total = await service.audit_log( actor_roles=_roles(current_user), page=1, page_size=_LOG_EXPORT_CAP, max_page_size=_LOG_EXPORT_CAP, **filters, ) flat = [ { "created_at": r["created_at"], "action": r["action"], "action_category": r["action_category"], "group_email": r["group_email"], "group_name": r["group_name"], "actor": ( r["actor_display_name"] or r["actor_email"] or ("system" if r["actor_type"] == "system" else "") ), "actor_email": r["actor_email"], "status": r["status"], "ip_address": r["ip_address"], # The whole detail blob, verbatim — the log export is a # forensic artefact and a hand-picked subset of keys would # drop exactly the field a future investigation needs # (delete reasons, member emails, before/after rules). "detail": ( json.dumps(r["detail"], ensure_ascii=False, sort_keys=True) if r["detail"] is not None else "" ), "trace_id": r["trace_id"], } for r in rows ] columns = [ ("created_at", "时间"), ("action", "操作"), ("action_category", "分类"), ("group_email", "邮件组"), ("group_name", "组名称"), ("actor", "操作人"), ("actor_email", "操作人邮箱"), ("status", "结果"), ("ip_address", "IP"), ("detail", "详情"), ("trace_id", "trace_id"), ] return await xlsx_export_response( db, request, current_user, columns=columns, rows=flat, total_matched=total, filename_stem="email-group-operation-log", sheet_title="操作日志", audit_action="email_group.audit_log.export", audit_resource_type="email_group", filters={ k: (str(v) if v is not None else None) for k, v in filters.items() }, ) # ── Cleanup workbench ── # # The candidate list, its export, and the batch delete. Deliberately in # the same module: all three read the same pool predicate # (``EmailGroupCleanupService._pool_conditions``), and the delete # re-checks it per group at execute time. Splitting them across files is # how the "what is a candidate" definition drifts between the list and # the thing that acts on it. @router.get( "/cleanup-candidates", summary="Empty static mail groups, with cleanup signals (HR / IT Admin)", ) async def list_cleanup_candidates( request: Request, probe_nesting: bool = Query( True, description=( "Ask Google which OTHER groups list each candidate as a " "member. One API call per returned row, so the page size is " "the fan-out. Turn it off for a fast, less informed list — " "rows then carry a `nesting_unknown` signal rather than " "being presented as clean." ), ), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=MAX_CANDIDATE_PAGE), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupCleanupService(db) data = await service.list_candidates( actor_roles=_roles(current_user), probe_nesting=probe_nesting, page=page, page_size=page_size, ) return success_response(data=data, request=request) @router.get( "/cleanup-candidates/export.xlsx", summary="Export the cleanup candidates with their signals", ) async def export_cleanup_candidates( request: Request, probe_nesting: bool = Query(True), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """One page-sized export. Capped at ``MAX_CANDIDATE_PAGE`` because the nesting probe is one Google call per row: an "export everything" that quietly issues 700 API calls is a different feature with a different failure mode. The header reports the true total so a truncated export is visible rather than mistaken for the whole pool. """ service = EmailGroupCleanupService(db) data = await service.list_candidates( actor_roles=_roles(current_user), probe_nesting=probe_nesting, page=1, page_size=MAX_CANDIDATE_PAGE, ) rows = [ { "email": item["email"], "name": item["name"], "risk": item["risk"], "signals": ", ".join( f"{s['key']}({s['level']})" for s in item["signals"] ), "aliases": ", ".join(item["aliases"]), "description": item["description"], "created_at": item["created_at"], "last_synced_at": item["last_synced_at"], } for item in data["items"] ] columns = [ ("email", "邮箱"), ("name", "名称"), ("risk", "风险档"), ("signals", "信号"), ("aliases", "真实别名"), ("description", "描述"), ("created_at", "创建时间"), ("last_synced_at", "最后同步"), ] return await xlsx_export_response( db, request, current_user, columns=columns, rows=rows, total_matched=data["total"], filename_stem="email-group-cleanup-candidates", sheet_title="清理候选", audit_action="email_group.cleanup_candidates.export", audit_resource_type="email_group", filters={ "probe_nesting": probe_nesting, # ``nesting_probed`` is True as soon as ONE address was # probed, and the probe is capped at ``_MAX_PROBED_ROWS`` # while this export asks for a full ``MAX_CANDIDATE_PAGE``. # Recording only the boolean would file a sheet with # unprobed rows as "fully probed" — honest per-row in the # spreadsheet (``nesting_not_probed``), a lie in the # forensic record. # # The budget travels in its OWN fields, not inside # ``nesting_probe_error``: that string now holds real probe # FAILURES only (it is ``None`` when none failed), so # reading the budget out of it would record an empty note # for a sheet with 300 unprobed rows — the same lie, one # level up. Both facts, separately. "nesting_probed": data["nesting_probed"], "nesting_probe_failures": data.get("nesting_probe_error") or "", "nesting_rows_not_probed": data.get("nesting_rows_not_probed", 0), "nesting_probe_budget": data.get("nesting_probe_budget", 0), "rows_exported": len(rows), "known_blind_spots": ", ".join(data["known_blind_spots"]), }, ) class CleanupRequest(BaseModel): """Batch delete. Irreversible on Google — no undelete exists. ``group_ids`` order is preserved into the audit ``sequence`` so the batch can be replayed in the order the operator saw on screen; ``audit_logs.created_at`` is whole-second and cannot order a 20-row batch on its own. """ group_ids: list[UUID] = Field(..., min_length=1, max_length=MAX_CLEANUP_BATCH) reason: str = Field(..., min_length=10, max_length=500) @router.post( "/cleanup", summary="Delete empty static mail groups in batch (HR / IT Admin)", ) # Irreversible AND fans out to N Google deletes, so a replayed request # must not re-run the sweep. Keyed on the actor: the same person # retrying with the SAME key is one operation. @idempotent("email_group.cleanup", key_params=("current_user.id",)) async def cleanup_groups( request: Request, body: CleanupRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): service = EmailGroupCleanupService(db) data = await service.cleanup( group_ids=list(body.group_ids), reason=body.reason, actor=current_user, actor_roles=_roles(current_user), trace_id=request.state.trace_id, ) return success_response(data=data, request=request)