"""HTTP surface for awareness campaigns. Mounted on the same two prefixes PR1 established: * ``/api/v1/security-awareness/my/...`` — what THIS employee owes, and the acknowledgement itself. Any authenticated user; every read and write is scoped to the caller by ``person_id``, never by a path parameter (a campaign id in the URL selects which of *their own* rows to touch, not whose). * ``/api/v1/admin/security-awareness/campaigns/...`` — the console. Reads gate on ``can_view_security``, writes on ``can_manage_security``. **Sending is a POST with no body-level confirmation flag.** The confirmation lives in the UI, where the audience preview is on screen next to the button. A server-side "are you sure" boolean would be a checkbox somebody learns to tick. """ from __future__ import annotations import uuid from datetime import datetime from fastapi import APIRouter, Depends, Query, Request, status from pydantic import BaseModel, Field, field_validator from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.background import spawn from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_security, can_view_security from app.core.response import success_response from app.domains.security_awareness.campaign_service import ( ADMIN_MAX_PAGE_SIZE, TARGET_MAX_PAGE_SIZE, CampaignService, run_campaign_dispatch_in_background, ) from app.domains.security_awareness.catalog import ( ACK_MOBILE, ACK_WEB, CAMPAIGN_CHANNELS, CAMPAIGN_STATUSES, COMPLETION_ACK, COMPLETION_MODES, ) # ════════════════════════════════════════════════════════════════════ # Bodies # ════════════════════════════════════════════════════════════════════ class CampaignCreate(BaseModel): material_id: uuid.UUID name: str = Field(..., min_length=1, max_length=200) # ``app.core.person_filters.FilterConfig`` shape + this domain's # ``exclude_person_ids``. Validated by resolving it (the create path # runs a preview), so a malformed filter is a 422 at author time # rather than a silent non-send at 09:00. audience_config: dict = Field(default_factory=dict) channels: list[str] = Field(default_factory=lambda: list(CAMPAIGN_CHANNELS)) mandatory: bool = False completion_mode: str = COMPLETION_ACK due_at: datetime | None = None @field_validator("channels") @classmethod def _check_channels(cls, v: list[str]) -> list[str]: unknown = sorted(set(v or []) - set(CAMPAIGN_CHANNELS)) if unknown: raise ValueError(f"unknown channel(s): {', '.join(unknown)}") return list(v or []) @field_validator("completion_mode") @classmethod def _check_mode(cls, v: str) -> str: if v not in COMPLETION_MODES: raise ValueError(f"unknown completion_mode {v!r}") return v class CampaignUpdate(BaseModel): """Partial. A campaign that has already sent accepts only ``name`` and ``due_at`` — see ``CampaignService.update``.""" name: str | None = Field(default=None, min_length=1, max_length=200) material_id: uuid.UUID | None = None audience_config: dict | None = None channels: list[str] | None = None mandatory: bool | None = None completion_mode: str | None = None due_at: datetime | None = None @field_validator("channels") @classmethod def _check_channels(cls, v: list[str] | None) -> list[str] | None: if v is None: return None return CampaignCreate._check_channels(v) @field_validator("completion_mode") @classmethod def _check_mode(cls, v: str | None) -> str | None: if v is not None and v not in COMPLETION_MODES: raise ValueError(f"unknown completion_mode {v!r}") return v class AudiencePreviewRequest(BaseModel): audience_config: dict = Field(default_factory=dict) class ScheduleRequest(BaseModel): """``None`` = send on the next dispatch tick (within ten minutes).""" scheduled_at: datetime | None = None #: Waive the duplicate-send guard — see ``CampaignService.schedule``. force: bool = False # ════════════════════════════════════════════════════════════════════ # Employee surface # ════════════════════════════════════════════════════════════════════ self_router = APIRouter( prefix="/api/v1/security-awareness/my", tags=["security-awareness"], ) @self_router.get("/campaigns", summary="Awareness I still owe") async def my_campaigns( request: Request, include_voluntary: bool = Query(default=False), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Mandatory campaigns awaiting this person's acknowledgement. Voluntary notices are excluded unless asked for: they are not work, and listing them alongside required reading would make the two indistinguishable — which is the entire reason the strengths are separate in the first place. """ rows = await CampaignService(db).my_pending( user.id, include_voluntary=include_voluntary, ) return success_response(data=rows, request=request) @self_router.post( "/campaigns/{campaign_id}/acknowledge", summary="I have read and understood", ) async def acknowledge( campaign_id: uuid.UUID, request: Request, source: str = Query(default=ACK_WEB), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Idempotent. A second press returns the FIRST timestamp rather than moving it — the timestamp is the evidence.""" result = await CampaignService(db).acknowledge( person_id=user.id, campaign_id=campaign_id, source=source if source in (ACK_WEB, ACK_MOBILE) else ACK_WEB, trace_id=getattr(request.state, "trace_id", ""), ) return success_response(data=result, request=request) # ════════════════════════════════════════════════════════════════════ # Admin surface # ════════════════════════════════════════════════════════════════════ admin_router = APIRouter( prefix="/api/v1/admin/security-awareness/campaigns", tags=["security-awareness-admin"], ) def _require_read(user: AuthUser) -> None: if not can_view_security(user.role_names): raise ForbiddenError("awareness campaigns require security read") def _require_write(user: AuthUser) -> None: if not can_manage_security(user.role_names): raise ForbiddenError("awareness campaigns require security write") @admin_router.get("", summary="Campaign list with funnels") async def list_campaigns( request: Request, status_filter: str | None = Query(default=None, alias="status", max_length=16), mandatory: bool | None = Query(default=None), material_id: uuid.UUID | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=ADMIN_MAX_PAGE_SIZE), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_read(user) if status_filter and status_filter not in CAMPAIGN_STATUSES: # A bogus status silently returning EVERYTHING is worse than an # error: the admin reads an unfiltered list as a filtered one and # concludes the wrong thing about how many campaigns are live. raise AppException( code="SECAW_UNKNOWN_STATUS", message=f"unknown campaign status {status_filter!r}", status_code=422, ) rows, total = await CampaignService(db).list_campaigns( status=status_filter, mandatory=mandatory, material_id=material_id, page=page, page_size=page_size, ) return success_response( data=rows, request=request, meta={ "total": total, "page": page, "page_size": page_size, "can_manage": can_manage_security(user.role_names), }, ) @admin_router.post("/preview-audience", summary="Dry-run an audience") async def preview_audience( body: AudiencePreviewRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Counts, a named sample and per-department / per-country breakdowns. "1 人" and "487 人" are both plausible outcomes of a mis-built filter, and the total alone does not tell the admin which one they built.""" _require_read(user) data = await CampaignService(db).preview_audience(body.audience_config) return success_response(data=data, request=request) @admin_router.get("/{campaign_id}", summary="One campaign, with breakdowns") async def get_campaign( campaign_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_read(user) data = await CampaignService(db).get_campaign(campaign_id) return success_response( data=data, request=request, meta={"can_manage": can_manage_security(user.role_names)}, ) @admin_router.get("/{campaign_id}/targets", summary="Per-person ledger") async def list_targets( campaign_id: uuid.UUID, request: Request, state: str = Query(default="all"), q: str | None = Query(default=None, max_length=200), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=TARGET_MAX_PAGE_SIZE), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_read(user) rows, total = await CampaignService(db).list_targets( campaign_id, state=state, q=q, page=page, page_size=page_size, ) return success_response( data=rows, request=request, meta={"total": total, "page": page, "page_size": page_size}, ) @admin_router.post("", status_code=status.HTTP_201_CREATED, summary="Create") async def create_campaign( body: CampaignCreate, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_write(user) row = await CampaignService(db).create( material_id=body.material_id, name=body.name, audience_config=body.audience_config, channels=body.channels, mandatory=body.mandatory, completion_mode=body.completion_mode, due_at=body.due_at, actor_id=user.id, trace_id=getattr(request.state, "trace_id", ""), ) return success_response(data={"id": str(row.id)}, request=request) @admin_router.put("/{campaign_id}", summary="Edit") async def update_campaign( campaign_id: uuid.UUID, body: CampaignUpdate, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_write(user) row = await CampaignService(db).update( campaign_id, body.model_dump(exclude_unset=True), actor_id=user.id, trace_id=getattr(request.state, "trace_id", ""), ) return success_response( data={"id": str(row.id), "status": row.status}, request=request, ) @admin_router.post("/{campaign_id}/schedule", summary="Arm it") async def schedule_campaign( campaign_id: uuid.UUID, body: ScheduleRequest, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_write(user) row = await CampaignService(db).schedule( campaign_id, body.scheduled_at, actor_id=user.id, trace_id=getattr(request.state, "trace_id", ""), force=body.force, ) return success_response( data={"id": str(row.id), "scheduled_at": row.scheduled_at}, request=request, ) @admin_router.post( "/{campaign_id}/send-now", status_code=status.HTTP_202_ACCEPTED, summary="Send immediately", ) async def send_now( campaign_id: uuid.UUID, request: Request, force: bool = Query( default=False, description=( "Waive the duplicate-send guard for a deliberate second wave " "of the same material to the same audience." ), ), user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Arm it for now, then dispatch OFF the request. Two steps rather than a separate send path, so the immediate send and the cron send go through exactly the same code — a "send now" that bypassed the dispatcher would be a second implementation of the one thing that must not have two. **202, not 200.** The fan-out is hundreds of external calls (15.5 minutes for a 416-person audience, measured 2026-09-07), so it runs on its own session via ``background.spawn`` and this returns as soon as the campaign is armed. Awaiting it here outlived nginx's 300s ``proxy_read_timeout``: the admin got a generic failure toast over a send that was still running, and pressing the button again created a SECOND campaign instead of retrying the first — 416 people, two copies of the same required reading. Nothing is lost if the spawned task never runs (a deploy landing in the same second): the campaign is committed as ``scheduled`` first, and ``security_awareness.campaign_dispatch`` picks up anything due within ten minutes. """ _require_write(user) svc = CampaignService(db) trace = getattr(request.state, "trace_id", "") row = await svc.schedule( campaign_id, None, actor_id=user.id, trace_id=trace, force=force, ) scheduled_at = row.scheduled_at # Commit BEFORE spawning: the background task loads the campaign from # its own session, so a task started against an uncommitted row would # either not see it or race the commit. (The cmdb sync trigger # learned this the same way — see its comment.) await db.commit() spawn( run_campaign_dispatch_in_background( campaign_id, trace_id=str(trace), actor_id=user.id, ), name=f"secaw.send_now:{campaign_id}", ) return success_response( data={ "id": str(campaign_id), "status": "dispatching", "scheduled_at": scheduled_at, }, request=request, ) @admin_router.post( "/{campaign_id}/resend", status_code=status.HTTP_202_ACCEPTED, summary="Nudge the unacknowledged", ) async def resend( campaign_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Only the people who have not acknowledged. Re-pushing to somebody who already did what was asked is the fastest way to teach the whole company to ignore this channel. **202, off-request, for the same reason as ``send-now``**: on a company-wide campaign "the people who have not acknowledged" is most of the audience on day one, so this fan-out is the same size and the same number of minutes. It runs on its own session; anything it does not reach stays PENDING for ``notification.outbox_retry``. """ _require_write(user) # Validate + 409 in-request, so the admin learns immediately that the # campaign is not in a resendable state instead of getting a 202 for # work that will refuse itself in the background. await CampaignService(db).require_resendable(campaign_id) trace = getattr(request.state, "trace_id", "") spawn( run_campaign_dispatch_in_background( campaign_id, trace_id=str(trace), actor_id=user.id, resend_only=True, ), name=f"secaw.resend:{campaign_id}", ) return success_response( data={"id": str(campaign_id), "status": "dispatching"}, request=request, ) @admin_router.post("/{campaign_id}/cancel", summary="Call it off before sending") async def cancel( campaign_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_write(user) row = await CampaignService(db).cancel( campaign_id, actor_id=user.id, trace_id=getattr(request.state, "trace_id", ""), ) return success_response( data={"id": str(row.id), "status": row.status}, request=request, ) @admin_router.post("/{campaign_id}/close", summary="Declare it finished") async def close( campaign_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Stops asking the people who never acknowledged. A real decision with a real cost, so it is a named act and the funnel at close time goes into the audit row — closing is when the number stops moving, and that is the number somebody may later report.""" _require_write(user) row = await CampaignService(db).close( campaign_id, actor_id=user.id, trace_id=getattr(request.state, "trace_id", ""), ) return success_response( data={"id": str(row.id), "status": row.status}, request=request, ) @admin_router.delete( "/{campaign_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a never-sent campaign", ) async def delete_campaign( campaign_id: uuid.UUID, request: Request, user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): _require_write(user) await CampaignService(db).delete( campaign_id, actor_id=user.id, trace_id=getattr(request.state, "trace_id", ""), ) return None