"""HTTP surface for 信息安全自评估 (semiannual ISMS self-assessment). Mounts under ``/api/v1/auditor/assessments``. RBAC mirrors the auditor domain: ``can_view_security`` reads, ``can_manage_security`` mutates. """ from __future__ import annotations import uuid from datetime import UTC, datetime from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user, get_real_ip from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_security, can_view_security from app.core.response import success_response from app.domains.auditor import assessment_service as svc from app.domains.auditor.assessment_schemas import ( CycleCreateRequest, CycleUpdateRequest, MeasureCreateRequest, MeasureUpdateRequest, SectionUpdateRequest, ) router = APIRouter(prefix="/api/v1/auditor/assessments", tags=["auditor"]) def _require_view(user: AuthUser) -> None: if not can_view_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to view security data", ) def _require_manage(user: AuthUser) -> None: if not can_manage_security(user.role_names): raise ForbiddenError( "auditor or global_admin role required to mutate security data", ) async def _audit( db: AsyncSession, request: Request, user: AuthUser, *, action: str, resource_id: str | None, detail: dict | None = None, ) -> None: await AuditService(db).log( trace_id=getattr(request.state, "trace_id", str(uuid.uuid4())), actor_type="user", actor_user_id=user.id, action=action, status="success", resource_type="auditor_assessment", resource_id=resource_id, detail=detail or {}, ip_address=get_real_ip(request), user_agent=request.headers.get("User-Agent"), ) # ════════════════════════════════════════════════════════════════════ # Cycles # ════════════════════════════════════════════════════════════════════ @router.get("/cycles") async def list_cycles( request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response({"items": await svc.list_cycles(db)}, request=request) @router.post("/cycles") async def create_cycle( request: Request, payload: CycleCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) cycle = await svc.create_cycle( db, period_key=payload.period_key, created_by_person_id=user.id, ) await _audit( db, request, user, action="auditor.assessment.cycle.create", resource_id=str(cycle.id), detail={"period_key": cycle.period_key}, ) await db.commit() return success_response( {"id": str(cycle.id), "period_key": cycle.period_key}, request=request, ) @router.get("/cycles/{cycle_id}") async def get_cycle( request: Request, cycle_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response(await svc.get_cycle_detail(db, cycle_id), request=request) @router.patch("/cycles/{cycle_id}") async def update_cycle( request: Request, cycle_id: uuid.UUID, payload: CycleUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await svc.update_cycle(db, cycle_id, payload=payload.model_dump(exclude_unset=True)) await _audit( db, request, user, action="auditor.assessment.cycle.update", resource_id=str(cycle_id), ) await db.commit() return success_response({"id": str(cycle_id)}, request=request) @router.post("/cycles/{cycle_id}/refresh") async def refresh_cycle( request: Request, cycle_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) n = await svc.refresh_all_auto_data(db, cycle_id) await _audit( db, request, user, action="auditor.assessment.cycle.refresh", resource_id=str(cycle_id), detail={"refreshed": n}, ) await db.commit() return success_response({"refreshed": n}, request=request) @router.post("/cycles/{cycle_id}/finalize") async def finalize_cycle( request: Request, cycle_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) cycle = await svc.finalize_cycle(db, cycle_id, finalized_by_person_id=user.id) await _audit( db, request, user, action="auditor.assessment.cycle.finalize", resource_id=str(cycle_id), ) await db.commit() return success_response({"id": str(cycle.id), "status": cycle.status}, request=request) @router.post("/cycles/{cycle_id}/reopen") async def reopen_cycle( request: Request, cycle_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) cycle = await svc.reopen_cycle(db, cycle_id) await _audit( db, request, user, action="auditor.assessment.cycle.reopen", resource_id=str(cycle_id), ) await db.commit() return success_response({"id": str(cycle.id), "status": cycle.status}, request=request) @router.get("/cycles/{cycle_id}/compare-previous") async def compare_previous( request: Request, cycle_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) return success_response( await svc.compare_previous(db, cycle_id), request=request, ) @router.get("/cycles/{cycle_id}/export") async def export_cycle( request: Request, cycle_id: uuid.UUID, fmt: str = Query("pdf", pattern="^(pdf|docx)$"), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) from app.domains.auditor import assessment_export as exp if fmt == "docx": buf, stem = await exp.build_docx(db, cycle_id) media = exp.DOCX_MEDIA_TYPE ext = "docx" else: buf, stem = await exp.build_pdf(db, cycle_id) media = exp.PDF_MEDIA_TYPE ext = "pdf" await _audit( db, request, user, action="auditor.assessment.export", resource_id=str(cycle_id), detail={"format": ext}, ) await db.commit() stamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") return StreamingResponse( buf, media_type=media, headers={ "Content-Disposition": f'attachment; filename="{stem}_{stamp}.{ext}"', "X-Content-Type-Options": "nosniff", }, ) @router.post("/cycles/{cycle_id}/ai-draft") async def ai_draft_cycle( request: Request, cycle_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) from app.domains.auditor import assessment_ai result = await assessment_ai.draft_cycle(db, cycle_id) await _audit( db, request, user, action="auditor.assessment.ai_draft", resource_id=str(cycle_id), detail=result, ) await db.commit() return success_response(result, request=request) # ════════════════════════════════════════════════════════════════════ # Sections # ════════════════════════════════════════════════════════════════════ @router.patch("/sections/{section_id}") async def update_section( request: Request, section_id: uuid.UUID, payload: SectionUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await svc.update_section( db, section_id, payload=payload.model_dump(exclude_unset=True), ) await db.commit() return success_response({"id": str(section_id)}, request=request) @router.post("/sections/{section_id}/refresh") async def refresh_section( request: Request, section_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await svc.refresh_section_auto_data(db, section_id) await db.commit() return success_response({"id": str(section_id)}, request=request) # ════════════════════════════════════════════════════════════════════ # Measures tracker # ════════════════════════════════════════════════════════════════════ @router.get("/measures") async def list_measures( request: Request, include_archived: bool = Query(False), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_view(user) items = await svc.list_measures(db, include_archived=include_archived) return success_response({"items": items}, request=request) @router.post("/measures") async def create_measure( request: Request, payload: MeasureCreateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) m = await svc.create_measure(db, payload=payload.model_dump()) await _audit( db, request, user, action="auditor.assessment.measure.create", resource_id=str(m.id), ) await db.commit() return success_response({"id": str(m.id)}, request=request) @router.patch("/measures/{measure_id}") async def update_measure( request: Request, measure_id: uuid.UUID, payload: MeasureUpdateRequest, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await svc.update_measure( db, measure_id, payload=payload.model_dump(exclude_unset=True), ) await db.commit() return success_response({"id": str(measure_id)}, request=request) @router.delete("/measures/{measure_id}") async def delete_measure( request: Request, measure_id: uuid.UUID, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_manage(user) await svc.delete_measure(db, measure_id) await _audit( db, request, user, action="auditor.assessment.measure.delete", resource_id=str(measure_id), ) await db.commit() return success_response({"deleted": True}, request=request)