"""HTTP surface for 安全每日一题. Employee endpoints (any signed-in user): today's question, submit answer, my stats, leaderboard. Admin endpoints (global_admin): question CRUD + AI generate. Coverage read is admin + auditor (compliance evidence). **The language is decided server-side, once.** The player routes take an optional ``?lang=`` (the frontend's active i18next language), fall back to ``Accept-Language``, and then to the caller's stored ``preferred_language`` — see ``localization.reader_lang``. The response carries the language that actually rendered plus whether it was a fallback, so the page can say "no English version of today's question yet" instead of silently showing Chinese. The client never receives a per-language map: the options a reader picks from and the answer key they are graded against have to come from one resolution. """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Header, Query, Request from pydantic import BaseModel 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.locale import Lang from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role from app.core.response import success_response from app.domains.security_quiz import localization, service from app.identity.person_models import IdentityPerson router = APIRouter(prefix="/api/v1/security-quiz", tags=["security-quiz"]) admin_router = APIRouter(prefix="/api/v1/security-quiz/admin", tags=["security-quiz-admin"]) def _require_admin(user: IdentityPerson) -> None: roles = user.role_names or [] if not (has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin")): raise ForbiddenError("Security-quiz administration requires global_admin.") def _require_admin_or_auditor(user: IdentityPerson) -> None: roles = user.role_names or [] if not ( has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin") or has_role(roles, AUDITOR_ROLE) ): raise ForbiddenError("Coverage read requires global_admin or auditor.") # ── Player ────────────────────────────────────────────────────────── def _lang( user: IdentityPerson, explicit: str | None, header: str | None, ) -> Lang: """Helix's own language switcher wins over the browser header, which wins over the stored preference — see ``localization.reader_lang``.""" return localization.reader_lang(user, explicit=explicit, header=header) @router.get("/today", summary="今日安全题 + 我的作答状态(按员工语言)") async def today( request: Request, lang: str | None = Query(default=None, max_length=16), accept_language: str | None = Header(default=None, alias="Accept-Language"), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await service.get_today(db, user, lang=_lang(user, lang, accept_language)) return success_response(data=data, request=request) class _AnswerBody(BaseModel): selected_key: str @router.post("/answer", summary="提交今日答案(每人每天一次,幂等)") async def answer( request: Request, body: _AnswerBody, lang: str | None = Query(default=None, max_length=16), accept_language: str | None = Header(default=None, alias="Accept-Language"), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): # ``lang`` only decides which language the explanation comes back in — # grading is on option KEYS, which are language-independent by # construction (see ``localization``). A reader who answered in # English gets the English explanation of why they were wrong. data = await service.submit_answer( db, user, body.selected_key.strip(), lang=_lang(user, lang, accept_language), ) return success_response(data=data, request=request) @router.get("/me", summary="我的答题统计(连胜/累计)") async def my_stats( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): from datetime import datetime from app.core.working_hours import resolve_timezone today_local = datetime.now(resolve_timezone(user)).date() data = await service.compute_stats(db, user.id, today_local) return success_response(data=data, request=request) @router.get("/leaderboard", summary="全员排行榜 + 我的名次") async def leaderboard( request: Request, limit: int = Query(default=20, ge=1, le=100), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): data = await service.get_leaderboard(db, user, limit=limit) return success_response(data=data, request=request) @router.get("/rank", summary="我的安全段位 + 进度(连胜/累计 → 段位)") async def my_rank( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """The caller's security 段位 (青铜→王者 by lifetime correct) + progress to the next tier + streak. Self-scoped. Shares the canonical ladder with the 装备墙 rank chip (`security_quiz.ranks`).""" from datetime import datetime from app.core.working_hours import resolve_timezone from app.domains.security_quiz import ranks today_local = datetime.now(resolve_timezone(user)).date() stats = await service.compute_stats(db, user.id, today_local) prog = ranks.progress(stats["total_correct"]) return success_response(data={**stats, "rank": prog}, request=request) @router.get("/team-battle", summary="部门擂台 — 各部门参与/正确率排名 + 上周快照") async def team_battle( request: Request, # #1799: bound the window. Unbounded, ?window_days=999999999999 made # timedelta(days=...) raise OverflowError → unhandled 500, and a large # legal value silently widened the answer aggregation to a full-table # scan. Any signed-in user could hit this. Sibling endpoints (e.g. # feishu_helpdesk days=Query(None, ge=1, le=366)) are all bounded. window_days: int = Query(30, ge=1, le=366), user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Live department standings over the trailing window (ranked by participation then accuracy) + last week's frozen snapshot for the week-over-week view. Any signed-in user (it's a team-spirit board, not sensitive).""" from app.domains.security_quiz import team_battle as tb live = await tb.compute_department_standings(db, window_days=window_days) snapshot = await tb.latest_snapshot(db) return success_response(data={"live": live, "last_week": snapshot}, request=request) # ── Admin ─────────────────────────────────────────────────────────── @admin_router.get("/coverage", summary="安全意识参与度(合规证据,admin+auditor)") async def coverage( request: Request, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin_or_auditor(user) data = await service.get_coverage(db) return success_response(data=data, request=request) @admin_router.get("/questions", summary="题库列表(admin)") async def list_questions( request: Request, status: str | None = None, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await service.list_questions(db, status=status) return success_response(data=data, request=request) class _QuestionBody(BaseModel): prompt: str options: list[dict] correct_key: str qtype: str = "single" explanation: str | None = None category: str = "general" difficulty: str = "normal" #: ``{"en": {"prompt", "options", "explanation"}, "ja": {…}}``. Chinese #: is the baseline above and must NOT appear here. Option keys in a #: block have to match ``options`` exactly — the answer key is recorded #: as option keys, so a translation that renumbers them would grade the #: reader against choices they never saw. Rejected with 422 if they #: disagree. localized: dict | None = None @admin_router.post("/questions", summary="新增题目(admin)") async def create_question( request: Request, body: _QuestionBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await service.create_question( db, actor=user, prompt=body.prompt, options=body.options, correct_key=body.correct_key, qtype=body.qtype, explanation=body.explanation, category=body.category, difficulty=body.difficulty, localized=body.localized, ) return success_response(data=data, request=request) class _QuestionUpdateBody(BaseModel): prompt: str | None = None options: list[dict] | None = None correct_key: str | None = None qtype: str | None = None explanation: str | None = None category: str | None = None difficulty: str | None = None status: str | None = None #: Same shape and rules as on create. Sent explicitly (including as #: ``{}`` to delete every translation) it replaces the stored blocks. #: OMITTED while the Chinese prompt / options / correct answer change, #: the stored translations are dropped — they now describe a different #: question than the answer key does. See ``service.update_question``. localized: dict | None = None @admin_router.put("/questions/{question_id}", summary="编辑题目(admin)") async def update_question( request: Request, question_id: UUID, body: _QuestionUpdateBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await service.update_question(db, question_id, **body.model_dump(exclude_unset=True)) return success_response(data=data, request=request) class _StatusBody(BaseModel): status: str @admin_router.post("/questions/{question_id}/status", summary="启用/归档题目(admin)") async def set_status( request: Request, question_id: UUID, body: _StatusBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await service.set_status(db, question_id, body.status) return success_response(data=data, request=request) class _GenerateBody(BaseModel): count: int = 5 @admin_router.post("/generate", summary="AI 生成题目(admin,无 LLM 时优雅 no-op)") async def generate( request: Request, body: _GenerateBody, user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_admin(user) data = await service.generate_ai_questions(db, actor=user, count=body.count) return success_response(data=data, request=request) __all__ = ["router", "admin_router"]