"""HTTP surface for 成就徽章 — the self-scoped wall plus an aggregate admin view.""" from __future__ import annotations from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.core.response import success_response from app.domains.gamification import service from app.identity.person_models import IdentityPerson router = APIRouter(prefix="/api/v1/me", tags=["gamification"]) admin_router = APIRouter(prefix="/api/v1/gamification/admin", tags=["gamification-admin"]) def _require_stats_reader(user: IdentityPerson) -> None: """Aggregate-only stats: admin + leadership read scope. Deliberately the SAME caliber as the arcade participation board this panel sits next to (`arcade/router._require_admin_or_management`), and no wider: the only surface that renders it is `/admin/arcade`, whose menu entry is gated on `isGlobalAdminView || isManagementView`. Granting HR the API without a way to reach it would be a permission that exists only on paper. No per-person data crosses this boundary — see ``get_org_stats``. """ roles = user.role_names or [] if not ( has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin") or has_role(roles, MANAGEMENT_ROLE) ): raise ForbiddenError( "Achievement stats require global_admin or management.", ) @router.get("/badges", summary="我的成就徽章墙(自助,只读;懒评估当前可解锁的)") async def my_badges( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The caller's achievement wall — every badge with earned/locked, unlocked_at, progress toward the ones still locked, and how many active colleagues hold each. ``newly_unlocked`` lets the frontend fire a one-time celebration; ``next_up`` names the badges they are closest to. Lazily evaluates so a freshly qualified badge shows without waiting for the nightly scan. Self-scoped, pure-pull (no push / no actionable badge).""" data = await service.get_my_badges(db, user) await db.commit() # persist any lazily-unlocked rows return success_response(data=data, request=request) @admin_router.get("/stats", summary="成就徽章 全站分布(admin / management,聚合只读)") async def org_stats( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Org-wide unlock distribution: holders per badge, participation rate and the badges nobody holds (the actionable signal that one is mis-tuned). Aggregate-only — deliberately no named ranking.""" _require_stats_reader(user) data = await service.get_org_stats(db) return success_response(data=data, request=request) __all__ = ["admin_router", "router"]