Skip to content

Backend API

Application

main

Main FastAPI Application - MasterplanOptimiserV3 (GC) Server. Lightweight calendar backend with passkey auth and one-way publish.

lifespan async

lifespan(_app: FastAPI)

Run application startup checks through FastAPI's supported lifecycle.

Source code in backend/app/main.py
@asynccontextmanager
async def lifespan(_app: FastAPI):
    """Run application startup checks through FastAPI's supported lifecycle."""

    await startup_event()
    from app.core.retention import retention_scheduler_loop
    from app.services.evidence_archive import evidence_archive_worker_loop

    retention_stop = asyncio.Event()
    retention_task = asyncio.create_task(
        retention_scheduler_loop(retention_stop),
        name="retention-scheduler",
    )
    archive_stop = asyncio.Event()
    archive_task = asyncio.create_task(
        evidence_archive_worker_loop(archive_stop),
        name="evidence-git-uploader",
    )
    try:
        yield
    finally:
        retention_stop.set()
        archive_stop.set()
        await asyncio.gather(retention_task, archive_task)

ha_write_permit_exception_handler async

ha_write_permit_exception_handler(request: Request, exc: HAWritePermitError)

Fail closed if a commit outlives its short witness permit.

Source code in backend/app/main.py
@app.exception_handler(HAWritePermitError)
async def ha_write_permit_exception_handler(request: Request, exc: HAWritePermitError):
    """Fail closed if a commit outlives its short witness permit."""

    return JSONResponse(
        status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
        content={"detail": "Writes are paused because ownership cannot be verified.", "code": "HA_OWNERSHIP_UNVERIFIED"},
        headers={"Cache-Control": "no-store", "Retry-After": "5"},
    )

production_exception_handler async

production_exception_handler(request: Request, exc: Exception)

Return a generic production error without logging sensitive values.

Source code in backend/app/main.py
@app.exception_handler(Exception)
async def production_exception_handler(request: Request, exc: Exception):
    """Return a generic production error without logging sensitive values."""
    logging.getLogger("api.error").error(json.dumps({
        "event": "request.error",
        "method": request.method,
        "path": request.url.path,
        "error_type": type(exc).__name__,
    }, separators=(",", ":"), sort_keys=True))
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={"detail": "Internal server error"},
    )

validation_exception_handler async

validation_exception_handler(request: Request, exc: RequestValidationError)

Return actionable field errors without reflecting submitted values.

Source code in backend/app/main.py
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
    """Return actionable field errors without reflecting submitted values."""

    detail = []
    for error in exc.errors():
        location = [
            part
            for part in error.get("loc", ())[:8]
            if isinstance(part, (str, int))
        ]
        message = str(error.get("msg") or "Invalid value").strip()[:240]
        error_type = str(error.get("type") or "value_error")[:80]
        detail.append({
            "type": error_type,
            "loc": location,
            "msg": message or "Invalid value",
        })

    return JSONResponse(
        status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
        content={"detail": detail or [{
            "type": "value_error",
            "loc": ["body"],
            "msg": "The submitted data is invalid",
        }]},
    )

limit_request_body async

limit_request_body(request: Request, call_next)

Reject request bodies larger than the server limit.

Source code in backend/app/main.py
@app.middleware("http")
async def limit_request_body(request: Request, call_next):
    """Reject request bodies larger than the server limit."""

    content_length = request.headers.get("content-length")
    if content_length:
        try:
            parsed_length = int(content_length)
        except ValueError:
            return JSONResponse(
                status_code=status.HTTP_400_BAD_REQUEST,
                content={"detail": "Invalid Content-Length header"},
            )
        if parsed_length < 0:
            return JSONResponse(
                status_code=status.HTTP_400_BAD_REQUEST,
                content={"detail": "Invalid Content-Length header"},
            )
        if parsed_length > _MAX_BODY_BYTES:
            return JSONResponse(
                status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
                content={"detail": "Request body too large"},
            )
    return await call_next(request)

enforce_active_writer async

enforce_active_writer(request: Request, call_next)

Reject every mutation when this node is not the durable active writer.

Source code in backend/app/main.py
@app.middleware("http")
async def enforce_active_writer(request: Request, call_next):
    """Reject every mutation when this node is not the durable active writer."""

    if is_ha_enabled() and request.url.path not in {"/health", "/ha/ready", "/ha/status"}:
        if request.method not in {"POST", "PUT", "PATCH", "DELETE"}:
            if request.url.path.startswith("/api/v1/governance/public"):
                # The published controller notice is immutable, non-sensitive
                # and replicated. Keep it readable during witness transitions.
                return await call_next(request)
            root_ha_status_read = (
                request.method == "GET"
                and request.url.path == "/api/v1/admin/ha/status"
            )
            if not control_witness_ready() and not root_ha_status_read:
                return JSONResponse(
                    status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                    content={"detail": "Live data is paused while service ownership is changing.", "code": "HA_LIVE_READS_PAUSED"},
                    headers={"Cache-Control": "no-store", "Retry-After": "5"},
                )
            return await call_next(request)
        db = SessionLocal()
        try:
            readiness = assess_readiness(db)
        except Exception:
            readiness = None
        finally:
            db.close()
        if readiness is None or not readiness.ready:
            return JSONResponse(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                content={"detail": "Writes are paused while service ownership is changing.", "code": "HA_WRITES_PAUSED"},
                headers={"Cache-Control": "no-store", "Retry-After": "5"},
            )
        try:
            require_write_permit(force_refresh=True)
        except HAWritePermitError:
            return JSONResponse(
                status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
                content={"detail": "Writes are paused because ownership cannot be verified.", "code": "HA_OWNERSHIP_UNVERIFIED"},
                headers={"Cache-Control": "no-store", "Retry-After": "5"},
            )
    return await call_next(request)

enforce_content_type async

enforce_content_type(request: Request, call_next)

Require JSON content type for mutating API requests.

Source code in backend/app/main.py
@app.middleware("http")
async def enforce_content_type(request: Request, call_next):
    """Require JSON content type for mutating API requests."""

    if (
        request.method in _WRITE_METHODS
        and request.url.path.startswith("/api/")
        and request.url.path not in _CONTENT_TYPE_EXEMPT_PATHS
        and not any(request.url.path.startswith(p) for p in _CONTENT_TYPE_EXEMPT_PREFIXES)
    ):
        ct = (request.headers.get("content-type") or "").lower()
        if not ct.startswith("application/json"):
            return JSONResponse(
                status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
                content={"detail": "Content-Type must be application/json"},
            )
    return await call_next(request)

prevent_sensitive_response_caching async

prevent_sensitive_response_caching(request: Request, call_next)

Prevent browser and intermediary storage of authenticated API data.

Source code in backend/app/main.py
@app.middleware("http")
async def prevent_sensitive_response_caching(request: Request, call_next):
    """Prevent browser and intermediary storage of authenticated API data."""
    response = await call_next(request)
    if request.url.path.startswith(_NO_STORE_PREFIXES):
        response.headers["Cache-Control"] = "no-store"
        response.headers["Pragma"] = "no-cache"
    return response

log_requests async

log_requests(request: Request, call_next)

Log only the bounded, purpose-defined request metadata.

Source code in backend/app/main.py
@app.middleware("http")
async def log_requests(request: Request, call_next):
    """Log only the bounded, purpose-defined request metadata."""

    request_id = uuid.uuid4().hex[:12]
    start = time.perf_counter()
    response = await call_next(request)
    elapsed_ms = (time.perf_counter() - start) * 1000
    _request_logger.info(json.dumps({
        "duration_ms": round(elapsed_ms),
        "event": "request.completed",
        "method": request.method,
        "path": request.url.path,
        "request_id": request_id,
        "status": response.status_code,
        "subject_ref": getattr(request.state, "subject_ref", None),
    }, separators=(",", ":"), sort_keys=True))
    response.headers["X-Request-ID"] = request_id
    return response

health_check async

health_check()

Liveness / readiness probe.

Source code in backend/app/main.py
@app.get("/health", tags=["health"])
async def health_check():
    """Liveness / readiness probe."""
    db_ok = False
    try:
        db = SessionLocal()
        db.execute(text("SELECT 1"))
        db.close()
        db_ok = True
    except Exception:
        pass
    payload = {"status": "ok" if db_ok else "degraded", "version": app.version, "db": db_ok}
    if not db_ok:
        return JSONResponse(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            content=payload,
            headers={"Cache-Control": "no-store", "Retry-After": "5"},
        )
    return payload

ha_ready async

ha_ready()

Return 200 only for the current writable cluster generation.

Source code in backend/app/main.py
@app.get("/ha/ready", tags=["health"], response_class=PlainTextResponse)
async def ha_ready():
    """Return 200 only for the current writable cluster generation."""

    db = SessionLocal()
    try:
        readiness = assess_readiness(db)
        if readiness.ready:
            record_heartbeat(db)
            return PlainTextResponse(
                "ready\n",
                status_code=status.HTTP_200_OK,
                headers={"Cache-Control": "no-store"},
            )
    except Exception:
        pass
    finally:
        db.close()
    return PlainTextResponse(
        "unavailable\n",
        status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
        headers={"Cache-Control": "no-store", "Retry-After": "5"},
    )

ha_status async

ha_status()

Expose a sanitised status shell without requiring database ownership.

Source code in backend/app/main.py
@app.get("/ha/status", tags=["health"])
async def ha_status():
    """Expose a sanitised status shell without requiring database ownership."""

    return JSONResponse(
        status_code=status.HTTP_200_OK,
        content=public_service_status(),
        headers={"Cache-Control": "no-store"},
    )

startup_event async

startup_event()

Create tables, seed admin, housekeep.

Source code in backend/app/main.py
async def startup_event():
    """Create tables, seed admin, housekeep."""
    if engine.dialect.name == "postgresql":
        with engine.connect() as connection:
            in_recovery = connection.execute(text("SELECT pg_is_in_recovery()")).scalar()
        if in_recovery:
            raise RuntimeError("The application backend refuses a recovery database")
        # Both symmetric nodes keep the backend available for health checks.
        # Request and commit fencing, rather than process startup, decides
        # which one may expose application data or accept writes.

    # Import models so SQLAlchemy sees them
    from app.models.ha import HAClusterState, HAProtectionOperation  # noqa
    from app.models.event import Event  # noqa
    from app.models.published import (  # noqa
        PublishedTask,
        PublishedPerson,
        PublishedPersonUnavailability,
        TaskEdit,
        PublishSnapshot,
        PublishedGeneralScheduleCategory,
        PublishedGeneralScheduleItem,
        GeneralSchedulePublishState,
    )
    from app.models.user import (  # noqa
        User, WebAuthnCredential, PasskeyChallenge, PasskeyCeremony,
        ExchangeCode, AuthSession, ActivationLink, ActivationEmailDelivery,
    )
    from app.models.notification import PushSubscription, Announcement, ScheduleChange  # noqa
    from app.models.server_setting import ServerSetting  # noqa
    from app.models.audit import AuditLog  # noqa
    from app.models.public_schedule_link import (  # noqa
        PublicScheduleLink,
        PublicScheduleLinkView,
    )
    from app.models.governance import (  # noqa
        DataPolicyAcknowledgement,
        GovernancePublication,
        InstanceGovernanceProfile,
    )
    from app.models.deletion import (  # noqa
        DeletionApprovalChallenge,
        DeletionChecklistApproval,
        DeletionCase,
        DeletionSubjectScope,
        DesktopDeletionWorkOrder,
    )
    from app.models.evidence import (  # noqa
        BackupInventoryRecord,
        EvidenceChainState,
        EvidenceKey,
        EvidenceKeyRegistrationChallenge,
        EvidenceOperation,
        EvidenceArchiveSubmission,
        PrivacyActionReceipt,
    )
    from app.models.retention import RetentionSchedulerState  # noqa

    # A peer process stays healthy enough for monitoring and promotion, but it
    # must not perform *any* schema, cleanup or bootstrap writes. Committed
    # migrations prepare both local databases during deployment. It must still
    # verify the replicated database/evidence pair before reporting healthy.
    if is_ha_enabled() and not control_witness_ready():
        verification_db = SessionLocal()
        try:
            from app.core.evidence import verify_existing
            verify_existing(verification_db)
        finally:
            verification_db.rollback()
            verification_db.close()
        print("[Startup] Schema verification and housekeeping skipped on a non-holder HA node")
        return

    # create_all is an engine-level DDL operation and therefore does not pass
    # through the Session commit fence. Require an online permit explicitly.
    require_write_permit(force_refresh=True)
    Base.metadata.create_all(bind=engine)
    print("[Startup] Database tables created / verified")

    db = SessionLocal()
    try:
        # Create default root admin
        from app.core.security import create_default_admin
        create_default_admin(db)

        # Evidence initialisation is idempotent and mandatory.
        from app.core.evidence import initialise as initialise_evidence
        initialise_evidence(db)

        from app.core.retention import run_retention_cycle

        counts = run_retention_cycle(db)
        print(
            "[Startup] Retention cycle complete "
            f"({sum(counts.values())} bounded action(s))"
        )

    except Exception as exc:
        logging.getLogger(__name__).critical(
            "Startup initialisation failed (%s)",
            type(exc).__name__,
        )
        raise
    finally:
        db.close()

root async

root()

Serve the static frontend when bundled, otherwise return API metadata.

Source code in backend/app/main.py
@app.get("/")
async def root():
    """Serve the static frontend when bundled, otherwise return API metadata."""

    _static = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "static")
    _index = os.path.join(_static, "index.html")
    if os.path.isfile(_index):
        from starlette.responses import FileResponse
        return FileResponse(_index)
    return {"message": "Masterplan Calendar API", "version": "1.0.0"}

API Router

router

API v1 Router - wire all sub-routers.

Activation

activation

Activation-token validation for passkey registration.

ActivationValidateResponse

Bases: BaseModel

Response returned when an activation token is checked.

Source code in backend/app/api/v1/activation.py
class ActivationValidateResponse(BaseModel):
    """Response returned when an activation token is checked."""

    valid: bool
    username: Optional[str] = None
    display_name: Optional[str] = None
    purpose: Optional[
        Literal["initial_setup", "additional_passkey", "credential_reset"]
    ] = None
    logo_color_1: Optional[str] = None
    logo_color_2: Optional[str] = None

ActivationTokenRequest

Bases: BaseModel

Activation token submitted without exposing it in the request URL.

Source code in backend/app/api/v1/activation.py
class ActivationTokenRequest(BaseModel):
    """Activation token submitted without exposing it in the request URL."""

    token: str = Field(..., min_length=20, max_length=256)

validate_token

validate_token(body: ActivationTokenRequest, request: Request, db: Session = Depends(get_db))

Check whether an activation token is valid.

Source code in backend/app/api/v1/activation.py
@router.post("/validate", response_model=ActivationValidateResponse)
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=client_ip_rate_key,
)
def validate_token(
    body: ActivationTokenRequest,
    request: Request,
    db: Session = Depends(get_db),
):
    """Check whether an activation token is valid."""
    link = validate_activation_token(body.token, db)
    if link is None:
        return ActivationValidateResponse(valid=False)

    user = db.query(User).filter(User.id == link.user_id).first()
    if not user or not user.is_active:
        return ActivationValidateResponse(valid=False)

    return ActivationValidateResponse(
        valid=True,
        username=user.username,
        display_name=user.display_name,
        purpose=link.purpose,
    )

Admin

admin

Admin endpoints - event CRUD (with secret generation) and user management.

EventCreateIn

Bases: BaseModel

Admin payload for creating a server event.

Source code in backend/app/api/v1/admin.py
class EventCreateIn(BaseModel):
    """Admin payload for creating a server event."""

    name: str = Field(..., min_length=1, max_length=128)
    evidence_id: str = Field(
        default_factory=lambda: str(uuid.uuid4()),
        pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
    )
    location: Optional[str] = Field(None, max_length=256)
    start_date: Optional[date] = None
    end_date: Optional[date] = None
    policy_version: Optional[int] = Field(None, ge=1)
    policy_sha256: Optional[str] = Field(None, pattern=r"^[0-9a-f]{64}$")
    publish_secret: str = Field(..., min_length=32, max_length=128)
    idempotency_key: str = Field(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")

    @model_validator(mode="after")
    def validate_date_range(self) -> "EventCreateIn":
        require_valid_event_date_range(self.start_date, self.end_date)
        return self

EventOut

Bases: BaseModel

Event record returned to administration screens.

Source code in backend/app/api/v1/admin.py
class EventOut(BaseModel):
    """Event record returned to administration screens."""

    id: int
    evidence_id: str
    name: str
    location: Optional[str] = None
    start_date: Optional[str] = None
    end_date: Optional[str] = None
    status: str
    purge_grace_days: Optional[int] = None
    purge_due_at: Optional[datetime] = None
    purge_case_request_id: Optional[str] = None
    purge_started_at: Optional[datetime] = None
    created_at: Optional[datetime] = None
    secret_created_at: Optional[datetime] = None
    secret_age_days: Optional[int] = None
    logo_color_1: Optional[str] = None
    logo_color_2: Optional[str] = None
    protection_operation_id: Optional[str] = None
    protection_state: Optional[str] = None
    protection_stage: Optional[str] = None

    model_config = ConfigDict(from_attributes=True)

EventCreateResponse

Bases: BaseModel

Event creation response that includes the one-time publish secret.

Source code in backend/app/api/v1/admin.py
class EventCreateResponse(BaseModel):
    """Event creation response that includes the one-time publish secret."""

    event: EventOut
    publish_secret: Optional[str] = None
    protection_operation_id: Optional[str] = None
    protection_state: Optional[str] = None
    protection_stage: Optional[str] = None

WebEditItemOut

Bases: BaseModel

One committed web edit shown in the operations review list.

Source code in backend/app/api/v1/admin.py
class WebEditItemOut(BaseModel):
    """One committed web edit shown in the operations review list."""

    task_id: int
    task_name: str
    day: Optional[str] = None
    start: Optional[datetime] = None
    end: Optional[datetime] = None
    location: Optional[str] = None
    edited_at: Optional[datetime] = None
    edited_by: Optional[str] = None
    edited_by_user_id: Optional[int] = None
    change_summary: List[str] = Field(default_factory=list)
    original_summary: str
    current_summary: str

WebEditSummaryOut

Bases: BaseModel

Event-level web-edit confidence state for admins and issuers.

Source code in backend/app/api/v1/admin.py
class WebEditSummaryOut(BaseModel):
    """Event-level web-edit confidence state for admins and issuers."""

    level: str
    edited_task_count: int
    last_edited_at: Optional[datetime] = None
    last_edited_by: Optional[str] = None
    has_published_baseline: bool
    headline: str
    description: str
    items: List[WebEditItemOut] = Field(default_factory=list)

RevertWebEditRequest

Bases: BaseModel

Bulk revert request for committed server web edits.

Source code in backend/app/api/v1/admin.py
class RevertWebEditRequest(BaseModel):
    """Bulk revert request for committed server web edits."""

    task_ids: Optional[List[int]] = None
    revert_all: bool = False

RevertWebEditResultOut

Bases: BaseModel

Result returned after reverting one or more web edits.

Source code in backend/app/api/v1/admin.py
class RevertWebEditResultOut(BaseModel):
    """Result returned after reverting one or more web edits."""

    success: bool
    reverted_count: int
    remaining_web_edit_count: int
    message: str
    task_id: Optional[int] = None

UserCreateIn

Bases: BaseModel

Admin payload for creating an event-scoped user.

Source code in backend/app/api/v1/admin.py
class UserCreateIn(BaseModel):
    """Admin payload for creating an event-scoped user."""

    username: str = Field(..., min_length=1, max_length=64)
    display_name: str = Field(..., min_length=1, max_length=128)
    email: Optional[EmailStr] = None
    event_id: Optional[int] = None
    is_admin: bool = False
    is_issuer: bool = False
    can_edit: bool = False
    tags: List[str] = Field(default_factory=list, max_length=100)

BulkUserCreateRowIn

Bases: BaseModel

One editable row in a bulk user creation request.

Source code in backend/app/api/v1/admin.py
class BulkUserCreateRowIn(BaseModel):
    """One editable row in a bulk user creation request."""

    username: str = Field(..., min_length=1, max_length=64)
    display_name: str = Field(..., min_length=1, max_length=128)
    email: Optional[EmailStr] = None
    can_edit: bool = False
    tags: List[str] = Field(default_factory=list, max_length=100)

BulkUserCreateIn

Bases: BaseModel

Event-scoped request for creating multiple ordinary users at once.

Source code in backend/app/api/v1/admin.py
class BulkUserCreateIn(BaseModel):
    """Event-scoped request for creating multiple ordinary users at once."""

    event_id: Optional[int] = None
    bulk_tags: List[str] = Field(default_factory=list, max_length=100)
    users: List[BulkUserCreateRowIn] = Field(..., min_length=1, max_length=200)

UserOut

Bases: BaseModel

User record returned to administration screens.

Source code in backend/app/api/v1/admin.py
class UserOut(BaseModel):
    """User record returned to administration screens."""

    id: int
    username: str
    display_name: str
    email: Optional[str] = None
    is_root_admin: bool
    is_admin: bool
    is_issuer: bool
    can_edit: bool
    is_active: bool
    is_activated: bool
    has_activation_link: bool = False
    last_activation_link_created_at: Optional[datetime] = None
    last_activation_at: Optional[datetime] = None
    activation_email_status: Optional[
        Literal["sending", "accepted", "failed", "unknown", "not_attempted"]
    ] = None
    activation_email_attempted_at: Optional[datetime] = None
    activation_email_accepted_at: Optional[datetime] = None
    activation_email_error_code: Optional[str] = None
    activation_email_error_message: Optional[str] = None
    activation_email_purpose: Optional[
        Literal["initial_setup", "additional_passkey", "credential_reset"]
    ] = None
    has_valid_email: bool = False
    linked_person_id: Optional[int] = None
    event_id: Optional[int] = None
    tags: Optional[List[str]] = None
    last_login_at: Optional[datetime] = None
    created_at: Optional[datetime] = None
    deletion_requested_at: Optional[datetime] = None

    model_config = ConfigDict(from_attributes=True)

UserCreateResponse

Bases: BaseModel

User creation response with the one-time activation URL.

Source code in backend/app/api/v1/admin.py
class UserCreateResponse(BaseModel):
    """User creation response with the one-time activation URL."""

    user: UserOut
    activation_url: str  # One-time link for passkey setup
    expires_at: datetime

BulkUserCreateError

Bases: BaseModel

Row-level failure returned from bulk user creation.

Source code in backend/app/api/v1/admin.py
class BulkUserCreateError(BaseModel):
    """Row-level failure returned from bulk user creation."""

    index: int
    username: Optional[str] = None
    field: str
    message: str

BulkUserCreateResponse

Bases: BaseModel

Partial success response for bulk user creation.

Source code in backend/app/api/v1/admin.py
class BulkUserCreateResponse(BaseModel):
    """Partial success response for bulk user creation."""

    created: List[UserOut]
    errors: List[BulkUserCreateError]

UserUpdateIn

Bases: BaseModel

Partial update payload for an existing user.

Source code in backend/app/api/v1/admin.py
class UserUpdateIn(BaseModel):
    """Partial update payload for an existing user."""

    display_name: Optional[str] = Field(None, max_length=128)
    email: Optional[EmailStr] = None
    is_admin: Optional[bool] = None
    is_issuer: Optional[bool] = None
    can_edit: Optional[bool] = None
    is_active: Optional[bool] = None
    linked_person_id: Optional[int] = Field(None, gt=0)
    event_id: Optional[int] = Field(None, gt=0)
    tags: Optional[List[str]] = Field(None, max_length=100)

UserTagActionIn

Bases: BaseModel

One atomic, explicitly scoped user-tag operation.

Source code in backend/app/api/v1/admin.py
class UserTagActionIn(BaseModel):
    """One atomic, explicitly scoped user-tag operation."""

    action: Literal["add", "remove", "rename", "delete"]
    tag: str = Field(..., min_length=1, max_length=100)
    replacement: Optional[str] = Field(None, min_length=1, max_length=100)
    user_ids: Optional[List[int]] = Field(None, min_length=1, max_length=1000)
    event_id: Optional[int] = Field(None, gt=0)

    @field_validator("tag", "replacement")
    @classmethod
    def clean_tag(cls, value: Optional[str]) -> Optional[str]:
        if value is None:
            return None
        cleaned = value.strip()
        if not cleaned:
            raise ValueError("Tags may not be empty")
        return cleaned

    @field_validator("user_ids")
    @classmethod
    def unique_user_ids(cls, value: Optional[List[int]]) -> Optional[List[int]]:
        if value is None:
            return None
        if len(value) != len(set(value)) or any(user_id <= 0 for user_id in value):
            raise ValueError("User IDs must be unique positive integers")
        return value

    @model_validator(mode="after")
    def validate_scope(self) -> "UserTagActionIn":
        if self.action in {"add", "remove"}:
            if not self.user_ids or self.event_id is not None or self.replacement is not None:
                raise ValueError("Add/remove requires only an explicit user_ids selection")
        elif self.action == "rename":
            if self.event_id is None or not self.replacement or self.user_ids is not None:
                raise ValueError("Rename requires event_id and replacement")
            if self.replacement == self.tag:
                raise ValueError("The replacement tag must be different")
        elif self.event_id is None or self.user_ids is not None or self.replacement is not None:
            raise ValueError("Delete requires only event_id")
        return self

UserTagActionOut

Bases: BaseModel

Public result of an atomic tag operation.

Source code in backend/app/api/v1/admin.py
class UserTagActionOut(BaseModel):
    """Public result of an atomic tag operation."""

    action: str
    affected_user_ids: List[int]
    affected_count: int

BatchActivationLinksIn

Bases: BaseModel

Bounded selection for generating initial activation links.

Source code in backend/app/api/v1/admin.py
class BatchActivationLinksIn(BaseModel):
    """Bounded selection for generating initial activation links."""

    event_id: Optional[int] = Field(None, gt=0)
    user_ids: Optional[List[int]] = Field(None, min_length=1, max_length=1000)

    @field_validator("user_ids")
    @classmethod
    def unique_optional_user_ids(cls, value: Optional[List[int]]) -> Optional[List[int]]:
        """Reject duplicate or non-positive explicit recipients."""

        if value is None:
            return value
        if len(set(value)) != len(value):
            raise ValueError("Each user may only be selected once")
        if any(user_id <= 0 for user_id in value):
            raise ValueError("User IDs must be positive")
        return value

unique_optional_user_ids classmethod

unique_optional_user_ids(value: Optional[List[int]]) -> Optional[List[int]]

Reject duplicate or non-positive explicit recipients.

Source code in backend/app/api/v1/admin.py
@field_validator("user_ids")
@classmethod
def unique_optional_user_ids(cls, value: Optional[List[int]]) -> Optional[List[int]]:
    """Reject duplicate or non-positive explicit recipients."""

    if value is None:
        return value
    if len(set(value)) != len(value):
        raise ValueError("Each user may only be selected once")
    if any(user_id <= 0 for user_id in value):
        raise ValueError("User IDs must be positive")
    return value

BatchActivationLinkSkipped

Bases: BaseModel

Concrete reason why a selected user received no manual link.

Source code in backend/app/api/v1/admin.py
class BatchActivationLinkSkipped(BaseModel):
    """Concrete reason why a selected user received no manual link."""

    user_id: int
    display_name: str
    error_code: str
    message: str

BatchActivationLinkItem

Bases: BaseModel

One generated manual link returned only to the requesting administrator.

Source code in backend/app/api/v1/admin.py
class BatchActivationLinkItem(BaseModel):
    """One generated manual link returned only to the requesting administrator."""

    user_id: int
    username: str
    display_name: str
    activation_url: str
    expires_at: datetime
    purpose: Literal["initial_setup"] = "initial_setup"

BatchActivationLinksOut

Bases: BaseModel

Generated manual links and concrete reasons for every exclusion.

Source code in backend/app/api/v1/admin.py
class BatchActivationLinksOut(BaseModel):
    """Generated manual links and concrete reasons for every exclusion."""

    links: List[BatchActivationLinkItem]
    count: int
    skipped: List[BatchActivationLinkSkipped]

ActivationQrCodeItemIn

Bases: BaseModel

One raw manual token paired with the user it was generated for.

Source code in backend/app/api/v1/admin.py
class ActivationQrCodeItemIn(BaseModel):
    """One raw manual token paired with the user it was generated for."""

    user_id: int = Field(..., gt=0)
    token: str = Field(..., min_length=1, max_length=256)

ActivationQrCodesIn

Bases: BaseModel

Bounded explicit selection for canonical activation QR downloads.

Source code in backend/app/api/v1/admin.py
class ActivationQrCodesIn(BaseModel):
    """Bounded explicit selection for canonical activation QR downloads."""

    items: List[ActivationQrCodeItemIn] = Field(..., min_length=1, max_length=50)

    @field_validator("items")
    @classmethod
    def unique_users(cls, value: List[ActivationQrCodeItemIn]):
        """Reject ambiguous duplicate users before rendering any artwork."""

        user_ids = [item.user_id for item in value]
        if len(set(user_ids)) != len(user_ids):
            raise ValueError("Each user may only be selected once")
        return value

unique_users classmethod

unique_users(value: List[ActivationQrCodeItemIn])

Reject ambiguous duplicate users before rendering any artwork.

Source code in backend/app/api/v1/admin.py
@field_validator("items")
@classmethod
def unique_users(cls, value: List[ActivationQrCodeItemIn]):
    """Reject ambiguous duplicate users before rendering any artwork."""

    user_ids = [item.user_id for item in value]
    if len(set(user_ids)) != len(user_ids):
        raise ValueError("Each user may only be selected once")
    return value

ActivationEmailIn

Bases: BaseModel

Optional retry metadata for one explicit activation-email send.

Source code in backend/app/api/v1/admin.py
class ActivationEmailIn(BaseModel):
    """Optional retry metadata for one explicit activation-email send."""

    retry_of_delivery_id: Optional[int] = Field(None, gt=0)
    purpose: Optional[ManagedPasskeyPurpose] = None

ActivationLinkIn

Bases: BaseModel

Optional operation for a manually distributed active-account link.

Source code in backend/app/api/v1/admin.py
class ActivationLinkIn(BaseModel):
    """Optional operation for a manually distributed active-account link."""

    purpose: Optional[ManagedPasskeyPurpose] = None

ActivationLinkOut

Bases: BaseModel

One manually distributed link and its resolved registration purpose.

Source code in backend/app/api/v1/admin.py
class ActivationLinkOut(BaseModel):
    """One manually distributed link and its resolved registration purpose."""

    activation_url: str
    expires_at: datetime
    purpose: Literal["initial_setup", "additional_passkey", "credential_reset"]

BatchActivationEmailsIn

Bases: BaseModel

Explicit bounded selection for immediate activation-email delivery.

Source code in backend/app/api/v1/admin.py
class BatchActivationEmailsIn(BaseModel):
    """Explicit bounded selection for immediate activation-email delivery."""

    user_ids: List[int] = Field(..., min_length=1, max_length=50)

    @field_validator("user_ids")
    @classmethod
    def unique_user_ids(cls, value: List[int]) -> List[int]:
        """Reject ambiguous duplicate recipients rather than sending twice."""

        if len(set(value)) != len(value):
            raise ValueError("Each user may only be selected once")
        if any(user_id <= 0 for user_id in value):
            raise ValueError("User IDs must be positive")
        return value

unique_user_ids classmethod

unique_user_ids(value: List[int]) -> List[int]

Reject ambiguous duplicate recipients rather than sending twice.

Source code in backend/app/api/v1/admin.py
@field_validator("user_ids")
@classmethod
def unique_user_ids(cls, value: List[int]) -> List[int]:
    """Reject ambiguous duplicate recipients rather than sending twice."""

    if len(set(value)) != len(value):
        raise ValueError("Each user may only be selected once")
    if any(user_id <= 0 for user_id in value):
        raise ValueError("User IDs must be positive")
    return value

ActivationEmailResult

Bases: BaseModel

Safe per-user result for an immediate SMTP attempt.

Source code in backend/app/api/v1/admin.py
class ActivationEmailResult(BaseModel):
    """Safe per-user result for an immediate SMTP attempt."""

    user_id: int
    display_name: str
    email: Optional[str] = None
    status: Literal[
        "sending", "accepted", "failed", "unknown", "skipped", "not_attempted"
    ]
    message: str
    delivery_id: Optional[int] = None
    error_code: Optional[str] = None
    expires_at: Optional[datetime] = None
    purpose: Literal["initial_setup", "additional_passkey", "credential_reset"]

BatchActivationEmailsOut

Bases: BaseModel

Per-recipient results and summary counts for a selected batch.

Source code in backend/app/api/v1/admin.py
class BatchActivationEmailsOut(BaseModel):
    """Per-recipient results and summary counts for a selected batch."""

    results: List[ActivationEmailResult]
    counts: dict[str, int]

ActivationEmailDeliveryOut

Bases: BaseModel

Non-secret activation-email delivery history entry.

Source code in backend/app/api/v1/admin.py
class ActivationEmailDeliveryOut(BaseModel):
    """Non-secret activation-email delivery history entry."""

    id: int
    activation_link_id: Optional[int] = None
    retry_of_id: Optional[int] = None
    recipient_email: str
    status: Literal["sending", "accepted", "failed", "unknown", "not_attempted"]
    error_code: Optional[str] = None
    error_message: Optional[str] = None
    includes_qr: bool
    started_at: Optional[datetime] = None
    completed_at: Optional[datetime] = None
    purpose: Literal["initial_setup", "additional_passkey", "credential_reset"]

ImportUserIn

Bases: BaseModel

Imported user record from a desktop setup export.

Source code in backend/app/api/v1/admin.py
class ImportUserIn(BaseModel):
    """Imported user record from a desktop setup export."""

    username: str = Field(..., max_length=64)
    display_name: str = Field(..., max_length=128)
    email: Optional[EmailStr] = None
    can_edit: bool = False
    person_id: Optional[int] = Field(None, gt=0)  # Desktop Person.id for auto-linking
    evidence_subject_id: str = Field(
        pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
    )

    model_config = ConfigDict(extra="forbid")

ImportEventIn

Bases: BaseModel

Imported event metadata from a desktop setup export.

Source code in backend/app/api/v1/admin.py
class ImportEventIn(BaseModel):
    """Imported event metadata from a desktop setup export."""

    evidence_id: str = Field(
        pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
    )
    name: str = Field(..., max_length=128)
    location: Optional[str] = Field(None, max_length=256)
    start_date: Optional[date] = None
    end_date: Optional[date] = None

    model_config = ConfigDict(extra="forbid")

    @model_validator(mode="after")
    def validate_date_range(self) -> "ImportEventIn":
        require_valid_event_date_range(self.start_date, self.end_date)
        return self

ImportSetupIn

Bases: BaseModel

Bulk setup import payload containing one event and users.

Source code in backend/app/api/v1/admin.py
class ImportSetupIn(BaseModel):
    """Bulk setup import payload containing one event and users."""

    event: ImportEventIn
    users: List[ImportUserIn] = Field(default_factory=list, max_length=1000)
    publish_secret: str = Field(..., min_length=32, max_length=128)
    idempotency_key: str = Field(..., pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")

    model_config = ConfigDict(extra="forbid")

ImportUserOut

Bases: BaseModel

Imported user response with activation URL.

Source code in backend/app/api/v1/admin.py
class ImportUserOut(BaseModel):
    """Imported user response with activation URL."""

    user: UserOut
    activation_url: str

ImportSetupResponse

Bases: BaseModel

Import response with generated publish secret and user links.

Source code in backend/app/api/v1/admin.py
class ImportSetupResponse(BaseModel):
    """Import response with generated publish secret and user links."""

    event: EventOut
    publish_secret: Optional[str] = None
    users: List[ImportUserOut]
    protection_operation_id: Optional[str] = None
    protection_state: Optional[str] = None
    protection_stage: Optional[str] = None

LinkPersonIn

Bases: BaseModel

Manual user-to-person linking payload.

Source code in backend/app/api/v1/admin.py
class LinkPersonIn(BaseModel):
    """Manual user-to-person linking payload."""

    person_id: Optional[int] = None  # external_person_id; None to unlink

SettingsUpdateIn

Bases: BaseModel

Runtime security settings update payload.

Source code in backend/app/api/v1/admin.py
class SettingsUpdateIn(BaseModel):
    """Runtime security settings update payload."""

    settings: dict  # {key: int_value, ...}

HADashboardOut

Bases: BaseModel

Sanitised root-only operational view of the local HA installation.

Source code in backend/app/api/v1/admin.py
class HADashboardOut(BaseModel):
    """Sanitised root-only operational view of the local HA installation."""

    format: Literal["mp-opt-ha-dashboard-v1"]
    observed_at: str
    mode: Literal["standalone", "ha"]
    cluster: dict
    transition: dict
    last_recovery: dict | None = None
    nodes: List[dict] = Field(default_factory=list)
    replication: dict
    recovery: dict
    incidents: List[dict] = Field(default_factory=list)
    incident_groups: List[dict] = Field(default_factory=list)
    incident_summary: dict = Field(default_factory=dict)

TestEmailIn

Bases: BaseModel

Validated recipient for a token-free SMTP configuration test.

Source code in backend/app/api/v1/admin.py
class TestEmailIn(BaseModel):
    """Validated recipient for a token-free SMTP configuration test."""

    recipient: EmailStr

InvalidateAllActivationLinksIn

Bases: BaseModel

Explicit confirmation for global activation-link invalidation.

Source code in backend/app/api/v1/admin.py
class InvalidateAllActivationLinksIn(BaseModel):
    """Explicit confirmation for global activation-link invalidation."""

    confirm: bool = False

AuditLogEntry

Bases: BaseModel

Single audit log entry returned to admins.

Source code in backend/app/api/v1/admin.py
class AuditLogEntry(BaseModel):
    """Single audit log entry returned to admins."""

    id: int
    timestamp: str
    user_id: Optional[int]
    actor_ref: Optional[str]
    action: str
    resource_type: Optional[str]
    resource_id: Optional[int]
    detail: Optional[str]
    outcome: str

    model_config = ConfigDict(from_attributes=True)

AuditLogResponse

Bases: BaseModel

Paginated audit log response.

Source code in backend/app/api/v1/admin.py
class AuditLogResponse(BaseModel):
    """Paginated audit log response."""

    total: int
    page: int
    per_page: int
    entries: List[AuditLogEntry]

reauth_begin

reauth_begin(request: Request, admin: User = Depends(get_current_user_for_commissioning), db: Session = Depends(get_db))

Start a passkey re-authentication challenge for the current account.

Source code in backend/app/api/v1/admin.py
@router.post("/reauth/begin")
@limiter.limit(PASSKEY_COARSE_IP_LIMIT, key_func=client_ip_rate_key)
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=passkey_session_rate_key,
)
def reauth_begin(
    request: Request,
    admin: User = Depends(get_current_user_for_commissioning),
    db: Session = Depends(get_db),
):
    """Start a passkey re-authentication challenge for the current account."""
    options = generate_authentication_options(
        rp_id=settings.WEBAUTHN_RP_ID,
        user_verification=UserVerificationRequirement.REQUIRED,
    )

    auth_session = getattr(admin, "_auth_session", None)
    if auth_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    entry = create_ceremony(
        options.challenge,
        REAUTHENTICATION,
        db,
        user_id=admin.id,
        session_id=auth_session.id,
        ttl_minutes=runtime_settings.get_int("reauth_challenge_ttl_minutes", db),
    )

    return {"options": options_to_json(options), "ceremony_id": entry.id}

reauth_complete

reauth_complete(body: CeremonyCompletion, request: Request, admin: User = Depends(get_current_user_for_commissioning), db: Session = Depends(get_db))

Verify passkey re-authentication and mark the current session.

Source code in backend/app/api/v1/admin.py
@router.post("/reauth/complete")
@limiter.limit(PASSKEY_COARSE_IP_LIMIT, key_func=client_ip_rate_key)
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=passkey_session_rate_key,
)
def reauth_complete(
    body: CeremonyCompletion,
    request: Request,
    admin: User = Depends(get_current_user_for_commissioning),
    db: Session = Depends(get_db),
):
    """Verify passkey re-authentication and mark the current session."""
    auth_session = getattr(admin, "_auth_session", None)
    if auth_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    ceremony = consume_ceremony(
        body.ceremony_id,
        REAUTHENTICATION,
        db,
        user_id=admin.id,
        session_id=auth_session.id,
    )
    try:
        credential_id_bytes = _credential_id(body.credential)
    except HTTPException as exc:
        audit(
            db,
            user=admin,
            action="auth.reauth_failed",
            request=request,
            outcome="denied",
        )
        db.commit()
        raise HTTPException(status_code=400, detail="Re-authentication failed") from exc
    stored_cred = (
        db.query(WebAuthnCredential)
        .filter(
            WebAuthnCredential.credential_id == credential_id_bytes,
            WebAuthnCredential.user_id == admin.id,
        )
        .with_for_update()
        .first()
    )
    if stored_cred is None:
        audit(
            db,
            user=admin,
            action="auth.reauth_failed",
            request=request,
            outcome="denied",
        )
        db.commit()
        raise HTTPException(status_code=400, detail="Re-authentication failed")

    try:
        _verify_user_handle(body.credential, admin.id)
        verification = verify_authentication_response(
            credential=body.credential,
            expected_challenge=base64url_to_bytes(ceremony.challenge),
            expected_rp_id=settings.WEBAUTHN_RP_ID,
            expected_origin=settings.WEBAUTHN_ORIGIN,
            credential_public_key=stored_cred.public_key,
            credential_current_sign_count=stored_cred.sign_count,
            require_user_verification=True,
        )
    except Exception as e:
        logger.warning(
            "Re-authentication failed for uid=%s (%s)",
            admin.id,
            type(e).__name__,
        )
        audit(
            db,
            user=admin,
            action="auth.reauth_failed",
            request=request,
            outcome="denied",
        )
        db.commit()
        raise HTTPException(status_code=400, detail="Re-authentication failed")

    now = datetime.now(timezone.utc)
    stored_cred.sign_count = verification.new_sign_count
    stored_cred.last_used_at = now

    auth_session.reauth_at = now
    audit(db, user=admin, action="auth.reauth", request=request)
    db.commit()

    return {"status": "ok"}

get_ha_protection_operation

get_ha_protection_operation(operation_id: str, admin: User = Depends(require_root_admin_read_only), db: Session = Depends(get_db))

Return the durable status of one standby-protected mutation.

Source code in backend/app/api/v1/admin.py
@router.get("/ha-protection-operations/{operation_id}", response_model=HAProtectionStatusOut)
def get_ha_protection_operation(
    operation_id: str,
    admin: User = Depends(require_root_admin_read_only),
    db: Session = Depends(get_db),
):
    """Return the durable status of one standby-protected mutation."""

    del admin
    try:
        parsed = uuid.UUID(operation_id)
    except ValueError as exc:
        raise HTTPException(status_code=404, detail="Protection operation not found") from exc
    if str(parsed) != operation_id:
        raise HTTPException(status_code=404, detail="Protection operation not found")
    operation = db.query(HAProtectionOperation).filter(HAProtectionOperation.id == operation_id).first()
    if operation is None:
        raise HTTPException(status_code=404, detail="Protection operation not found")
    sync_protection_operation(db, operation)
    db.commit()
    db.refresh(operation)
    return _operation_out(operation)

create_event

create_event(request: Request, response: Response, body: EventCreateIn, admin: User = Depends(require_admin), db: Session = Depends(get_db))

Create a new event. Returns the publish secret ONCE.

Source code in backend/app/api/v1/admin.py
@router.post("/events", response_model=EventCreateResponse)
@limiter.limit("20/minute")
def create_event(
    request: Request,
    response: Response,
    body: EventCreateIn,
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Create a new event. Returns the publish secret ONCE."""
    policy_identity = current_policy_identity(db)
    if policy_identity is not None:
        if body.policy_version is None or body.policy_sha256 is None:
            raise HTTPException(
                status_code=428,
                detail={
                    "code": "data_policy_acknowledgement_required",
                    "policy_version": policy_identity[0],
                    "policy_sha256": policy_identity[1],
                    "message": "Review and acknowledge the current exact permitted-data policy before creating an event.",
                },
            )
        policy_version, policy_sha256 = require_current_policy_identity(
            body.policy_version, body.policy_sha256, db
        )
    secret_hash = hashlib.sha256(body.publish_secret.encode()).hexdigest()
    if settings.HA_MODE == "ha":
        try:
            existing_operation = find_protection_operation(db, body.idempotency_key)
        except ValueError as exc:
            raise HTTPException(status_code=422, detail="Invalid idempotency key") from exc
        if existing_operation is not None:
            if existing_operation.operation_type != "publisher-secret-create":
                raise HTTPException(status_code=409, detail="Idempotency key is already in use")
            event = db.query(Event).filter(Event.id == int(existing_operation.resource_id or 0)).first()
            if event is None or event.publish_secret_hash != secret_hash:
                raise HTTPException(status_code=409, detail="Idempotent event request does not match")
            sync_protection_operation(db, existing_operation)
            db.commit()
            response.status_code = status.HTTP_202_ACCEPTED
            return EventCreateResponse(
                event=_event_out(event, db),
                protection_operation_id=existing_operation.id,
                protection_state=existing_operation.state,
                protection_stage=existing_operation.stage,
            )

    event = Event(
        evidence_id=body.evidence_id,
        name=body.name,
        location=body.location,
        start_date=body.start_date,
        end_date=body.end_date,
        status="draft",
        publish_secret_hash=secret_hash,
        secret_created_at=datetime.now(timezone.utc),
    )
    db.add(event)
    db.flush()
    materialise_event_purge_deadline(event, db)
    if policy_identity is not None:
        db.add(DataPolicyAcknowledgement(
            user_id=admin.id,
            event_id=event.id,
            policy_version=policy_version,
            policy_sha256=policy_sha256,
            scope="event_creator",
        ))
    audit(db, user=admin, action="event.create", resource_type="event",
          resource_id=event.id, request=request)
    protection: HAProtectionOperation | None = None
    try:
        protection = create_protection_operation(
            db,
            idempotency_key=body.idempotency_key,
            operation_type="publisher-secret-create",
            resource_type="event",
            resource_id=str(event.id),
        )
        db.commit()
    except HAWritePermitError as exc:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise HTTPException(
            status_code=503,
            detail={"code": "ha_guard_unavailable", "message": "The standby protection guard is unavailable."},
        ) from exc
    except Exception:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise
    db.refresh(event)
    if protection is not None:
        db.refresh(protection)
        if not queue_protection_operation(protection):
            protection.state = "indeterminate"
            protection.stage = "attention_required"
            protection.error_code = "replication_agent_unavailable"
            db.commit()
        response.status_code = status.HTTP_202_ACCEPTED

    return EventCreateResponse(
        event=_event_out(event, db),
        publish_secret=body.publish_secret if protection is None else None,
        protection_operation_id=protection.id if protection else None,
        protection_state=protection.state if protection else None,
        protection_stage=protection.stage if protection else None,
    )

list_events

list_events(admin: User = Depends(require_admin), db: Session = Depends(get_db))

List all server events for administrators.

Source code in backend/app/api/v1/admin.py
@router.get("/events", response_model=List[EventOut])
def list_events(
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """List all server events for administrators."""

    events = db.query(Event).order_by(Event.created_at.desc()).all()
    now = datetime.now(timezone.utc)
    values = [_event_out(event, db, now=now) for event in events]
    db.commit()
    return values

get_event_web_edits

get_event_web_edits(event_id: int, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Return compact web-edit confidence state for one event.

Source code in backend/app/api/v1/admin.py
@router.get("/events/{event_id}/web-edits", response_model=WebEditSummaryOut)
def get_event_web_edits(
    event_id: int,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Return compact web-edit confidence state for one event."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")
    if _is_issuer_only(admin) and admin.event_id != event_id:
        raise HTTPException(status_code=403, detail="No access to this event")

    summary = derive_web_edit_summary(event_id, db)
    return WebEditSummaryOut(
        level=summary.level,
        edited_task_count=summary.edited_task_count,
        last_edited_at=summary.last_edited_at,
        last_edited_by=summary.last_edited_by,
        has_published_baseline=summary.has_published_baseline,
        headline=summary.headline,
        description=summary.description,
        items=[WebEditItemOut(**item.__dict__) for item in summary.items],
    )

revert_event_web_edit

revert_event_web_edit(event_id: int, task_id: int, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Revert one committed web edit for an event.

Source code in backend/app/api/v1/admin.py
@router.post(
    "/events/{event_id}/web-edits/{task_id}/revert",
    response_model=RevertWebEditResultOut,
)
def revert_event_web_edit(
    event_id: int,
    task_id: int,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Revert one committed web edit for an event."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")
    if _is_issuer_only(admin) and admin.event_id != event_id:
        raise HTTPException(status_code=403, detail="No access to this event")

    audit_entries = _web_edit_audit_entries(event_id, db, [task_id])
    try:
        task_name, remaining = revert_web_edit(event_id, task_id, db)
    except LookupError as exc:
        raise HTTPException(status_code=404, detail=str(exc)) from exc

    audit(
        db,
        user=admin,
        action="web_edit.revert",
        resource_type="published_task",
        resource_id=task_id,
        detail=json.dumps(
            {
                "event_id": event_id,
                "task_id": task_id,
                "tasks": audit_entries,
            }
        ),
        request=request,
    )
    db.commit()
    return RevertWebEditResultOut(
        success=True,
        reverted_count=1,
        remaining_web_edit_count=remaining,
        message=f"{task_name} reverted to the published version.",
        task_id=task_id,
    )

revert_event_web_edits

revert_event_web_edits(event_id: int, body: RevertWebEditRequest, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Revert selected or all committed web edits for an event.

Source code in backend/app/api/v1/admin.py
@router.post(
    "/events/{event_id}/web-edits/revert",
    response_model=RevertWebEditResultOut,
)
def revert_event_web_edits(
    event_id: int,
    body: RevertWebEditRequest,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Revert selected or all committed web edits for an event."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")
    if _is_issuer_only(admin) and admin.event_id != event_id:
        raise HTTPException(status_code=403, detail="No access to this event")
    if not body.revert_all and not body.task_ids:
        raise HTTPException(status_code=400, detail="Select at least one web edit to revert")

    audit_entries = _web_edit_audit_entries(
        event_id,
        db,
        None if body.revert_all else body.task_ids,
    )
    reverted, remaining = revert_web_edits(
        event_id,
        db,
        task_ids=body.task_ids,
        revert_all=body.revert_all,
    )
    audit(
        db,
        user=admin,
        action="web_edit.revert_bulk",
        resource_type="event",
        resource_id=event_id,
        detail=json.dumps(
            {
                "event_id": event_id,
                "task_ids": body.task_ids,
                "revert_all": body.revert_all,
                "reverted_count": reverted,
                "tasks": audit_entries,
            }
        ),
        request=request,
    )
    db.commit()
    return RevertWebEditResultOut(
        success=True,
        reverted_count=reverted,
        remaining_web_edit_count=remaining,
        message=f"{reverted} web edit{'s' if reverted != 1 else ''} reverted.",
    )

regenerate_event_secret

regenerate_event_secret(event_id: int, request: Request, response: Response, body: ProtectedSecretMutationIn, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Regenerate the publish secret for an event. Returns the new secret ONCE.

Source code in backend/app/api/v1/admin.py
@router.post("/events/{event_id}/regenerate-secret", response_model=ProtectedSecretMutationOut)
def regenerate_event_secret(
    event_id: int,
    request: Request,
    response: Response,
    body: ProtectedSecretMutationIn,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Regenerate the publish secret for an event. Returns the new secret ONCE."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")
    if event.purge_case_request_id:
        raise HTTPException(
            status_code=409,
            detail={
                "code": "EVENT_PURGE_IN_PROGRESS",
                "message": "The publish credential is reserved for the pending Desktop deletion report.",
            },
        )

    secret_hash = hashlib.sha256(body.publish_secret.encode()).hexdigest()
    if settings.HA_MODE == "ha":
        existing_operation = find_protection_operation(db, body.idempotency_key)
        if existing_operation is not None:
            if existing_operation.operation_type != "publisher-secret-rotation" or existing_operation.resource_id != str(event.id):
                raise HTTPException(status_code=409, detail="Idempotency key is already in use")
            if event.publish_secret_hash != secret_hash:
                raise HTTPException(status_code=409, detail="Idempotent rotation request does not match")
            sync_protection_operation(db, existing_operation)
            db.commit()
            response.status_code = status.HTTP_202_ACCEPTED
            return ProtectedSecretMutationOut(
                protection_operation_id=existing_operation.id,
                protection_state=existing_operation.state,
                protection_stage=existing_operation.stage,
            )
        pending = db.query(HAProtectionOperation).filter(
            HAProtectionOperation.operation_type == "publisher-secret-rotation",
            HAProtectionOperation.resource_type == "event",
            HAProtectionOperation.resource_id == str(event.id),
            HAProtectionOperation.state.in_(["pending", "indeterminate"]),
        ).first()
        if pending is not None:
            raise HTTPException(
                status_code=409,
                detail={"code": "protection_pending", "operation_id": pending.id},
            )
    event.publish_secret_hash = secret_hash
    event.secret_created_at = datetime.now(timezone.utc)
    audit(db, user=admin, action="event.regenerate_secret", resource_type="event",
          resource_id=event.id, request=request)
    protection: HAProtectionOperation | None = None
    try:
        protection = create_protection_operation(
            db, idempotency_key=body.idempotency_key,
            operation_type="publisher-secret-rotation", resource_type="event",
            resource_id=str(event.id),
        )
        db.commit()
    except HAWritePermitError as exc:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise HTTPException(status_code=503, detail="The standby protection guard is unavailable") from exc
    except Exception:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise
    if protection is not None:
        db.refresh(protection)
        if not queue_protection_operation(protection):
            protection.state = "indeterminate"
            protection.stage = "attention_required"
            protection.error_code = "replication_agent_unavailable"
            db.commit()
        response.status_code = status.HTTP_202_ACCEPTED
    return ProtectedSecretMutationOut(
        publish_secret=body.publish_secret if protection is None else None,
        protection_operation_id=protection.id if protection else None,
        protection_state=protection.state if protection else None,
        protection_stage=protection.stage if protection else None,
    )

create_user

create_user(request: Request, body: UserCreateIn, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Create a new user and return a one-time activation URL.

Source code in backend/app/api/v1/admin.py
@router.post("/users", response_model=UserCreateResponse)
@limiter.limit("20/minute")
def create_user(
    request: Request,
    body: UserCreateIn,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Create a new user and return a one-time activation URL."""
    # Issuer scoping: force own event, block privilege escalation
    if _is_issuer_only(admin):
        body.event_id = admin.event_id
        if body.is_admin or body.is_issuer:
            raise HTTPException(status_code=403, detail="Issuers cannot grant admin or issuer roles")
        body.is_issuer = False

    if body.is_admin and not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Only root admin can grant admin role")
    if body.is_admin or body.is_issuer:
        ensure_recent_reauth(admin, db)

    # Only root can set is_issuer
    if body.is_issuer and not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Only root admin can grant issuer role")

    # Check username uniqueness
    existing = db.query(User).filter(User.username == body.username).first()
    if existing:
        raise HTTPException(status_code=409, detail="Username already taken")

    # Root may prepare an ordinary account before deciding its event. Other
    # operators remain event-scoped, and privileged roles must always have an
    # event so they cannot acquire ambiguous global access.
    if not body.event_id:
        if not admin.is_root_admin:
            raise HTTPException(status_code=422, detail="event_id is required")
        if body.is_admin or body.is_issuer:
            raise HTTPException(status_code=422, detail="Privileged users require an event")
    else:
        event = db.query(Event).filter(Event.id == body.event_id).first()
        if not event:
            raise HTTPException(status_code=404, detail="Event not found")

    user = User(
        username=body.username,
        display_name=body.display_name,
        email=str(body.email) if body.email else None,
        event_id=body.event_id,
        is_admin=body.is_admin,
        is_issuer=body.is_issuer,
        can_edit=body.can_edit,
        is_active=True,
        is_activated=False,
        tags=_normalise_tags(body.tags),
    )
    db.add(user)
    db.commit()
    db.refresh(user)

    audit(db, user=admin, action="user.create", resource_type="user",
          resource_id=user.id, request=request)

    # Create activation link
    raw_token, _link = create_activation_link(
        user_id=user.id,
        created_by_id=admin.id,
        db=db,
    )
    db.commit()

    activation_url = f"/activate#token={raw_token}"

    return UserCreateResponse(
        user=_user_out(user),
        activation_url=activation_url,
        expires_at=_ensure_aware_utc(_link.expires_at),
    )

bulk_create_users

bulk_create_users(request: Request, body: BulkUserCreateIn, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Create multiple ordinary users for one event, returning row errors.

Source code in backend/app/api/v1/admin.py
@router.post("/users/bulk", response_model=BulkUserCreateResponse)
@limiter.limit("20/minute")
def bulk_create_users(
    request: Request,
    body: BulkUserCreateIn,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Create multiple ordinary users for one event, returning row errors."""

    event_id = body.event_id
    if _is_issuer_only(admin):
        event_id = admin.event_id

    if not event_id:
        raise HTTPException(status_code=422, detail="event_id is required")

    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")

    created: list[UserOut] = []
    errors: list[BulkUserCreateError] = []
    seen_usernames: set[str] = set()
    existing_usernames = {
        username
        for (username,) in db.query(User.username)
        .filter(User.username.in_([row.username for row in body.users]))
        .all()
    }

    for index, row in enumerate(body.users):
        username = row.username.strip()
        display_name = row.display_name.strip()
        if not username:
            errors.append(BulkUserCreateError(
                index=index, username=row.username, field="username",
                message="Username is required",
            ))
            continue
        if not display_name:
            errors.append(BulkUserCreateError(
                index=index, username=username, field="display_name",
                message="Display name is required",
            ))
            continue
        if username in seen_usernames:
            errors.append(BulkUserCreateError(
                index=index, username=username, field="username",
                message="Username is duplicated in this batch",
            ))
            continue
        seen_usernames.add(username)
        if username in existing_usernames:
            errors.append(BulkUserCreateError(
                index=index, username=username, field="username",
                message="Username already taken",
            ))
            continue

        user = User(
            username=username,
            display_name=display_name,
            email=str(row.email) if row.email else None,
            event_id=event_id,
            is_admin=False,
            is_issuer=False,
            can_edit=row.can_edit,
            is_active=True,
            is_activated=False,
            tags=_normalise_tags(body.bulk_tags, row.tags),
        )
        db.add(user)
        try:
            db.commit()
        except IntegrityError:
            db.rollback()
            errors.append(BulkUserCreateError(
                index=index, username=username, field="username",
                message="Username already taken",
            ))
            existing_usernames.add(username)
            continue
        db.refresh(user)
        existing_usernames.add(username)
        created.append(_user_out(user))

    if created:
        audit(
            db,
            user=admin,
            action="user.create_bulk",
            resource_type="user",
            detail=json.dumps({
                "event_id": event_id,
                "created_user_ids": [user.id for user in created],
                "error_count": len(errors),
            }),
            request=request,
        )
        db.commit()

    return BulkUserCreateResponse(created=created, errors=errors)

list_users

list_users(event_id: Optional[int] = None, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

List users visible to the current admin or issuer.

Source code in backend/app/api/v1/admin.py
@router.get("/users", response_model=List[UserOut])
def list_users(
    event_id: Optional[int] = None,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """List users visible to the current admin or issuer."""

    query = db.query(User).filter(User.is_root_admin == False)
    # Issuer scoping: force filter to own event
    if _is_issuer_only(admin):
        query = query.filter(
            User.event_id == admin.event_id,
            User.is_admin == False,  # noqa: E712
            User.is_issuer == False,  # noqa: E712
        )
    elif event_id is not None:
        query = query.filter(User.event_id == event_id)
    users = query.order_by(User.created_at.desc()).all()
    link_meta = _activation_link_metadata([u.id for u in users], db)

    return [
        UserOut(
            id=u.id,
            username=u.username,
            display_name=u.display_name,
            email=u.email,
            is_root_admin=u.is_root_admin,
            is_admin=u.is_admin,
            is_issuer=u.is_issuer,
            can_edit=u.can_edit,
            is_active=u.is_active,
            is_activated=u.is_activated,
            has_activation_link=bool(link_meta.get(u.id, {}).get("has_activation_link")),
            last_activation_link_created_at=link_meta.get(u.id, {}).get("last_activation_link_created_at"),
            last_activation_at=link_meta.get(u.id, {}).get("last_activation_at"),
            activation_email_status=link_meta.get(u.id, {}).get("activation_email_status"),
            activation_email_attempted_at=link_meta.get(u.id, {}).get("activation_email_attempted_at"),
            activation_email_accepted_at=link_meta.get(u.id, {}).get("activation_email_accepted_at"),
            activation_email_error_code=link_meta.get(u.id, {}).get("activation_email_error_code"),
            activation_email_error_message=link_meta.get(u.id, {}).get("activation_email_error_message"),
            activation_email_purpose=link_meta.get(u.id, {}).get("activation_email_purpose"),
            has_valid_email=_has_valid_email(u.email),
            linked_person_id=u.linked_person_id,
            event_id=u.event_id,
            tags=u.tags or [],
            last_login_at=u.last_login_at,
            created_at=u.created_at,
            deletion_requested_at=u.deletion_requested_at,
        )
        for u in users
    ]

apply_user_tag_action

apply_user_tag_action(body: UserTagActionIn, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Apply one atomic tag action without issuing per-user requests.

Source code in backend/app/api/v1/admin.py
@router.put("/user-tags/actions", response_model=UserTagActionOut)
def apply_user_tag_action(
    body: UserTagActionIn,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Apply one atomic tag action without issuing per-user requests."""

    query = db.query(User).filter(User.is_root_admin == False)  # noqa: E712
    if body.action in {"add", "remove"}:
        query = query.filter(User.id.in_(body.user_ids or []))
    else:
        event_id = admin.event_id if _is_issuer_only(admin) else body.event_id
        if _is_issuer_only(admin) and body.event_id != admin.event_id:
            raise HTTPException(status_code=403, detail="Issuers may only manage their own event")
        query = query.filter(User.event_id == event_id)

    targets = query.order_by(User.id.asc()).all()
    if body.action in {"add", "remove"} and len(targets) != len(body.user_ids or []):
        raise HTTPException(status_code=404, detail="One or more selected users were not found")
    for target in targets:
        require_user_management_access(target, admin)

    affected: list[int] = []
    for target in targets:
        current = _normalise_tags(target.tags or [])
        if body.action == "add":
            updated = _normalise_tags(current, [body.tag])
        elif body.action in {"remove", "delete"}:
            updated = [tag for tag in current if tag != body.tag]
        else:
            updated = _normalise_tags(
                [body.replacement if tag == body.tag else tag for tag in current]
            )
        if updated != current:
            target.tags = updated
            affected.append(target.id)

    audit(
        db,
        user=admin,
        action="user.tags_bulk_update",
        resource_type="user",
        detail=json.dumps({
            "operation": body.action,
            "event_id": body.event_id,
            "selected_count": len(body.user_ids or []),
            "affected_count": len(affected),
        }),
        request=request,
    )
    db.commit()
    return UserTagActionOut(
        action=body.action,
        affected_user_ids=affected,
        affected_count=len(affected),
    )

update_user

update_user(user_id: int, body: UserUpdateIn, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Update a user while enforcing issuer and event boundaries.

Source code in backend/app/api/v1/admin.py
@router.put("/users/{user_id}", response_model=UserOut)
def update_user(
    user_id: int,
    body: UserUpdateIn,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Update a user while enforcing issuer and event boundaries."""

    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)
    if not user.is_active:
        raise HTTPException(status_code=409, detail="Cannot modify a deactivated user")

    # Issuer scoping
    if _is_issuer_only(admin):
        # Block privilege escalation and event reassignment
        if body.is_admin is not None:
            raise HTTPException(status_code=403, detail="Issuers cannot change admin status")
        if body.is_issuer is not None:
            raise HTTPException(status_code=403, detail="Issuers cannot change issuer status")
        if "event_id" in body.model_fields_set and body.event_id != admin.event_id:
            raise HTTPException(status_code=403, detail="Issuers cannot reassign users to other events")

    # Only a recently re-authenticated root may change global roles.
    if body.is_admin is not None and not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Only root admin can change admin status")
    if body.is_issuer is not None and not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Only root admin can change issuer status")
    if body.is_admin is not None or body.is_issuer is not None:
        ensure_recent_reauth(admin, db)

    if body.is_active is not None and body.is_active != user.is_active:
        ensure_recent_reauth(admin, db)

    event_field_supplied = "event_id" in body.model_fields_set
    if event_field_supplied and body.event_id is None:
        if not admin.is_root_admin:
            raise HTTPException(status_code=403, detail="Only root admin can unassign users")
        if user.is_admin or user.is_issuer:
            raise HTTPException(status_code=409, detail="Privileged users require an event")
    if event_field_supplied and body.event_id is not None:
        event = db.query(Event).filter(Event.id == body.event_id).first()
        if event is None:
            raise HTTPException(status_code=404, detail="Event not found")

    event_changed = event_field_supplied and body.event_id != user.event_id
    if event_changed:
        ensure_recent_reauth(admin, db)
    effective_event_id = body.event_id if event_field_supplied else user.event_id
    linked_person = None
    if body.linked_person_id is not None:
        linked_person = (
            db.query(PublishedPerson)
            .filter(
                PublishedPerson.event_id == effective_event_id,
                PublishedPerson.external_person_id == body.linked_person_id,
            )
            .first()
        )
        if linked_person is None:
            raise HTTPException(status_code=404, detail="Person not found in this event")

    changed_fields = [
        field
        for field, value in body.model_dump(exclude_unset=True).items()
        if value != getattr(user, field)
    ]
    if (
        event_changed
        and "linked_person_id" not in body.model_fields_set
        and user.linked_person_id is not None
    ):
        changed_fields.append("linked_person_id")

    if body.display_name is not None:
        user.display_name = body.display_name
    if "email" in body.model_fields_set:
        user.email = str(body.email) if body.email else None
    if body.is_admin is not None:
        user.is_admin = body.is_admin
    if body.is_issuer is not None:
        user.is_issuer = body.is_issuer
    if body.can_edit is not None:
        user.can_edit = body.can_edit
    if body.is_active is not None:
        user.is_active = body.is_active
    if "linked_person_id" in body.model_fields_set:
        user.linked_person_id = body.linked_person_id
        if linked_person is not None:
            user.evidence_subject_id = linked_person.evidence_subject_id
    if event_field_supplied:
        user.event_id = body.event_id
        if event_changed and "linked_person_id" not in body.model_fields_set:
            user.linked_person_id = None
    if body.tags is not None:
        user.tags = [str(tag)[:100] for tag in body.tags[:100]]

    audit(
        db,
        user=admin,
        action="user.update",
        resource_type="user",
        resource_id=user.id,
        detail=json.dumps({"changed_fields": changed_fields}),
        request=request,
    )
    db.commit()
    db.refresh(user)

    if any(
        field in changed_fields
        for field in {"is_admin", "is_issuer", "can_edit", "is_active", "event_id"}
    ):
        revoke_all_user_sessions(user.id, db)

    return UserOut(
        id=user.id,
        username=user.username,
        display_name=user.display_name,
        email=user.email,
        is_root_admin=user.is_root_admin,
        is_admin=user.is_admin,
        is_issuer=user.is_issuer,
        can_edit=user.can_edit,
        is_active=user.is_active,
        is_activated=user.is_activated,
        has_valid_email=_has_valid_email(user.email),
        linked_person_id=user.linked_person_id,
        event_id=user.event_id,
        tags=user.tags or [],
        last_login_at=user.last_login_at,
        created_at=user.created_at,
        deletion_requested_at=user.deletion_requested_at,
    )

delete_user

delete_user(user_id: int, request: Request, admin: User = Depends(require_recent_reauth), db: Session = Depends(get_db))

Remove an unused invitation; used accounts require signed erasure.

Source code in backend/app/api/v1/admin.py
@router.delete("/users/{user_id}")
@limiter.limit("10/minute")
def delete_user(
    user_id: int,
    request: Request,
    admin: User = Depends(require_recent_reauth),
    db: Session = Depends(get_db),
):
    """Remove an unused invitation; used accounts require signed erasure."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)

    blockers = _direct_removal_blockers(db, user)
    if blockers:
        raise HTTPException(
            status_code=409,
            detail={
                "code": "SIGNED_DELETION_REQUIRED",
                "message": (
                    "This account has identity or operational history. Use the "
                    "signed deletion-evidence workflow instead."
                ),
                "blockers": blockers,
            },
        )

    # Invitation-only records carry no completed ceremony or operational act.
    db.query(AuthSession).filter(AuthSession.user_id == user_id).delete(synchronize_session=False)
    db.query(ExchangeCode).filter(ExchangeCode.user_id == user_id).delete(synchronize_session=False)
    db.query(PasskeyChallenge).filter(PasskeyChallenge.user_id == user_id).delete(synchronize_session=False)
    db.query(PasskeyCeremony).filter(PasskeyCeremony.user_id == user_id).delete(synchronize_session=False)
    db.query(WebAuthnCredential).filter(WebAuthnCredential.user_id == user_id).delete(synchronize_session=False)
    db.query(ActivationEmailDelivery).filter(
        ActivationEmailDelivery.user_id == user_id
    ).delete(synchronize_session=False)
    db.query(ActivationLink).filter(
        ActivationLink.created_by_id == user_id
    ).delete(synchronize_session=False)
    db.query(ActivationLink).filter(ActivationLink.user_id == user_id).delete(synchronize_session=False)

    # Older account-creation audit rows may name the invitation in free text.
    # Preserve the administrative fact without retaining the unused identity.
    db.query(AuditLog).filter(
        AuditLog.resource_type == "user", AuditLog.resource_id == user_id
    ).update(
        {
            "resource_id": None,
            "detail": json.dumps({"subject": "unused_invitation"}),
        },
        synchronize_session=False,
    )
    audit(
        db,
        user=admin,
        action="user.delete_unused_invitation",
        resource_type="user",
        resource_id=None,
        detail=json.dumps({"removal_mode": "direct_unused_invitation"}),
        request=request,
    )
    db.delete(user)
    db.commit()
    return {"status": "ok", "removal_mode": "direct_unused_invitation"}
batch_activation_links(body: BatchActivationLinksIn, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Generate links for the exact eligible selection and report exclusions.

Source code in backend/app/api/v1/admin.py
@router.post("/batch-activation-links", response_model=BatchActivationLinksOut)
def batch_activation_links(
    body: BatchActivationLinksIn,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Generate links for the exact eligible selection and report exclusions."""

    event_id = body.event_id
    user_ids = body.user_ids
    if _is_issuer_only(admin):
        if admin.event_id is None:
            raise HTTPException(status_code=403, detail="Issuer has no event access")
        event_id = admin.event_id

    skipped: list[BatchActivationLinkSkipped] = []
    if user_ids is not None:
        users = []
        for user_id in user_ids:
            user = db.query(User).filter(User.id == user_id).first()
            if user is None:
                skipped.append(BatchActivationLinkSkipped(
                    user_id=user_id,
                    display_name="Unknown user",
                    error_code="user_not_found",
                    message="User not found.",
                ))
                continue
            require_user_management_access(user, admin)
            if event_id is not None and user.event_id != event_id:
                skipped.append(BatchActivationLinkSkipped(
                    user_id=user.id,
                    display_name=user.display_name,
                    error_code="event_mismatch",
                    message="This user is not in the selected event.",
                ))
                continue
            if not user.is_active:
                skipped.append(BatchActivationLinkSkipped(
                    user_id=user.id,
                    display_name=user.display_name,
                    error_code="account_inactive",
                    message="This account is deactivated.",
                ))
                continue
            if user.is_activated:
                skipped.append(BatchActivationLinkSkipped(
                    user_id=user.id,
                    display_name=user.display_name,
                    error_code="already_activated",
                    message="This user is already activated.",
                ))
                continue
            users.append(user)
    else:
        query = db.query(User).filter(
            User.is_activated.is_(False),
            User.is_active.is_(True),
            User.is_root_admin.is_(False),
        )
        if not admin.is_root_admin:
            query = query.filter(
                User.is_admin.is_(False),
                User.is_issuer.is_(False),
            )
        if event_id is not None:
            query = query.filter(User.event_id == event_id)
        users = query.all()

    results = []
    for u in users:
        try:
            raw_token, _link = create_activation_link(
                user_id=u.id,
                created_by_id=admin.id,
                db=db,
                purpose="initial_setup",
            )
        except ActivationDeliveryInProgressError:
            skipped.append(BatchActivationLinkSkipped(
                user_id=u.id,
                display_name=u.display_name,
                error_code="delivery_in_progress",
                message="An activation email is currently being handed off.",
            ))
            continue
        results.append({
            "user_id": u.id,
            "username": u.username,
            "display_name": u.display_name,
            "activation_url": f"/activate#token={raw_token}",
            "expires_at": _ensure_aware_utc(_link.expires_at),
        })
    audit(
        db,
        user=admin,
        action="activation.create_batch",
        resource_type="user",
        detail=json.dumps({"user_ids": [user.id for user in users]}),
        request=request,
    )
    db.commit()
    return {
        "links": results,
        "count": len(results),
        "skipped": [item.model_dump() for item in skipped],
    }

download_activation_qr_codes

download_activation_qr_codes(body: ActivationQrCodesIn, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Return canonical QR PNGs for valid manual links in one ZIP archive.

Raw tokens arrive only in the protected request body. They are validated against their stored hashes and are never included in logs, audit detail, filenames, database fields, or error responses.

Source code in backend/app/api/v1/admin.py
@router.post("/activation-qr-codes")
def download_activation_qr_codes(
    body: ActivationQrCodesIn,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Return canonical QR PNGs for valid manual links in one ZIP archive.

    Raw tokens arrive only in the protected request body. They are validated
    against their stored hashes and are never included in logs, audit detail,
    filenames, database fields, or error responses.
    """

    resolved: list[tuple[ActivationQrCodeItemIn, User, ActivationLink]] = []
    for item in body.items:
        link = validate_activation_token(item.token, db)
        if link is None or link.user_id != item.user_id:
            raise HTTPException(
                status_code=400,
                detail=(
                    "One or more activation links are no longer available. "
                    "Generate fresh links and try again."
                ),
            )
        user = db.query(User).filter(User.id == item.user_id).first()
        if user is None or not user.is_active:
            raise HTTPException(
                status_code=400,
                detail=(
                    "One or more activation links are no longer available. "
                    "Generate fresh links and try again."
                ),
            )
        require_user_management_access(user, admin)
        resolved.append((item, user, link))

    archive = io.BytesIO()
    with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as output:
        for item, user, link in resolved:
            png = render_activation_qr_png(
                absolute_activation_url(item.token),
                user.display_name,
                link.purpose,
            )
            output.writestr(
                _activation_qr_filename(user.display_name, user.id),
                png,
            )

    audit(
        db,
        user=admin,
        action="activation.qr_download",
        resource_type="user",
        detail=json.dumps({"user_ids": [item.user_id for item in body.items]}),
        request=request,
    )
    db.commit()
    return Response(
        content=archive.getvalue(),
        media_type="application/zip",
        headers={
            "Content-Disposition": 'attachment; filename="activation-qr-codes.zip"',
            "Cache-Control": "no-store",
            "Pragma": "no-cache",
        },
    )

get_activation_delivery_settings

get_activation_delivery_settings(admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Return safe activation delivery capability and effective validity.

Source code in backend/app/api/v1/admin.py
@router.get("/activation-delivery/settings")
def get_activation_delivery_settings(
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Return safe activation delivery capability and effective validity."""

    result = safe_mail_settings()
    result["expiry_hours"] = runtime_settings.get_int(
        "activation_link_expiry_hours",
        db,
    )
    return result

send_user_activation_email

send_user_activation_email(user_id: int, request: Request, body: ActivationEmailIn | None = None, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Generate and immediately email a fresh activation link to one user.

Source code in backend/app/api/v1/admin.py
@router.post(
    "/users/{user_id}/activation-email",
    response_model=ActivationEmailResult,
)
@limiter.limit("10/minute")
def send_user_activation_email(
    user_id: int,
    request: Request,
    body: ActivationEmailIn | None = None,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Generate and immediately email a fresh activation link to one user."""

    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)
    requested_purpose = body.purpose if body else None
    try:
        purpose = resolve_activation_purpose(
            is_activated=user.is_activated,
            requested=requested_purpose,
        )
    except ValueError as exc:
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    if not user.is_active:
        return ActivationEmailResult(
            user_id=user.id,
            display_name=user.display_name,
            email=user.email,
            status="skipped",
            message="This account is deactivated.",
            error_code="account_inactive",
            purpose=purpose,
        )
    if user.is_activated:
        ensure_recent_reauth(admin, db)

    retry_of_id = body.retry_of_delivery_id if body else None
    if retry_of_id is not None:
        retry = (
            db.query(ActivationEmailDelivery)
            .filter(
                ActivationEmailDelivery.id == retry_of_id,
                ActivationEmailDelivery.user_id == user.id,
            )
            .first()
        )
        if retry is None:
            raise HTTPException(status_code=404, detail="Delivery attempt not found")
        if retry.status not in {"failed", "unknown", "not_attempted"}:
            raise HTTPException(status_code=409, detail="Only unsuccessful deliveries can be retried")
        if requested_purpose is not None and retry.purpose != requested_purpose:
            raise HTTPException(
                status_code=409,
                detail="Retry purpose does not match the original delivery",
            )
        purpose = retry.purpose
    else:
        retry = _latest_retryable_delivery(user.id, db, purpose=purpose)
        retry_of_id = retry.id if retry else None

    try:
        normalise_recipient(user.email)
    except ActivationMailError as error:
        return ActivationEmailResult(
            user_id=user.id,
            display_name=user.display_name,
            email=user.email,
            status="skipped",
            message=error.safe_message,
            error_code=error.code,
            purpose=purpose,
        )

    try:
        with ActivationMailer() as mailer:
            return _send_user_activation_email(
                user=user,
                admin=admin,
                mailer=mailer,
                request=request,
                db=db,
                purpose=purpose,
                retry_of_id=retry_of_id,
            )
    except ActivationMailError as error:
        return _record_not_attempted(
            user=user,
            admin=admin,
            error=error,
            purpose=purpose,
            retry_of_id=retry_of_id,
            request=request,
            db=db,
        )

send_batch_activation_emails

send_batch_activation_emails(body: BatchActivationEmailsIn, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Email activation links to an explicit selection of pending users.

Source code in backend/app/api/v1/admin.py
@router.post(
    "/batch-activation-emails",
    response_model=BatchActivationEmailsOut,
)
@limiter.limit("2/minute")
def send_batch_activation_emails(
    body: BatchActivationEmailsIn,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Email activation links to an explicit selection of pending users."""

    results: list[ActivationEmailResult] = []
    eligible: list[User] = []
    for user_id in body.user_ids:
        user = db.query(User).filter(User.id == user_id).first()
        if user is None:
            results.append(ActivationEmailResult(
                user_id=user_id,
                display_name="Unknown user",
                status="skipped",
                message="User not found.",
                error_code="user_not_found",
                purpose=INITIAL_SETUP,
            ))
            continue
        require_user_management_access(user, admin)
        if not user.is_active:
            results.append(ActivationEmailResult(
                user_id=user.id,
                display_name=user.display_name,
                email=user.email,
                status="skipped",
                message="This account is deactivated.",
                error_code="account_inactive",
                purpose=INITIAL_SETUP,
            ))
            continue
        if user.is_activated:
            results.append(ActivationEmailResult(
                user_id=user.id,
                display_name=user.display_name,
                email=user.email,
                status="skipped",
                message="This user is already activated. Manage passkeys individually.",
                error_code="already_activated",
                purpose=INITIAL_SETUP,
            ))
            continue
        try:
            normalise_recipient(user.email)
        except ActivationMailError as error:
            results.append(ActivationEmailResult(
                user_id=user.id,
                display_name=user.display_name,
                email=user.email,
                status="skipped",
                message=error.safe_message,
                error_code=error.code,
                purpose=INITIAL_SETUP,
            ))
            continue
        eligible.append(user)

    if eligible:
        try:
            with ActivationMailer() as mailer:
                connection_lost = False
                for user in eligible:
                    retry = _latest_retryable_delivery(
                        user.id,
                        db,
                        purpose=INITIAL_SETUP,
                    )
                    if connection_lost:
                        result = _record_not_attempted(
                            user=user,
                            admin=admin,
                            error=ActivationMailError(
                                "smtp_connection_lost",
                                "The mail connection stopped before this user was attempted. Retry this email.",
                            ),
                            purpose=INITIAL_SETUP,
                            retry_of_id=retry.id if retry else None,
                            request=request,
                            db=db,
                        )
                    else:
                        result = _send_user_activation_email(
                            user=user,
                            admin=admin,
                            mailer=mailer,
                            request=request,
                            db=db,
                            purpose=INITIAL_SETUP,
                            retry_of_id=retry.id if retry else None,
                        )
                        connection_lost = result.status == "unknown"
                    results.append(result)
        except ActivationMailError as error:
            for user in eligible:
                retry = _latest_retryable_delivery(
                    user.id,
                    db,
                    purpose=INITIAL_SETUP,
                )
                results.append(_record_not_attempted(
                    user=user,
                    admin=admin,
                    error=error,
                    purpose=INITIAL_SETUP,
                    retry_of_id=retry.id if retry else None,
                    request=request,
                    db=db,
                ))

    order = {user_id: index for index, user_id in enumerate(body.user_ids)}
    results.sort(key=lambda result: order[result.user_id])
    counts: dict[str, int] = {}
    for result in results:
        counts[result.status] = counts.get(result.status, 0) + 1
    return BatchActivationEmailsOut(results=results, counts=counts)

get_user_activation_email_deliveries

get_user_activation_email_deliveries(user_id: int, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Return non-secret activation-email history for one visible user.

Source code in backend/app/api/v1/admin.py
@router.get(
    "/users/{user_id}/activation-email-deliveries",
    response_model=List[ActivationEmailDeliveryOut],
)
def get_user_activation_email_deliveries(
    user_id: int,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Return non-secret activation-email history for one visible user."""

    user = db.query(User).filter(User.id == user_id).first()
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)
    deliveries = (
        db.query(ActivationEmailDelivery)
        .filter(ActivationEmailDelivery.user_id == user.id)
        .order_by(ActivationEmailDelivery.started_at.desc(), ActivationEmailDelivery.id.desc())
        .all()
    )
    return [ActivationEmailDeliveryOut(
        id=delivery.id,
        activation_link_id=delivery.activation_link_id,
        retry_of_id=delivery.retry_of_id,
        recipient_email=delivery.recipient_email,
        status=delivery.status,
        error_code=delivery.error_code,
        error_message=delivery.error_message,
        includes_qr=delivery.includes_qr,
        started_at=_ensure_aware_utc(delivery.started_at),
        completed_at=_ensure_aware_utc(delivery.completed_at),
        purpose=delivery.purpose,
    ) for delivery in deliveries]
create_user_activation_link(user_id: int, request: Request, body: ActivationLinkIn | None = None, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Create a purpose-bound registration link and invalidate older links.

Source code in backend/app/api/v1/admin.py
@router.post("/users/{user_id}/activation-link", response_model=ActivationLinkOut)
def create_user_activation_link(
    user_id: int,
    request: Request,
    body: ActivationLinkIn | None = None,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Create a purpose-bound registration link and invalidate older links."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)
    if not user.is_active:
        raise HTTPException(status_code=409, detail="Cannot create activation link for a deactivated user")
    try:
        purpose = resolve_activation_purpose(
            is_activated=user.is_activated,
            requested=body.purpose if body else None,
        )
    except ValueError as exc:
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    if user.is_activated:
        ensure_recent_reauth(admin, db)

    try:
        raw_token, _link = create_activation_link(
            user_id=user.id,
            created_by_id=admin.id,
            db=db,
            purpose=purpose,
        )
    except ActivationDeliveryInProgressError as exc:
        raise HTTPException(
            status_code=409,
            detail="Wait for the activation email hand-off to finish before creating a manual link",
        ) from exc
    audit(
        db,
        user=admin,
        action="activation.create",
        resource_type="user",
        resource_id=user.id,
        detail=json.dumps({"purpose": _link.purpose}),
        request=request,
    )
    db.commit()

    return ActivationLinkOut(
        activation_url=f"/activate#token={raw_token}",
        expires_at=_ensure_aware_utc(_link.expires_at),
        purpose=purpose,
    )

get_additional_passkey_capability

get_additional_passkey_capability(current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Return the safe enrollment mode for the current account.

Source code in backend/app/api/v1/admin.py
@account_router.get("/additional-passkey")
def get_additional_passkey_capability(
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Return the safe enrollment mode for the current account."""

    if _is_management_account(current_user):
        return {
            "mode": "direct",
            "self_service_enabled": True,
            "email_available": bool(current_user.email),
            "mail_configured": mail_is_configured(),
            "can_request": False,
            "message": "Management passkeys are added directly after passkey re-authentication.",
        }

    enabled = runtime_settings.get_int(
        "self_service_additional_passkeys_enabled", db
    ) == 1
    email_available = True
    try:
        normalise_recipient(current_user.email)
    except ActivationMailError:
        email_available = False
    smtp_ready = mail_is_configured()
    governance_ready = False
    governance_message: str | None = None
    if enabled and email_available and smtp_ready:
        try:
            resolve_activation_mail_governance(user=current_user, db=db)
            governance_ready = True
        except ActivationMailGovernanceError as error:
            governance_message = error.safe_message
    if not enabled:
        message = "Additional passkey enrollment is not enabled for participant accounts."
    elif not email_available:
        message = (
            "Your email address has not been added by an administrator. "
            "Contact an administrator to make additional-passkey enrollment available."
        )
    elif not smtp_ready:
        message = (
            "Email delivery is not configured for this deployment. "
            "Contact an administrator to add another passkey."
        )
    elif not governance_ready:
        message = governance_message or (
            "Participant email delivery is waiting for published Governance settings."
        )
    else:
        message = (
            "A one-time enrollment link can be sent to the email address "
            "recorded by your administrator."
        )
    return {
        "mode": "email",
        "self_service_enabled": enabled,
        "email_available": email_available,
        "mail_configured": smtp_ready,
        "governance_ready": governance_ready,
        "can_request": enabled and email_available and smtp_ready and governance_ready,
        "per_minute": runtime_settings.get_int(
            "self_service_passkey_emails_per_minute", db
        ),
        "per_day": runtime_settings.get_int(
            "self_service_passkey_emails_per_day", db
        ),
        "message": message,
    }

request_additional_passkey_email

request_additional_passkey_email(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Send a purpose-bound link only to the signed-in participant's address.

Source code in backend/app/api/v1/admin.py
@account_router.post(
    "/additional-passkey/email",
    response_model=ActivationEmailResult,
)
@limiter.limit("20/minute")
def request_additional_passkey_email(
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Send a purpose-bound link only to the signed-in participant's address."""

    if _is_management_account(current_user):
        raise HTTPException(
            status_code=409,
            detail="Management accounts add passkeys directly after re-authentication.",
        )
    if runtime_settings.get_int("self_service_additional_passkeys_enabled", db) != 1:
        raise HTTPException(
            status_code=403,
            detail="Additional passkey enrollment is not enabled for participant accounts.",
        )
    try:
        normalise_recipient(current_user.email)
    except ActivationMailError as error:
        raise HTTPException(
            status_code=409,
            detail=(
                "Your email address has not been added by an administrator. "
                "Contact an administrator to make additional-passkey enrollment available."
            ),
        ) from error
    if not mail_is_configured():
        raise HTTPException(
            status_code=503,
            detail=(
                "Email delivery is not configured for this deployment. "
                "Contact an administrator to add another passkey."
            ),
        )

    now = datetime.now(timezone.utc)
    self_service_attempts = db.query(ActivationEmailDelivery).filter(
        ActivationEmailDelivery.user_id == current_user.id,
        ActivationEmailDelivery.requested_by_id == current_user.id,
        ActivationEmailDelivery.purpose == ADDITIONAL_PASSKEY,
    )
    minute_limit = runtime_settings.get_int(
        "self_service_passkey_emails_per_minute", db
    )
    day_limit = runtime_settings.get_int("self_service_passkey_emails_per_day", db)
    if self_service_attempts.filter(
        ActivationEmailDelivery.started_at >= now - timedelta(minutes=1)
    ).count() >= minute_limit:
        raise HTTPException(
            status_code=429,
            detail="The per-minute additional-passkey email limit has been reached. Try again shortly.",
            headers={"Retry-After": "60"},
        )
    if self_service_attempts.filter(
        ActivationEmailDelivery.started_at >= now - timedelta(days=1)
    ).count() >= day_limit:
        raise HTTPException(
            status_code=429,
            detail="The daily additional-passkey email limit has been reached. Contact an administrator if access is urgent.",
            headers={"Retry-After": "86400"},
        )

    try:
        with ActivationMailer() as mailer:
            return _send_user_activation_email(
                user=current_user,
                admin=current_user,
                mailer=mailer,
                request=request,
                db=db,
                purpose=ADDITIONAL_PASSKEY,
            )
    except ActivationMailError as error:
        return _record_not_attempted(
            user=current_user,
            admin=current_user,
            error=error,
            purpose=ADDITIONAL_PASSKEY,
            retry_of_id=None,
            request=request,
            db=db,
        )
get_user_activation_links(user_id: int, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Get activation link status for a user.

Source code in backend/app/api/v1/admin.py
@router.get("/users/{user_id}/activation-links")
def get_user_activation_links(
    user_id: int,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Get activation link status for a user."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)

    links = (
        db.query(ActivationLink)
        .filter(ActivationLink.user_id == user_id)
        .order_by(ActivationLink.created_at.desc())
        .all()
    )

    now = datetime.now(timezone.utc)

    result = []
    for link in links:
        if link.used_at:
            status = "used"
        elif link.invalidated_at:
            status = "invalidated"
        elif link.delivery_pending:
            status = "delivery_pending"
        elif link.expires_at and _ensure_aware_utc(link.expires_at) < now:
            status = "expired"
        else:
            status = "active"

        result.append({
            "id": link.id,
            "purpose": link.purpose,
            "status": status,
            "created_at": link.created_at.isoformat() if link.created_at else None,
            "expires_at": link.expires_at.isoformat() if link.expires_at else None,
            "used_at": link.used_at.isoformat() if link.used_at else None,
        })

    return result
invalidate_activation_link(user_id: int, link_id: int, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Invalidate a specific activation link.

Source code in backend/app/api/v1/admin.py
@router.delete("/users/{user_id}/activation-links/{link_id}")
def invalidate_activation_link(
    user_id: int,
    link_id: int,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Invalidate a specific activation link."""
    from datetime import datetime, timezone

    # Issuer scoping: verify target user shares event
    target_user = db.query(User).filter(User.id == user_id).first()
    if target_user:
        require_user_management_access(target_user, admin)

    link = (
        db.query(ActivationLink)
        .filter(ActivationLink.id == link_id, ActivationLink.user_id == user_id)
        .first()
    )
    if not link:
        raise HTTPException(status_code=404, detail="Activation link not found")
    if link.delivery_pending:
        raise HTTPException(
            status_code=409,
            detail="Wait for the activation email hand-off to finish before invalidating this link",
        )

    link.invalidated_at = datetime.now(timezone.utc)
    audit(
        db,
        user=admin,
        action="activation.invalidate",
        resource_type="activation_link",
        resource_id=link.id,
        request=request,
    )
    db.commit()
    return {"status": "ok", "message": "Activation link invalidated"}

delete_event

delete_event(event_id: int, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Reject direct deletion in favour of the accountable case workflow.

Source code in backend/app/api/v1/admin.py
@router.delete("/events/{event_id}")
def delete_event(
    event_id: int,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Reject direct deletion in favour of the accountable case workflow."""
    event = db.query(Event).filter(Event.id == event_id).first()
    if not event:
        raise HTTPException(status_code=404, detail="Event not found")
    raise HTTPException(
        status_code=409,
        detail={
            "code": "DELETION_CASE_REQUIRED",
            "message": (
                "Start an event deletion case at "
                f"/api/v1/admin/deletion-requests/events/{event_id}; "
                "direct deletion is disabled."
            ),
        },
    )

import_setup

import_setup(request: Request, response: Response, body: ImportSetupIn, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Import event + users with a browser/Desktop-generated publish secret.

Source code in backend/app/api/v1/admin.py
@router.post("/import-setup", response_model=ImportSetupResponse)
@limiter.limit("5/minute")
def import_setup(
    request: Request,
    response: Response,
    body: ImportSetupIn,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Import event + users with a browser/Desktop-generated publish secret."""
    secret_hash = hashlib.sha256(body.publish_secret.encode()).hexdigest()
    if settings.HA_MODE == "ha":
        existing_operation = find_protection_operation(db, body.idempotency_key)
        if existing_operation is not None:
            if existing_operation.operation_type != "publisher-secret-import":
                raise HTTPException(status_code=409, detail="Idempotency key is already in use")
            event = db.query(Event).filter(Event.id == int(existing_operation.resource_id or 0)).first()
            if event is None or event.publish_secret_hash != secret_hash:
                raise HTTPException(status_code=409, detail="Idempotent setup import does not match")
            sync_protection_operation(db, existing_operation)
            db.commit()
            response.status_code = status.HTTP_202_ACCEPTED
            return ImportSetupResponse(
                event=_event_out(event, db), users=[],
                protection_operation_id=existing_operation.id,
                protection_state=existing_operation.state,
                protection_stage=existing_operation.stage,
            )

    event = Event(
        evidence_id=body.event.evidence_id,
        name=body.event.name,
        location=body.event.location,
        start_date=body.event.start_date,
        end_date=body.event.end_date,
        status="draft",
        publish_secret_hash=secret_hash,
    )
    db.add(event)
    db.flush()  # Get event.id
    materialise_event_purge_deadline(event, db)

    # Create users
    user_results: List[ImportUserOut] = []
    for u_in in body.users:
        # Skip duplicates
        existing = db.query(User).filter(User.username == u_in.username).first()
        if existing:
            continue

        user = User(
            evidence_subject_id=u_in.evidence_subject_id,
            username=u_in.username,
            display_name=u_in.display_name,
            email=str(u_in.email) if u_in.email else None,
            event_id=event.id,
            can_edit=u_in.can_edit,
            is_active=True,
            is_activated=False,
            linked_person_id=u_in.person_id,  # Auto-link via desktop Person.id
        )
        db.add(user)
        db.flush()

        raw_token, activation_link = create_activation_link(
            user_id=user.id,
            created_by_id=admin.id,
            db=db,
        )
        if settings.HA_MODE == "ha":
            activation_link.delivery_pending = True

        user_results.append(ImportUserOut(
            user=UserOut(
                id=user.id,
                username=user.username,
                display_name=user.display_name,
                email=user.email,
                is_root_admin=False,
                is_admin=False,
                is_issuer=False,
                can_edit=user.can_edit,
                is_active=True,
                is_activated=False,
                linked_person_id=u_in.person_id,
                event_id=event.id,
                last_login_at=None,
                created_at=user.created_at,
            ),
            activation_url=f"/activate#token={raw_token}",
        ))

    # Auto-link if published person data already exists for this event
    _auto_link_event_users(event.id, db)

    audit(db, user=admin, action="event.import_setup", resource_type="event",
          resource_id=event.id, request=request)
    protection: HAProtectionOperation | None = None
    try:
        protection = create_protection_operation(
            db, idempotency_key=body.idempotency_key,
            operation_type="publisher-secret-import", resource_type="event",
            resource_id=str(event.id),
        )
        db.commit()
    except HAWritePermitError as exc:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise HTTPException(status_code=503, detail="The standby protection guard is unavailable") from exc
    except Exception:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise
    if protection is not None:
        db.refresh(protection)
        if not queue_protection_operation(protection):
            protection.state = "indeterminate"
            protection.stage = "attention_required"
            protection.error_code = "replication_agent_unavailable"
            db.commit()
        response.status_code = status.HTTP_202_ACCEPTED

    return ImportSetupResponse(
        event=_event_out(event, db),
        publish_secret=body.publish_secret if protection is None else None,
        users=user_results,
        protection_operation_id=protection.id if protection else None,
        protection_state=protection.state if protection else None,
        protection_stage=protection.stage if protection else None,
    )
link_user_to_person(user_id: int, body: LinkPersonIn, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db))

Manually link or unlink a user to a published person.

Source code in backend/app/api/v1/admin.py
@router.put("/users/{user_id}/link-person")
def link_user_to_person(
    user_id: int,
    body: LinkPersonIn,
    request: Request,
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Manually link or unlink a user to a published person."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)

    person = None
    if body.person_id is not None:
        # Validate person exists for user's event
        person = (
            db.query(PublishedPerson)
            .filter(
                PublishedPerson.event_id == user.event_id,
                PublishedPerson.external_person_id == body.person_id,
            )
            .first()
        )
        if not person:
            raise HTTPException(status_code=404, detail="Person not found in this event")

    user.linked_person_id = body.person_id
    if person is not None:
        user.evidence_subject_id = person.evidence_subject_id
    audit(
        db,
        user=admin,
        action="user.link_person",
        resource_type="user",
        resource_id=user.id,
        detail=json.dumps({"person_id": body.person_id}),
        request=request,
    )
    db.commit()
    return {"status": "ok", "linked_person_id": user.linked_person_id}

get_security_settings

get_security_settings(admin: User = Depends(require_root_admin), db: Session = Depends(get_db))

Return root-admin security settings with current effective values.

Source code in backend/app/api/v1/admin.py
@router.get("/settings")
def get_security_settings(
    admin: User = Depends(require_root_admin),
    db: Session = Depends(get_db),
):
    """Return root-admin security settings with current effective values."""
    return runtime_settings.get_all(db)

get_retention_status

get_retention_status(admin: User = Depends(require_root_admin), db: Session = Depends(get_db))

Return bounded scheduler health and the complete retention inventory.

Source code in backend/app/api/v1/admin.py
@router.get("/retention/status")
def get_retention_status(
    admin: User = Depends(require_root_admin),
    db: Session = Depends(get_db),
):
    """Return bounded scheduler health and the complete retention inventory."""

    return retention_status(db)

update_security_settings

update_security_settings(body: SettingsUpdateIn, request: Request, admin: User = Depends(require_root_recent_reauth), db: Session = Depends(get_db))

Update root-admin security settings after re-authentication.

Source code in backend/app/api/v1/admin.py
@router.put("/settings")
def update_security_settings(
    body: SettingsUpdateIn,
    request: Request,
    admin: User = Depends(require_root_recent_reauth),
    db: Session = Depends(get_db),
):
    """Update root-admin security settings after re-authentication."""
    updated = []
    errors = []
    governance_impact = runtime_settings.governance_impact(db)
    for key, value in body.settings.items():
        try:
            governance_impact = runtime_settings.set_value(key, int(value), db)
            updated.append(key)
        except (KeyError, ValueError, TypeError) as exc:
            errors.append({"key": key, "error": str(exc)})
    if updated:
        import json
        audit(db, user=admin, action="settings.update", resource_type="settings",
              detail=json.dumps({"updated_fields": updated}), request=request)
        db.commit()
        if "ha_replication_interval_minutes" in updated and settings.HA_MODE == "ha":
            _request_ha_replication("settings-change")
    return {"updated": updated, "errors": errors, "governance_impact": governance_impact}

get_ha_dashboard_status

get_ha_dashboard_status(admin: User = Depends(require_root_admin_read_only), db: Session = Depends(get_db))

Return the complete sanitised HA/recovery dashboard document.

Source code in backend/app/api/v1/admin.py
@router.get("/ha/status", response_model=HADashboardOut)
def get_ha_dashboard_status(
    admin: User = Depends(require_root_admin_read_only),
    db: Session = Depends(get_db),
):
    """Return the complete sanitised HA/recovery dashboard document."""

    return _read_ha_dashboard(db)

send_test_email

send_test_email(body: TestEmailIn, request: Request, admin: User = Depends(require_root_recent_reauth), db: Session = Depends(get_db))

Send a token-free test email after recent root re-authentication.

Source code in backend/app/api/v1/admin.py
@router.post("/settings/email/test")
@limiter.limit("3/minute")
def send_test_email(
    body: TestEmailIn,
    request: Request,
    admin: User = Depends(require_root_recent_reauth),
    db: Session = Depends(get_db),
):
    """Send a token-free test email after recent root re-authentication."""

    recipient = normalise_recipient(str(body.recipient))
    try:
        with ActivationMailer() as mailer:
            mailer.send(build_test_message(recipient))
    except ActivationMailError as error:
        audit(
            db,
            user=admin,
            action="settings.email_test",
            resource_type="settings",
            detail=json.dumps({"error_code": error.code}),
            request=request,
            outcome="error",
        )
        db.commit()
        raise HTTPException(status_code=503, detail=error.safe_message) from error
    audit(
        db,
        user=admin,
        action="settings.email_test",
        resource_type="settings",
        request=request,
    )
    db.commit()
    return {
        "status": "accepted",
        "message": "Test email accepted by the mail server.",
    }
invalidate_all_activation_links(body: InvalidateAllActivationLinksIn, request: Request, admin: User = Depends(require_root_recent_reauth), db: Session = Depends(get_db))

Invalidate every currently active activation link after confirmation.

Source code in backend/app/api/v1/admin.py
@router.post("/activation-links/invalidate-all")
@limiter.limit("2/minute")
def invalidate_all_activation_links(
    body: InvalidateAllActivationLinksIn,
    request: Request,
    admin: User = Depends(require_root_recent_reauth),
    db: Session = Depends(get_db),
):
    """Invalidate every currently active activation link after confirmation."""

    if not body.confirm:
        raise HTTPException(status_code=422, detail="Explicit confirmation is required")
    now = datetime.now(timezone.utc)
    count = (
        db.query(ActivationLink)
        .filter(
            ActivationLink.used_at.is_(None),
            ActivationLink.invalidated_at.is_(None),
            ActivationLink.delivery_pending.is_(False),
            ActivationLink.expires_at > now,
        )
        .update({"invalidated_at": now}, synchronize_session="fetch")
    )
    audit(
        db,
        user=admin,
        action="activation.invalidate_all",
        resource_type="activation_link",
        detail=json.dumps({"count": count}),
        request=request,
    )
    db.commit()
    return {"status": "ok", "invalidated_count": count}

get_audit_log

get_audit_log(page: int = Query(1, ge=1, le=100000), per_page: int = Query(50, ge=1, le=200), action: Optional[str] = Query(None, max_length=64), user_id: Optional[int] = None, admin: User = Depends(require_root_admin), db: Session = Depends(get_db))

Query the global audit log (root/controller only).

Source code in backend/app/api/v1/admin.py
@router.get("/audit-log", response_model=AuditLogResponse)
def get_audit_log(
    page: int = Query(1, ge=1, le=100000),
    per_page: int = Query(50, ge=1, le=200),
    action: Optional[str] = Query(None, max_length=64),
    user_id: Optional[int] = None,
    admin: User = Depends(require_root_admin),
    db: Session = Depends(get_db),
):
    """Query the global audit log (root/controller only)."""
    from app.models.audit import AuditLog

    q = db.query(AuditLog)
    if action:
        q = q.filter(AuditLog.action == action)
    if user_id is not None:
        q = q.filter(AuditLog.user_id == user_id)

    total = q.count()
    entries = (
        q.order_by(AuditLog.timestamp.desc())
        .offset((page - 1) * per_page)
        .limit(per_page)
        .all()
    )

    return AuditLogResponse(
        total=total,
        page=page,
        per_page=per_page,
        entries=[
            AuditLogEntry(
                id=e.id,
                timestamp=e.timestamp.isoformat() if e.timestamp else "",
                user_id=e.user_id,
                actor_ref=e.actor_ref,
                action=e.action,
                resource_type=e.resource_type,
                resource_id=e.resource_id,
                detail=e.detail,
                outcome=e.outcome or "success",
            )
            for e in entries
        ],
    )

Authentication

auth

Authentication endpoints - session management, exchange, logout.

ExchangeRequest

Bases: BaseModel

Short-lived passkey exchange code submitted after WebAuthn login.

Source code in backend/app/api/v1/auth.py
class ExchangeRequest(BaseModel):
    """Short-lived passkey exchange code submitted after WebAuthn login."""

    code: str = Field(..., min_length=20, max_length=256)

UserMeResponse

Bases: BaseModel

Authenticated user profile returned to the frontend.

Source code in backend/app/api/v1/auth.py
class UserMeResponse(BaseModel):
    """Authenticated user profile returned to the frontend."""

    id: int
    username: str
    display_name: str
    email: Optional[str] = None
    is_root_admin: bool
    is_admin: bool
    is_issuer: bool
    can_edit: bool
    is_active: bool
    is_activated: bool
    linked_person_id: Optional[int] = None
    event_id: Optional[int] = None
    offline_access_ttl_hours: int = 24
    commissioning_required: bool = False
    commissioning_stage: str = "complete"

    model_config = ConfigDict(from_attributes=True)

ExchangeResponse

Bases: BaseModel

Session exchange response returned after successful passkey login.

Source code in backend/app/api/v1/auth.py
class ExchangeResponse(BaseModel):
    """Session exchange response returned after successful passkey login."""

    id: int
    username: str
    display_name: str
    is_root_admin: bool
    is_admin: bool
    commissioning_required: bool = False
    commissioning_stage: str = "complete"

SessionResponse

Bases: BaseModel

Minimal active-session metadata visible only to its account owner.

Source code in backend/app/api/v1/auth.py
class SessionResponse(BaseModel):
    """Minimal active-session metadata visible only to its account owner."""

    id: int
    current: bool
    device: str
    created_at: datetime
    last_seen_at: Optional[datetime] = None
    expires_at: datetime

SessionRevocationResponse

Bases: BaseModel

Result of revoking one account-owned session.

Source code in backend/app/api/v1/auth.py
class SessionRevocationResponse(BaseModel):
    """Result of revoking one account-owned session."""

    revoked: bool
    current: bool

exchange_code_for_session

exchange_code_for_session(body: ExchangeRequest, request: Request, response: Response, db: Session = Depends(get_db))

Exchange a short-lived one-time code (from passkey auth) for a session cookie.

Source code in backend/app/api/v1/auth.py
@router.post("/exchange", response_model=ExchangeResponse)
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=client_ip_rate_key,
)
def exchange_code_for_session(
    body: ExchangeRequest,
    request: Request,
    response: Response,
    db: Session = Depends(get_db),
):
    """Exchange a short-lived one-time code (from passkey auth) for a session cookie."""
    now = datetime.now(timezone.utc)
    exchange = (
        db.query(ExchangeCode)
        .filter(
            ExchangeCode.code == hashlib.sha256(body.code.encode()).hexdigest(),
            ExchangeCode.used_at.is_(None),
        )
        .first()
    )
    if exchange is None:
        raise HTTPException(status_code=400, detail="Invalid or already-used code")

    expires = exchange.expires_at
    if expires.tzinfo is None:
        expires = expires.replace(tzinfo=timezone.utc)
    if now > expires:
        raise HTTPException(status_code=400, detail="Code expired")

    user = db.query(User).filter(User.id == exchange.user_id).first()
    if not user or not user.is_active or (
        not user.is_activated and not user.is_root_admin
    ):
        raise HTTPException(status_code=400, detail="Authentication failed")

    consumed = (
        db.query(ExchangeCode)
        .filter(
            ExchangeCode.id == exchange.id,
            ExchangeCode.used_at.is_(None),
            ExchangeCode.expires_at > now,
        )
        .update({"used_at": now}, synchronize_session=False)
    )
    if consumed != 1:
        db.rollback()
        raise HTTPException(status_code=400, detail="Invalid or already-used code")
    db.commit()

    is_privileged = user.is_root_admin or user.is_admin or user.is_issuer
    session = create_session(
        user_id=user.id,
        db=db,
        ip_address=request.client.host if request.client else None,
        user_agent=request.headers.get("user-agent"),
        accept_language=request.headers.get("accept-language"),
        is_privileged=is_privileged,
        reauthenticated=True,
    )
    _set_session_cookie(
        response,
        session._raw_token,
        session.csrf_token,
        session.expires_at,
    )

    user.last_login_at = now
    db.commit()

    audit(db, user=user, action="auth.login", request=request)
    db.commit()

    return ExchangeResponse(
        id=user.id,
        username=user.username,
        display_name=user.display_name,
        is_root_admin=user.is_root_admin,
        is_admin=user.is_admin,
        commissioning_required=user.is_root_admin and commissioning_required(db),
        commissioning_stage=commissioning_stage(db) if user.is_root_admin else "complete",
    )

logout

logout(request: Request, response: Response, db: Session = Depends(get_db))

Revoke the current session and clear auth cookies.

Source code in backend/app/api/v1/auth.py
@router.post("/logout")
def logout(request: Request, response: Response, db: Session = Depends(get_db)):
    """Revoke the current session and clear auth cookies."""

    token = request.cookies.get(settings.SESSION_COOKIE_NAME)
    user = None
    if token:
        try:
            auth_sess = validate_session(
                token,
                db,
                user_agent=request.headers.get("user-agent"),
                accept_language=request.headers.get("accept-language"),
            )
            if auth_sess:
                user = db.query(User).filter(User.id == auth_sess.user_id).first()
        except Exception:
            user = None

        revoke_session(token, db)

    if user:
        audit(db, user=user, action="auth.logout", request=request)
        db.commit()

    _clear_session_cookies(response)
    response.headers["Cache-Control"] = "no-store"
    return {"message": "Logged out"}

get_me

get_me(current_user: User = Depends(get_current_user_for_commissioning), db: Session = Depends(get_db))

Return the currently authenticated user.

Source code in backend/app/api/v1/auth.py
@router.get("/me", response_model=UserMeResponse)
def get_me(
    current_user: User = Depends(get_current_user_for_commissioning),
    db: Session = Depends(get_db),
):
    """Return the currently authenticated user."""

    return UserMeResponse(
        id=current_user.id,
        username=current_user.username,
        display_name=current_user.display_name,
        email=current_user.email,
        is_root_admin=current_user.is_root_admin,
        is_admin=current_user.is_admin,
        is_issuer=current_user.is_issuer,
        can_edit=current_user.can_edit,
        is_active=current_user.is_active,
        is_activated=current_user.is_activated,
        linked_person_id=current_user.linked_person_id,
        event_id=current_user.event_id,
        offline_access_ttl_hours=runtime_settings.get_int(
            "offline_access_ttl_hours",
            db,
        ),
        commissioning_required=(current_user.is_root_admin and commissioning_required(db)),
        commissioning_stage=(commissioning_stage(db) if current_user.is_root_admin else "complete"),
    )

root_access

root_access(current_user: User = Depends(require_root_admin))

Authorise HTTP delivery of a root-only frontend route.

Source code in backend/app/api/v1/auth.py
@router.get("/root-access")
def root_access(current_user: User = Depends(require_root_admin)):
    """Authorise HTTP delivery of a root-only frontend route."""

    return {"status": "ok"}

recovery_key_access

recovery_key_access(current_user: User = Depends(require_root_recent_reauth))

Unlock the browser-local recovery-key generator after root WebAuthn.

Source code in backend/app/api/v1/auth.py
@router.get("/recovery-key-access")
def recovery_key_access(
    current_user: User = Depends(require_root_recent_reauth),
):
    """Unlock the browser-local recovery-key generator after root WebAuthn."""

    return {"status": "ok"}

list_sessions

list_sessions(current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

List non-expired sessions for the current account only.

Source code in backend/app/api/v1/auth.py
@router.get("/sessions", response_model=List[SessionResponse])
def list_sessions(
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """List non-expired sessions for the current account only."""

    current_session = getattr(current_user, "_auth_session", None)
    if current_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    now = datetime.now(timezone.utc)
    sessions = (
        db.query(AuthSession)
        .filter(
            AuthSession.user_id == current_user.id,
            AuthSession.revoked_at.is_(None),
            AuthSession.expires_at > now,
        )
        .order_by(AuthSession.created_at.desc(), AuthSession.id.desc())
        .all()
    )
    return [
        SessionResponse(
            id=session.id,
            current=session.id == current_session.id,
            device=_coarse_user_agent(session.user_agent) or "Browser on Other",
            created_at=session.created_at,
            last_seen_at=session.last_seen_at,
            expires_at=session.expires_at,
        )
        for session in sessions
    ]

revoke_owned_session

revoke_owned_session(session_id: int, request: Request, response: Response, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Revoke one active session owned by the current account.

Source code in backend/app/api/v1/auth.py
@router.delete("/sessions/{session_id}", response_model=SessionRevocationResponse)
def revoke_owned_session(
    session_id: int,
    request: Request,
    response: Response,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Revoke one active session owned by the current account."""

    current_session = getattr(current_user, "_auth_session", None)
    if current_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    target = (
        db.query(AuthSession)
        .filter(
            AuthSession.id == session_id,
            AuthSession.user_id == current_user.id,
            AuthSession.revoked_at.is_(None),
        )
        .first()
    )
    if target is None:
        raise HTTPException(status_code=404, detail="Active session not found")
    is_current = target.id == current_session.id
    target.revoked_at = datetime.now(timezone.utc)
    audit(
        db,
        user=current_user,
        action="auth.session_revoke",
        detail=json.dumps({"target": "current" if is_current else "other"}),
        request=request,
    )
    db.commit()
    if is_current:
        _clear_session_cookies(response)
    response.headers["Cache-Control"] = "no-store"
    return SessionRevocationResponse(revoked=True, current=is_current)

Calendar

calendar

Calendar endpoints - serve published tasks with optional edit overlay.

AttendeeOut

Bases: BaseModel

Calendar attendee shown on a published task.

Source code in backend/app/api/v1/calendar.py
class AttendeeOut(BaseModel):
    """Calendar attendee shown on a published task."""

    name: str = Field(..., min_length=1, max_length=256)
    person_id: int = Field(..., gt=0)

TaskOut

Bases: BaseModel

Published task representation returned to calendar clients.

Source code in backend/app/api/v1/calendar.py
class TaskOut(BaseModel):
    """Published task representation returned to calendar clients."""

    id: int
    external_task_id: int
    name: str
    summary: Optional[str] = None
    description: Optional[str] = None
    start: str  # ISO datetime
    end: str    # ISO datetime
    working_date: str
    location_name: Optional[str] = None
    location_address: Optional[str] = None
    task_type_code: Optional[str] = None
    task_type_name: Optional[str] = None
    color: Optional[str] = None
    attendees: List[AttendeeOut] = []
    field_assignments: Optional[Dict[str, List[AttendeeOut]]] = None
    field_values: Optional[Dict[str, Any]] = None
    field_definitions: Optional[List[Dict[str, str]]] = None
    additional: Optional[Dict[str, Any]] = None
    sort_order: float = 0
    has_web_edit: bool = False
    web_edit_edited_at: Optional[str] = None
    web_edit_edited_by: Optional[str] = None
    web_edit_edited_by_user_id: Optional[int] = None
    web_edit_change_summary: List[str] = []

PersonOut

Bases: BaseModel

Published person visible in the event calendar.

Source code in backend/app/api/v1/calendar.py
class PersonOut(BaseModel):
    """Published person visible in the event calendar."""

    id: int
    external_person_id: int
    first_name: str
    last_name: str

PublicScheduleItemOut

Bases: BaseModel

Published public General Schedule item shown in the calendar.

Source code in backend/app/api/v1/calendar.py
class PublicScheduleItemOut(BaseModel):
    """Published public General Schedule item shown in the calendar."""

    id: int
    external_session_element_id: int
    title: str
    date: str
    start_time: str
    end_time: str
    working_date: str
    location_name: Optional[str] = None
    location_address: Optional[str] = None
    responsible: Optional[str] = None
    audience_teams: List[Dict[str, Any]] = []
    description: Optional[str] = None
    category_id: Optional[int] = None
    category_name: Optional[str] = None
    type_id: Optional[int] = None
    type_name: Optional[str] = None
    copy_template_html: Optional[str] = None
    category: Optional[str] = None
    colour: Optional[str] = None
    sort_order: float = 0

PublicScheduleCategoryOut

Bases: BaseModel

Published General Schedule audience category shown as a calendar view.

Source code in backend/app/api/v1/calendar.py
class PublicScheduleCategoryOut(BaseModel):
    """Published General Schedule audience category shown as a calendar view."""

    id: int
    name: str
    sort_order: float = 0

GeneralScheduleStateOut

Bases: BaseModel

Latest General Schedule publish state for the calendar.

Source code in backend/app/api/v1/calendar.py
class GeneralScheduleStateOut(BaseModel):
    """Latest General Schedule publish state for the calendar."""

    published_at: Optional[str] = None
    fingerprint: Optional[str] = None
    item_count: int = 0

PersonUnavailabilityOut

Bases: BaseModel

Published person unavailability returned to authenticated calendars.

Source code in backend/app/api/v1/calendar.py
class PersonUnavailabilityOut(BaseModel):
    """Published person unavailability returned to authenticated calendars."""

    person_id: int
    working_date: str
    start: str
    end: str

CalendarResponse

Bases: BaseModel

Calendar payload for one event and user role.

Source code in backend/app/api/v1/calendar.py
class CalendarResponse(BaseModel):
    """Calendar payload for one event and user role."""

    event_id: int
    event_name: str
    start_date: Optional[str] = None
    end_date: Optional[str] = None
    logo_color_1: Optional[str] = None
    logo_color_2: Optional[str] = None
    day_aliases: Optional[Dict[str, str]] = None
    schedule_day_range: Dict[str, int]
    tasks: List[TaskOut]
    persons: List[PersonOut]
    public_schedule_categories: List[PublicScheduleCategoryOut] = []
    public_schedule_views: List[PublicScheduleCategoryOut] = []
    public_schedule_items: List[PublicScheduleItemOut] = []
    general_schedule_state: Optional[GeneralScheduleStateOut] = None
    unavailabilities: List[PersonUnavailabilityOut] = []
    data_policy_version: Optional[int] = None
    data_policy_sha256: Optional[str] = None
    data_policy_acknowledged: bool = True

OfflineTaskOut

Bases: BaseModel

Participant-visible task contract approved for browser persistence.

Source code in backend/app/api/v1/calendar.py
class OfflineTaskOut(BaseModel):
    """Participant-visible task contract approved for browser persistence."""

    id: int
    external_task_id: int
    name: str
    summary: Optional[str] = None
    description: Optional[str] = None
    start: str
    end: str
    working_date: str
    location_name: Optional[str] = None
    location_address: Optional[str] = None
    task_type_code: Optional[str] = None
    task_type_name: Optional[str] = None
    color: Optional[str] = None
    attendees: List[AttendeeOut] = []
    field_assignments: Optional[Dict[str, List[AttendeeOut]]] = None
    field_values: Optional[Dict[str, Any]] = None
    field_definitions: Optional[List[Dict[str, str]]] = None
    additional: None = None
    sort_order: float = 0
    has_web_edit: bool = False
    web_edit_edited_at: None = None
    web_edit_edited_by: None = None
    web_edit_edited_by_user_id: None = None
    web_edit_change_summary: List[str] = []

OfflinePublicScheduleItemOut

Bases: BaseModel

Public programme fields approved for authenticated offline storage.

Source code in backend/app/api/v1/calendar.py
class OfflinePublicScheduleItemOut(BaseModel):
    """Public programme fields approved for authenticated offline storage."""

    id: int
    external_session_element_id: int
    title: str
    date: str
    working_date: str
    start_time: str
    end_time: str
    location_name: Optional[str] = None
    location_address: Optional[str] = None
    responsible: Optional[str] = None
    audience_teams: List[Dict[str, Optional[str]]] = []
    description: Optional[str] = None
    category_id: Optional[int] = None
    category_name: Optional[str] = None
    type_name: Optional[str] = None
    colour: Optional[str] = None
    sort_order: float = 0

OfflineCalendarResponse

Bases: BaseModel

Fail-closed calendar contract that may be retained in IndexedDB.

Source code in backend/app/api/v1/calendar.py
class OfflineCalendarResponse(BaseModel):
    """Fail-closed calendar contract that may be retained in IndexedDB."""

    event_id: int
    event_name: str
    start_date: Optional[str] = None
    end_date: Optional[str] = None
    day_aliases: Optional[Dict[str, str]] = None
    schedule_day_range: Dict[str, int]
    tasks: List[OfflineTaskOut]
    persons: List[PersonOut]
    public_schedule_categories: List[PublicScheduleCategoryOut] = []
    public_schedule_views: List[PublicScheduleCategoryOut] = []
    public_schedule_items: List[OfflinePublicScheduleItemOut] = []
    unavailabilities: List[PersonUnavailabilityOut] = []
    data_policy_version: Optional[int] = None
    data_policy_sha256: Optional[str] = None
    data_policy_acknowledged: bool = True

TaskEditIn

Bases: BaseModel

Single draft edit payload for an existing task.

Source code in backend/app/api/v1/calendar.py
class TaskEditIn(BaseModel):
    """Single draft edit payload for an existing task."""

    start: Optional[str] = None   # ISO datetime
    end: Optional[str] = None     # ISO datetime
    attendees: Optional[List[AttendeeOut]] = Field(None, max_length=500)
    field_assignments: Optional[Dict[str, List[AttendeeOut]]] = Field(
        None,
        max_length=100,
    )

BatchEditItem

Bases: BaseModel

Batch item containing one task edit.

Source code in backend/app/api/v1/calendar.py
class BatchEditItem(BaseModel):
    """Batch item containing one task edit."""

    task_id: int = Field(..., gt=0)
    name: Optional[str] = Field(None, max_length=512)
    summary: Optional[str] = Field(None, max_length=2000)
    description: Optional[str] = Field(None, max_length=10000)
    start: Optional[str] = Field(None, max_length=64)
    end: Optional[str] = Field(None, max_length=64)
    location_name: Optional[str] = Field(None, max_length=512)
    location_address: Optional[str] = Field(None, max_length=1024)
    color: Optional[str] = Field(None, max_length=32)
    attendees: Optional[List[AttendeeOut]] = Field(None, max_length=500)
    field_assignments: Optional[Dict[str, List[AttendeeOut]]] = Field(
        None,
        max_length=100,
    )
    field_values: Optional[Dict[str, Any]] = Field(None, max_length=100)

BatchCreateItem

Bases: BaseModel

Batch item containing one new draft task.

Source code in backend/app/api/v1/calendar.py
class BatchCreateItem(BaseModel):
    """Batch item containing one new draft task."""

    name: str = Field(..., min_length=1, max_length=512)
    summary: Optional[str] = Field(None, max_length=2000)
    description: Optional[str] = Field(None, max_length=10000)
    start: str = Field(..., max_length=64)
    end: str = Field(..., max_length=64)
    location_name: Optional[str] = Field(None, max_length=512)
    location_address: Optional[str] = Field(None, max_length=1024)
    color: Optional[str] = Field(None, max_length=32)
    attendees: Optional[List[AttendeeOut]] = Field(None, max_length=500)
    field_assignments: Optional[Dict[str, List[AttendeeOut]]] = Field(
        None,
        max_length=100,
    )
    field_values: Optional[Dict[str, Any]] = Field(None, max_length=100)

BatchCommitRequest

Bases: BaseModel

Request body for committing draft edits and created tasks.

Source code in backend/app/api/v1/calendar.py
class BatchCommitRequest(BaseModel):
    """Request body for committing draft edits and created tasks."""

    edits: List[BatchEditItem] = Field(default_factory=list, max_length=500)
    deletions: List[int] = Field(default_factory=list, max_length=500)
    creations: List[BatchCreateItem] = Field(default_factory=list, max_length=500)

get_calendar

get_calendar(event_id: int, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Get published tasks for an event, with permitted role-specific fields.

Source code in backend/app/api/v1/calendar.py
@router.get("/{event_id}", response_model=CalendarResponse)
@limiter.limit("60/minute")
def get_calendar(
    event_id: int,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Get published tasks for an event, with permitted role-specific fields."""
    participant_only = not (
        current_user.can_edit
        or current_user.is_admin
        or current_user.is_root_admin
        or current_user.is_issuer
    )
    return _build_calendar_response(
        event_id, current_user, db, participant_only=participant_only
    )

get_offline_calendar

get_offline_calendar(event_id: int, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Return the bounded calendar contract approved for optional device storage.

Source code in backend/app/api/v1/calendar.py
@router.get("/{event_id}/offline", response_model=OfflineCalendarResponse)
@limiter.limit("60/minute")
def get_offline_calendar(
    event_id: int,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Return the bounded calendar contract approved for optional device storage."""
    calendar = _build_calendar_response(
        event_id,
        current_user,
        db,
        participant_only=True,
    )
    return _offline_calendar_response(calendar)

get_persons

get_persons(event_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Get person list for filter dropdown.

Source code in backend/app/api/v1/calendar.py
@router.get("/{event_id}/persons", response_model=List[PersonOut])
def get_persons(
    event_id: int,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Get person list for filter dropdown."""
    _check_event_access(event_id, current_user, db)

    persons = (
        db.query(PublishedPerson)
        .filter(PublishedPerson.event_id == event_id)
        .order_by(PublishedPerson.last_name, PublishedPerson.first_name)
        .all()
    )

    if not (
        current_user.can_edit
        or current_user.is_admin
        or current_user.is_root_admin
        or current_user.is_issuer
    ):
        persons = [
            person for person in persons
            if current_user.linked_person_id is not None
            and person.external_person_id == current_user.linked_person_id
        ]

    return [
        PersonOut(
            id=p.id,
            external_person_id=p.external_person_id,
            first_name=p.first_name,
            last_name=p.last_name,
        )
        for p in persons
    ]

edit_task

edit_task(event_id: int, task_id: int, body: TaskEditIn, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Save a web-only edit for a task. Requires can_edit permission.

Source code in backend/app/api/v1/calendar.py
@router.put("/{event_id}/tasks/{task_id}")
def edit_task(
    event_id: int,
    task_id: int,
    body: TaskEditIn,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Save a web-only edit for a task. Requires can_edit permission."""
    if not current_user.can_edit and not current_user.is_admin and not current_user.is_root_admin:
        raise HTTPException(status_code=403, detail="Edit permission required")

    _check_event_access(event_id, current_user, db)
    require_data_policy_acknowledgement(current_user, event_id, db)

    task = db.query(PublishedTask).filter(
        PublishedTask.id == task_id,
        PublishedTask.event_id == event_id,
    ).first()
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")

    # Upsert task_edit
    edit = db.query(TaskEdit).filter(TaskEdit.task_id == task_id).first()
    if edit is None:
        edit = TaskEdit(task_id=task_id, edited_by_user_id=current_user.id)
        db.add(edit)

    parsed_start, parsed_end = _validate_effective_task_values(
        start=body.start,
        end=body.end,
        fallback_start=edit.start_datetime or task.start_datetime,
        fallback_end=edit.end_datetime or task.end_datetime,
    )
    if body.start is not None:
        edit.start_datetime = parsed_start
    if body.end is not None:
        edit.end_datetime = parsed_end
    if body.field_assignments is not None:
        field_assignments_data, effective_assignments = _merge_field_assignments(
            event_id, task, edit, body.field_assignments, db
        )
        edit.field_assignments_json = json.dumps(field_assignments_data)
        edit.attendees_json = json.dumps(
            _flatten_field_assignments(effective_assignments)
        )
    elif body.attendees is not None:
        if _assignment_field_ids(task):
            raise HTTPException(
                status_code=422,
                detail="Structured assignments must be edited by category",
            )
        edit.attendees_json = json.dumps([
            attendee.model_dump()
            for attendee in _canonical_attendees(event_id, body.attendees, db)
        ])

    edit.edited_by_user_id = current_user.id
    audit(
        db,
        user=current_user,
        action="calendar.task_edit",
        resource_type="published_task",
        resource_id=task.id,
        detail=json.dumps({"event_id": event_id}),
        request=request,
    )
    db.commit()

    return {"status": "ok", "message": "Edit saved"}

revert_task_edit

revert_task_edit(event_id: int, task_id: int, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Revert a task to its published version (delete web edit).

Source code in backend/app/api/v1/calendar.py
@router.delete("/{event_id}/tasks/{task_id}/edits")
def revert_task_edit(
    event_id: int,
    task_id: int,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Revert a task to its published version (delete web edit)."""
    if not current_user.can_edit and not current_user.is_admin and not current_user.is_root_admin:
        raise HTTPException(status_code=403, detail="Edit permission required")

    _check_event_access(event_id, current_user, db)

    task = db.query(PublishedTask).filter(
        PublishedTask.id == task_id,
        PublishedTask.event_id == event_id,
    ).first()
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")
    deleted = db.query(TaskEdit).filter(TaskEdit.task_id == task.id).delete()

    if not deleted:
        raise HTTPException(status_code=404, detail="No edit found for this task")

    audit(
        db,
        user=current_user,
        action="calendar.task_revert",
        resource_type="published_task",
        resource_id=task.id,
        detail=json.dumps({"event_id": event_id}),
        request=request,
    )
    db.commit()
    return {"status": "ok", "message": "Edit reverted"}

batch_commit

batch_commit(event_id: int, request: Request, body: BatchCommitRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Batch-commit edits, deletions, and new tasks in a single transaction. Sends a push notification after successful commit.

Source code in backend/app/api/v1/calendar.py
@router.post("/{event_id}/tasks/commit")
@limiter.limit("20/minute")
def batch_commit(
    event_id: int,
    request: Request,
    body: BatchCommitRequest,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Batch-commit edits, deletions, and new tasks in a single transaction.
    Sends a push notification after successful commit."""
    if not current_user.can_edit and not current_user.is_admin and not current_user.is_root_admin:
        raise HTTPException(status_code=403, detail="Edit permission required")

    event = _check_event_access(event_id, current_user, db)
    require_data_policy_acknowledgement(current_user, event_id, db)

    # --- Edits ---
    for item in body.edits:
        task = db.query(PublishedTask).filter(
            PublishedTask.id == item.task_id,
            PublishedTask.event_id == event_id,
        ).first()
        if task is None:
            raise HTTPException(status_code=404, detail=f"Task {item.task_id} not found")

        edit = db.query(TaskEdit).filter(TaskEdit.task_id == item.task_id).first()
        if edit is None:
            edit = TaskEdit(task_id=item.task_id, edited_by_user_id=current_user.id)
            db.add(edit)

        if item.name is not None:
            edit.name = item.name
        if item.summary is not None:
            edit.summary = item.summary
        if item.description is not None:
            edit.description = item.description
        parsed_start, parsed_end = _validate_effective_task_values(
            start=item.start,
            end=item.end,
            fallback_start=edit.start_datetime or task.start_datetime,
            fallback_end=edit.end_datetime or task.end_datetime,
            colour=item.color,
        )
        if item.start is not None:
            edit.start_datetime = parsed_start
        if item.end is not None:
            edit.end_datetime = parsed_end
        if item.location_name is not None:
            edit.location_name = item.location_name
        if item.location_address is not None:
            edit.location_address = item.location_address
        if item.color is not None:
            edit.color = item.color
        if item.field_assignments is not None:
            field_assignments_data, effective_assignments = _merge_field_assignments(
                event_id, task, edit, item.field_assignments, db
            )
            edit.field_assignments_json = json.dumps(field_assignments_data)
            edit.attendees_json = json.dumps(
                _flatten_field_assignments(effective_assignments)
            )
        elif item.attendees is not None:
            if _assignment_field_ids(task):
                raise HTTPException(
                    status_code=422,
                    detail="Structured assignments must be edited by category",
                )
            edit.attendees_json = json.dumps([
                attendee.model_dump()
                for attendee in _canonical_attendees(event_id, item.attendees, db)
            ])
        if item.field_values is not None:
            edit.field_values_json = json.dumps(
                _merge_field_values(task, edit, item.field_values)
            )
        edit.edited_by_user_id = current_user.id

    # --- Deletions ---
    for task_id in body.deletions:
        task = db.query(PublishedTask).filter(
            PublishedTask.id == task_id,
            PublishedTask.event_id == event_id,
        ).first()
        if task is None:
            raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

        # For web-created tasks, hard-delete instead of soft-delete
        if task.web_created:
            db.query(TaskEdit).filter(TaskEdit.task_id == task_id).delete()
            db.delete(task)
        else:
            edit = db.query(TaskEdit).filter(TaskEdit.task_id == task_id).first()
            if edit is None:
                edit = TaskEdit(task_id=task_id, edited_by_user_id=current_user.id)
                db.add(edit)
            edit.is_deleted = True
            edit.edited_by_user_id = current_user.id

    # --- Creations ---
    for new_task in body.creations:
        if new_task.field_assignments:
            raise HTTPException(
                status_code=422,
                detail="New web tasks cannot define hidden assignment fields",
            )
        if new_task.field_values:
            raise HTTPException(
                status_code=422,
                detail="New web tasks cannot define hidden task fields",
            )
        parsed_start, parsed_end = _validate_effective_task_values(
            start=new_task.start,
            end=new_task.end,
            fallback_start=datetime.now(timezone.utc),
            fallback_end=datetime.now(timezone.utc),
            colour=new_task.color,
        )
        attendees_data = [
            attendee.model_dump()
            for attendee in _canonical_attendees(
                event_id,
                new_task.attendees or [],
                db,
            )
        ]
        db.add(PublishedTask(
            event_id=event_id,
            external_task_id=0,
            name=new_task.name,
            summary=new_task.summary,
            description=new_task.description,
            start_datetime=parsed_start,
            end_datetime=parsed_end,
            location_name=new_task.location_name,
            location_address=new_task.location_address,
            color=new_task.color,
            attendees_json=json.dumps(attendees_data) if attendees_data else None,
            field_assignments_json=None,
            field_values_json=None,
            web_created=True,
        ))

    audit(
        db,
        user=current_user,
        action="calendar.commit",
        resource_type="event",
        resource_id=event_id,
        detail=json.dumps(
            {
                "edited_task_ids": [item.task_id for item in body.edits],
                "deleted_task_ids": body.deletions,
                "created_count": len(body.creations),
            }
        ),
        request=request,
    )
    db.commit()

    # Send push notification
    notification_sent = False
    try:
        from app.core.push import send_push_to_event
        count = send_push_to_event(
            event_id=event_id,
            title="Masterplan Changed",
            body="The masterplan has been changed manually.",
            url=f"/calendar?event={event_id}",
            db=db,
            notification_type="schedule",
        )
        notification_sent = count > 0
    except Exception as exc:
        logger.warning(
            "Calendar commit push delivery failed for event %s (%s)",
            event_id,
            type(exc).__name__,
        )

    return {
        "status": "ok",
        "edits_applied": len(body.edits),
        "tasks_deleted": len(body.deletions),
        "tasks_created": len(body.creations),
        "notification_sent": notification_sent,
    }

GDPR

gdpr

GDPR endpoints - data export, deletion request, and admin-initiated anonymisation.

Admin-only execution model: users can request deletion (flag), admin approves and runs.

DataExportResponse

Bases: BaseModel

GDPR export payload for one server user.

Source code in backend/app/api/v1/gdpr.py
class DataExportResponse(BaseModel):
    """GDPR export payload for one server user."""

    user: dict
    sessions_count: int
    credentials_count: int
    push_subscriptions: List[dict]
    task_edits_count: int
    linked_persons: List[dict]
    audit_entries: List[dict]

DeletionRequestResponse

Bases: BaseModel

Status response for user deletion request actions.

Source code in backend/app/api/v1/gdpr.py
class DeletionRequestResponse(BaseModel):
    """Status response for user deletion request actions."""

    status: str
    message: str
    request_id: str
    state: str
    submitted_at: datetime
    normal_response_due_at: datetime
    completed_at: Optional[datetime] = None
    outcome: Optional[str] = None
    limitations: List[str] = Field(default_factory=list)
    status_capability: Optional[str] = None

DeletionFinaliseIn

Bases: BaseModel

Strict completion has no exception or compatibility parameters.

Source code in backend/app/api/v1/gdpr.py
class DeletionFinaliseIn(BaseModel):
    """Strict completion has no exception or compatibility parameters."""

    model_config = ConfigDict(extra="forbid")

BackupResolutionIn

Bases: BaseModel

Exact recovery packages the controller confirms were deleted.

Source code in backend/app/api/v1/gdpr.py
class BackupResolutionIn(BaseModel):
    """Exact recovery packages the controller confirms were deleted."""

    package_ids: List[str] = Field(min_length=1, max_length=128)

OutstandingActionsResolutionIn

Bases: BaseModel

Exact external actions that the controller confirms are complete.

Source code in backend/app/api/v1/gdpr.py
class OutstandingActionsResolutionIn(BaseModel):
    """Exact external actions that the controller confirms are complete."""

    actions: List[str] = Field(min_length=1, max_length=2)

ChecklistApprovalBeginIn

Bases: BaseModel

Approval role requested for the immutable checklist.

Source code in backend/app/api/v1/gdpr.py
class ChecklistApprovalBeginIn(BaseModel):
    """Approval role requested for the immutable checklist."""

    role: str = Field(pattern=r"^(executor|controller|processor)$")

EventErasureRequestIn

Bases: BaseModel

Root-authorised request to erase one complete event scope.

Source code in backend/app/api/v1/gdpr.py
class EventErasureRequestIn(BaseModel):
    """Root-authorised request to erase one complete event scope."""

    model_config = ConfigDict(extra="forbid")

export_user_data

export_user_data(user_id: int, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Export all personal data for a user (admin only). GDPR Article 20.

Source code in backend/app/api/v1/gdpr.py
@admin_router.get("/users/{user_id}/export", response_model=DataExportResponse)
def export_user_data(
    user_id: int,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Export all personal data for a user (admin only). GDPR Article 20."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)

    user_data = {
        "id": user.id,
        "username": user.username,
        "display_name": user.display_name,
        "email": user.email,
        "is_admin": user.is_admin,
        "can_edit": user.can_edit,
        "event_id": user.event_id,
        "tags": user.tags,
        "created_at": user.created_at.isoformat() if user.created_at else None,
        "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
    }

    sessions_count = db.query(AuthSession).filter(AuthSession.user_id == user_id).count()
    credentials_count = db.query(WebAuthnCredential).filter(WebAuthnCredential.user_id == user_id).count()

    push_subs = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).all()
    push_data = [
        {"event_id": s.event_id, "created_at": s.created_at.isoformat() if s.created_at else None}
        for s in push_subs
    ]

    task_edits_count = db.query(TaskEdit).filter(TaskEdit.edited_by_user_id == user_id).count()

    linked_persons = []
    if user.linked_person_id and user.event_id:
        persons = (
            db.query(PublishedPerson)
            .filter(
                PublishedPerson.event_id == user.event_id,
                PublishedPerson.external_person_id == user.linked_person_id,
            )
            .all()
        )
        linked_persons = [
            {"name": f"{p.first_name} {p.last_name}", "email": p.email, "event_id": p.event_id}
            for p in persons
        ]

    audit_entries = (
        db.query(AuditLog)
        .filter(AuditLog.user_id == user_id)
        .order_by(AuditLog.timestamp.desc())
        .limit(500)
        .all()
    )
    audit_data = [
        {
            "timestamp": e.timestamp.isoformat() if e.timestamp else None,
            "action": e.action,
            "resource_type": e.resource_type,
            "outcome": e.outcome,
        }
        for e in audit_entries
    ]

    audit(db, user=admin, action="gdpr.export", resource_type="user",
          resource_id=user_id, request=request)
    db.commit()

    return DataExportResponse(
        user=user_data,
        sessions_count=sessions_count,
        credentials_count=credentials_count,
        push_subscriptions=push_data,
        task_edits_count=task_edits_count,
        linked_persons=linked_persons,
        audit_entries=audit_data,
    )

gdpr_delete_user

gdpr_delete_user(user_id: int, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Create and accept a strict erasure case for one managed user.

Source code in backend/app/api/v1/gdpr.py
@admin_router.delete("/users/{user_id}/gdpr-delete")
def gdpr_delete_user(
    user_id: int,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Create and accept a strict erasure case for one managed user."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)

    deletion_job = _pending_deletion_job(db, user_id)
    if deletion_job is None:
        deletion_job = _new_deletion_job(db, user)
    try:
        accept_subject_request(db, deletion_job, user)
        if deletion_job.desktop_deletion_required:
            event = db.query(Event).filter(Event.id == user.event_id).one()
            ensure_desktop_work_order(
                db,
                deletion_job,
                event=event,
                subject_ref=deletion_job.subject_evidence_id,
            )
    except (EvidenceUnavailable, ValueError) as exc:
        db.rollback()
        raise HTTPException(
            status_code=409,
            detail={"code": "DELETION_WORKFLOW_REJECTED", "message": str(exc)},
        ) from exc

    audit(db, user=admin, action="gdpr.delete", resource_type="user",
          resource_id=None, detail=json.dumps({
              "result": (
                  "accepted_and_queued_for_desktop"
                  if deletion_job.desktop_deletion_required
                  else "accepted_server_only"
              ),
              "deletion_request_id": deletion_job.request_id,
          }), request=request)
    db.commit()

    return {
        "status": "accepted",
        "message": (
            "Access was revoked and the desktop deletion work order was created."
            if deletion_job.desktop_deletion_required
            else "Access was revoked; this server-only account is ready for live-data deletion."
        ),
        "request_id": deletion_job.request_id,
        "state": deletion_job.state,
    }

list_deletion_requests

list_deletion_requests(admin: User = Depends(require_admin), db: Session = Depends(get_db))

List non-identifying deletion workflow receipts.

Source code in backend/app/api/v1/gdpr.py
@admin_router.get("/deletion-requests")
def list_deletion_requests(
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """List non-identifying deletion workflow receipts."""

    jobs = db.query(DeletionCase).order_by(DeletionCase.id.desc()).limit(500).all()
    return [_job_detail(job, db) for job in jobs]

create_event_erasure_request

create_event_erasure_request(event_id: int, body: EventErasureRequestIn, request: Request, root: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Create and accept a whole-event erasure case using the unified workflow.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/events/{event_id}", status_code=202)
def create_event_erasure_request(
    event_id: int,
    body: EventErasureRequestIn,
    request: Request,
    root: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Create and accept a whole-event erasure case using the unified workflow."""

    if not root.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin access required")
    event = db.query(Event).filter(Event.id == event_id).first()
    if event is None:
        raise HTTPException(status_code=404, detail="Event not found")
    existing = db.query(DeletionCase).filter(
        DeletionCase.case_type == "event_erasure",
        DeletionCase.event_evidence_id == event.evidence_id,
        DeletionCase.state.in_(_OPEN_DELETION_STATES),
    ).first()
    if existing is not None:
        return _job_detail(existing, db)
    try:
        job = create_event_erasure_case(
            db,
            event,
            initiation_reason="manual_root",
        )
        event.purge_case_request_id = job.request_id
        event.purge_started_at = job.submitted_at or datetime.now(timezone.utc)
        ensure_desktop_work_order(db, job, event=event, subject_ref=None)
        accept_event_request(db, job, event)
    except (EvidenceUnavailable, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    audit(
        db,
        user=root,
        action="gdpr.create_event_erasure",
        resource_type="deletion_request",
        detail=json.dumps(
            {"deletion_request_id": job.request_id, "event_ref": event.evidence_id}
        ),
        request=request,
    )
    db.commit()
    return _job_detail(job, db)

confirm_deletion_has_no_controlled_backups

confirm_deletion_has_no_controlled_backups(request_id: str, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Confirm that this deployment uses no controlled recovery backups.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/no-controlled-backups")
def confirm_deletion_has_no_controlled_backups(
    request_id: str,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Confirm that this deployment uses no controlled recovery backups."""

    if not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin access required")
    if settings.HA_MODE == "ha":
        raise HTTPException(
            status_code=409,
            detail="Two-node HA requires a clean replacement snapshot so both nodes can prove local snapshot resolution.",
        )
    job = _admin_deletion_job(db, request_id)
    try:
        if job.clean_backup_job_id:
            cancel_pending_clean_backup_request(job_id=job.clean_backup_job_id)
            job.clean_backup_job_id = None
        confirm_no_controlled_backups(db, job)
    except (EvidenceUnavailable, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    audit(db, user=admin, action="gdpr.no_controlled_backups_confirmed",
          resource_type="deletion_request",
          detail=json.dumps({"deletion_request_id": job.request_id}), request=request)
    db.commit()
    return _job_detail(job, db)

advance_deletion_request

advance_deletion_request(request_id: str, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db))

Advance all currently provable machine-side deletion steps.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/advance")
def advance_deletion_request(
    request_id: str,
    request: Request,
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Advance all currently provable machine-side deletion steps."""

    if not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin access required")
    job = _admin_deletion_job(db, request_id)
    try:
        steps = _advance_deletion_case(db, job, admin)
    except (EvidenceUnavailable, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    if steps:
        audit(db, user=admin, action="gdpr.advance_deletion",
              resource_type="deletion_request",
              detail=json.dumps({"deletion_request_id": job.request_id, "steps": steps}),
              request=request)
    db.commit()
    job = _admin_deletion_job(db, request_id)
    try:
        peer_step = _advance_peer_protection(db, job, admin, request)
        if peer_step:
            steps.append(peer_step)
        if job.peer_confirmation_sha256:
            follow_up = _advance_deletion_case(db, job, admin)
            if follow_up:
                steps.extend(follow_up)
                audit(db, user=admin, action="gdpr.advance_deletion",
                      resource_type="deletion_request",
                      detail=json.dumps({"deletion_request_id": job.request_id, "steps": follow_up}),
                      request=request)
                db.commit()
            if (
                settings.HA_MODE == "ha"
                and not job.clean_backup_job_id
                and not job.clean_backup_receipt_id
            ):
                if _queue_clean_backup(db, job, admin, request):
                    steps.append("recovery_snapshot_requested")
    except (EvidenceUnavailable, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    return {"advanced": steps, **_job_detail(job, db)}

resolve_deletion_backups

resolve_deletion_backups(request_id: str, body: BackupResolutionIn, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Record root-authenticated deletion of exact superseded packages.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/resolve-backups")
def resolve_deletion_backups(
    request_id: str,
    body: BackupResolutionIn,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Record root-authenticated deletion of exact superseded packages."""

    if not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin access required")
    job = _admin_deletion_job(db, request_id)
    package_ids = sorted(set(body.package_ids))
    if any(len(value) != 36 for value in package_ids):
        raise HTTPException(status_code=422, detail="Every package ID must be a UUID")
    records = db.query(BackupInventoryRecord).filter(
        BackupInventoryRecord.package_id.in_(package_ids),
    ).all()
    if {record.package_id for record in records} != set(package_ids):
        raise HTTPException(status_code=409, detail="One or more packages are not in the inventory")
    if any(record.status != "superseded_pending_deletion" for record in records):
        raise HTTPException(status_code=409, detail="Only unresolved superseded packages may be resolved")
    payload = {
        "case_id": job.request_id,
        "package_ids": package_ids,
        "outcome": "operator_confirmed_deleted",
        "status": "backup_inventory_resolved",
    }
    digest = append_evidence_record(
        db,
        workflow_type="deletion_case",
        workflow_id=job.request_id,
        operation_type="backup_inventory_resolved",
        record_type="deletion.backup_inventory_resolved",
        payload=payload,
    )
    for record in records:
        record.status = "confirmed_deleted"
        record.deletion_resolution_sha256 = digest
    job.backup_resolution_sha256 = digest
    job.state = "awaiting_checklist"
    audit(
        db,
        user=admin,
        action="gdpr.resolve_backups",
        resource_type="deletion_request",
        detail=json.dumps({"deletion_request_id": job.request_id, "package_count": len(package_ids)}),
        request=request,
    )
    db.commit()
    return _job_detail(job, db)

resolve_deletion_outstanding_actions

resolve_deletion_outstanding_actions(request_id: str, body: OutstandingActionsResolutionIn, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Confirm external copies were removed after the local desktop transaction.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/resolve-outstanding-actions")
def resolve_deletion_outstanding_actions(
    request_id: str,
    body: OutstandingActionsResolutionIn,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Confirm external copies were removed after the local desktop transaction."""

    if not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin access required")
    job = _admin_deletion_job(db, request_id)
    try:
        receipt_sha256 = resolve_outstanding_actions(db, job, actions=body.actions)
    except (EvidenceUnavailable, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    audit(
        db,
        user=admin,
        action="gdpr.resolve_outstanding_actions",
        resource_type="deletion_request",
        detail=json.dumps({"deletion_request_id": job.request_id}),
        request=request,
    )
    db.commit()
    return {"receipt_sha256": receipt_sha256, **_job_detail(job, db)}

create_deletion_checklist

create_deletion_checklist(request_id: str, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Freeze the immutable checklist after machine prerequisites pass.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/checklist")
def create_deletion_checklist(
    request_id: str,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Freeze the immutable checklist after machine prerequisites pass."""

    job = _admin_deletion_job(db, request_id)
    try:
        checklist = build_checklist(job, db)
    except (EvidenceUnavailable, ValueError) as exc:
        db.commit()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    audit(
        db,
        user=admin,
        action="gdpr.create_checklist",
        resource_type="deletion_request",
        detail=json.dumps({"deletion_request_id": job.request_id}),
        request=request,
    )
    db.commit()
    return {
        "checklist": checklist,
        "checklist_sha256": job.checklist_sha256,
        "state": job.state,
    }

begin_deletion_checklist_approval

begin_deletion_checklist_approval(request_id: str, body: ChecklistApprovalBeginIn, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db))

Start a WebAuthn ceremony bound to one checklist and role.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/approvals/begin")
def begin_deletion_checklist_approval(
    request_id: str,
    body: ChecklistApprovalBeginIn,
    request: Request,
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Start a WebAuthn ceremony bound to one checklist and role."""

    job = _admin_deletion_job(db, request_id)
    if not job.checklist_sha256 or job.state not in {"awaiting_approvals", "ready_for_completion"}:
        raise HTTPException(status_code=409, detail="The case has no checklist awaiting approval")
    if body.role != "executor" or not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Only root may authorise Server completion")
    auth_session = getattr(admin, "_auth_session", None)
    if auth_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    nonce = secrets.token_bytes(32)
    challenge = hashlib.sha256(
        b"mp-opt-deletion-checklist-v1\0"
        + job.request_id.encode("ascii")
        + b"\0"
        + job.checklist_sha256.encode("ascii")
        + b"\0"
        + body.role.encode("ascii")
        + b"\0"
        + nonce
    ).digest()
    options = generate_authentication_options(
        rp_id=settings.WEBAUTHN_RP_ID,
        challenge=challenge,
        user_verification=UserVerificationRequirement.REQUIRED,
    )
    ceremony = create_ceremony(
        challenge,
        DELETION_APPROVAL,
        db,
        user_id=admin.id,
        session_id=auth_session.id,
    )
    context = DeletionApprovalChallenge(
        ceremony_id=ceremony.id,
        case_id=job.id,
        checklist_sha256=job.checklist_sha256,
        role=body.role,
        user_id=admin.id,
    )
    db.add(context)
    db.commit()
    return {
        "options": options_to_json(options),
        "ceremony_id": ceremony.id,
        "checklist_sha256": job.checklist_sha256,
        "role": body.role,
    }

begin_deletion_completion_confirmation

begin_deletion_completion_confirmation(request_id: str, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db))

Begin one confirmation that covers the single-maintainer final review.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/completion-confirmation/begin")
def begin_deletion_completion_confirmation(
    request_id: str,
    request: Request,
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Begin one confirmation that covers the single-maintainer final review."""

    if not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin access required")
    job = _admin_deletion_job(db, request_id)
    if not job.checklist_sha256 or job.state not in {"awaiting_approvals", "ready_for_completion"}:
        raise HTTPException(status_code=409, detail="The case is not ready for completion review")
    auth_session = getattr(admin, "_auth_session", None)
    if auth_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    nonce = secrets.token_bytes(32)
    challenge = hashlib.sha256(
        b"mp-opt-deletion-completion-v1\0"
        + job.request_id.encode("ascii")
        + b"\0"
        + job.checklist_sha256.encode("ascii")
        + b"\0"
        + nonce
    ).digest()
    options = generate_authentication_options(
        rp_id=settings.WEBAUTHN_RP_ID,
        challenge=challenge,
        user_verification=UserVerificationRequirement.REQUIRED,
    )
    ceremony = create_ceremony(
        challenge, DELETION_APPROVAL, db,
        user_id=admin.id, session_id=auth_session.id,
    )
    db.add(DeletionApprovalChallenge(
        ceremony_id=ceremony.id,
        case_id=job.id,
        checklist_sha256=job.checklist_sha256,
        role="executor",
        user_id=admin.id,
    ))
    db.commit()
    return {"options": options_to_json(options), "ceremony_id": ceremony.id}

complete_deletion_completion_confirmation

complete_deletion_completion_confirmation(request_id: str, body: CeremonyCompletion, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db))

Verify one human confirmation, record both single-maintainer roles, and finish.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/completion-confirmation/complete")
def complete_deletion_completion_confirmation(
    request_id: str,
    body: CeremonyCompletion,
    request: Request,
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Verify one human confirmation, record both single-maintainer roles, and finish."""

    if not admin.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin access required")
    job = _admin_deletion_job(db, request_id)
    auth_session = getattr(admin, "_auth_session", None)
    if auth_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    ceremony = consume_ceremony(
        body.ceremony_id, DELETION_APPROVAL, db,
        user_id=admin.id, session_id=auth_session.id,
    )
    context = db.query(DeletionApprovalChallenge).filter(
        DeletionApprovalChallenge.ceremony_id == ceremony.id,
        DeletionApprovalChallenge.case_id == job.id,
        DeletionApprovalChallenge.checklist_sha256 == job.checklist_sha256,
        DeletionApprovalChallenge.role == "executor",
        DeletionApprovalChallenge.user_id == admin.id,
        DeletionApprovalChallenge.consumed_at.is_(None),
    ).first()
    if context is None:
        db.rollback()
        raise HTTPException(status_code=400, detail="Completion confirmation is no longer valid")
    try:
        credential_id = _credential_id(body.credential)
        stored = db.query(WebAuthnCredential).filter(
            WebAuthnCredential.credential_id == credential_id,
            WebAuthnCredential.user_id == admin.id,
        ).with_for_update().one()
        _verify_user_handle(body.credential, admin.id)
        verification = verify_authentication_response(
            credential=body.credential,
            expected_challenge=base64url_to_bytes(ceremony.challenge),
            expected_rp_id=settings.WEBAUTHN_RP_ID,
            expected_origin=settings.WEBAUTHN_ORIGIN,
            credential_public_key=stored.public_key,
            credential_current_sign_count=stored.sign_count,
            require_user_verification=True,
        )
        stored.sign_count = verification.new_sign_count
        stored.last_used_at = datetime.now(timezone.utc)
        context.consumed_at = datetime.now(timezone.utc)
        credential_sha256 = hashlib.sha256(credential_id).hexdigest()
        record_checklist_approval(
            db, job, role="executor", user_id=admin.id,
            credential_sha256=credential_sha256,
        )
        complete_case(job, db)
    except Exception as exc:
        db.rollback()
        raise HTTPException(status_code=400, detail="Completion confirmation failed") from exc
    audit(db, user=admin, action="gdpr.confirm_deletion_completion",
          resource_type="deletion_request",
          detail=json.dumps({"deletion_request_id": job.request_id}), request=request)
    db.commit()
    return _job_detail(job, db)

complete_deletion_checklist_approval

complete_deletion_checklist_approval(request_id: str, role: str, body: CeremonyCompletion, request: Request, admin: User = Depends(require_admin), db: Session = Depends(get_db))

Verify and record a checklist-bound passkey approval.

Source code in backend/app/api/v1/gdpr.py
@admin_router.post("/deletion-requests/{request_id}/approvals/{role}/complete")
def complete_deletion_checklist_approval(
    request_id: str,
    role: str,
    body: CeremonyCompletion,
    request: Request,
    admin: User = Depends(require_admin),
    db: Session = Depends(get_db),
):
    """Verify and record a checklist-bound passkey approval."""

    job = _admin_deletion_job(db, request_id)
    auth_session = getattr(admin, "_auth_session", None)
    if auth_session is None:
        raise HTTPException(status_code=401, detail="Session expired or invalid")
    ceremony = consume_ceremony(
        body.ceremony_id,
        DELETION_APPROVAL,
        db,
        user_id=admin.id,
        session_id=auth_session.id,
    )
    context = db.query(DeletionApprovalChallenge).filter(
        DeletionApprovalChallenge.ceremony_id == ceremony.id,
        DeletionApprovalChallenge.case_id == job.id,
        DeletionApprovalChallenge.checklist_sha256 == job.checklist_sha256,
        DeletionApprovalChallenge.role == role,
        DeletionApprovalChallenge.user_id == admin.id,
        DeletionApprovalChallenge.consumed_at.is_(None),
    ).first()
    if context is None:
        db.rollback()
        raise HTTPException(status_code=400, detail="Approval ceremony does not match this checklist")
    try:
        credential_id = _credential_id(body.credential)
        stored = db.query(WebAuthnCredential).filter(
            WebAuthnCredential.credential_id == credential_id,
            WebAuthnCredential.user_id == admin.id,
        ).with_for_update().one()
        _verify_user_handle(body.credential, admin.id)
        verification = verify_authentication_response(
            credential=body.credential,
            expected_challenge=base64url_to_bytes(ceremony.challenge),
            expected_rp_id=settings.WEBAUTHN_RP_ID,
            expected_origin=settings.WEBAUTHN_ORIGIN,
            credential_public_key=stored.public_key,
            credential_current_sign_count=stored.sign_count,
            require_user_verification=True,
        )
        stored.sign_count = verification.new_sign_count
        stored.last_used_at = datetime.now(timezone.utc)
        context.consumed_at = datetime.now(timezone.utc)
        approval = record_checklist_approval(
            db,
            job,
            role=role,
            user_id=admin.id,
            credential_sha256=hashlib.sha256(credential_id).hexdigest(),
        )
        if job.state == "ready_for_completion":
            complete_case(job, db)
    except Exception as exc:
        db.rollback()
        raise HTTPException(status_code=400, detail="Checklist approval failed") from exc
    audit(
        db,
        user=admin,
        action=f"gdpr.approve_checklist.{role}",
        resource_type="deletion_request",
        detail=json.dumps({"deletion_request_id": job.request_id}),
        request=request,
    )
    db.commit()
    return {
        "approval_sha256": approval.approval_sha256,
        "role": approval.role,
        "state": job.state,
    }

dismiss_deletion_request

dismiss_deletion_request(user_id: int, request: Request, admin: User = Depends(require_admin_recent_reauth), db: Session = Depends(get_db))

Clear a user's pending deletion request without taking action.

Source code in backend/app/api/v1/gdpr.py
@admin_router.delete("/users/{user_id}/deletion-request")
def dismiss_deletion_request(
    user_id: int,
    request: Request,
    admin: User = Depends(require_admin_recent_reauth),
    db: Session = Depends(get_db),
):
    """Clear a user's pending deletion request without taking action."""
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    require_user_management_access(user, admin)
    if not user.deletion_requested_at:
        raise HTTPException(status_code=409, detail="No pending deletion request")

    now = datetime.now(timezone.utc)
    deletion_job = _pending_deletion_job(db, user_id)
    if deletion_job is not None:
        try:
            deletion_job.decision_code = "controller_rejected_request"
            append_evidence_record(
                db,
                workflow_type="deletion_case",
                workflow_id=deletion_job.request_id,
                operation_type="rejected",
                record_type="data_subject.deletion.rejected",
                payload={
                    "request_id": deletion_job.request_id,
                    "event_ref": deletion_job.event_evidence_id,
                    "subject_ref": deletion_job.subject_evidence_id,
                    "decision_code": deletion_job.decision_code,
                    "status": "rejected",
                },
            )
        except EvidenceUnavailable as exc:
            db.rollback()
            raise HTTPException(
                status_code=503,
                detail={"code": "EVIDENCE_UNAVAILABLE", "message": str(exc)},
            ) from exc
        deletion_job.state = "rejected"
        deletion_job.decision_at = now
        deletion_job.user_id = None
    user.deletion_requested_at = None
    audit(db, user=admin, action="gdpr.dismiss_deletion", resource_type="user",
          resource_id=user_id,
          detail=json.dumps({
              "deletion_request_id": deletion_job.request_id if deletion_job else None,
              "result": "rejected",
          }),
          request=request)
    db.commit()

    return {"status": "ok", "message": "Deletion request dismissed"}

request_deletion

request_deletion(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Flag the current user's account for deletion. An admin will review.

Source code in backend/app/api/v1/gdpr.py
@user_router.post("/deletion-requests", response_model=DeletionRequestResponse)
@user_router.post("/request-deletion", response_model=DeletionRequestResponse)
def request_deletion(
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Flag the current user's account for deletion. An admin will review."""
    if current_user.is_root_admin:
        raise HTTPException(status_code=403, detail="Root admin cannot request self-deletion")
    ensure_recent_reauth(current_user, db)

    existing = _pending_deletion_job(db, current_user.id)
    if existing is not None:
        return _deletion_response(
            existing,
            status="pending",
            message="Your deletion request is already pending.",
        )

    current_user.deletion_requested_at = datetime.now(timezone.utc)
    deletion_job = _new_deletion_job(db, current_user)
    audit(
        db,
        user=current_user,
        action="gdpr.request_deletion",
        resource_type="deletion_request",
        resource_id=None,
        detail=json.dumps({"deletion_request_id": deletion_job.request_id}),
        request=request,
    )
    db.commit()

    return _deletion_response(
        deletion_job,
        status="ok",
        message="Your deletion request has been submitted. An administrator will process it.",
    )

current_deletion_request

current_deletion_request(current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Return the current durable deletion receipt for the signed-in user.

Source code in backend/app/api/v1/gdpr.py
@user_router.get("/deletion-requests/current", response_model=DeletionRequestResponse)
def current_deletion_request(
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Return the current durable deletion receipt for the signed-in user."""
    job = (
        db.query(DeletionCase)
        .filter(DeletionCase.user_id == current_user.id)
        .order_by(DeletionCase.id.desc())
        .first()
    )
    if job is None:
        raise HTTPException(status_code=404, detail="No deletion request found")
    return _deletion_response(
        job,
        status="pending" if job.state in _OPEN_DELETION_STATES else job.state,
        message="Your deletion request receipt is available.",
    )

deletion_request_status_with_capability

deletion_request_status_with_capability(request_id: str, request: Request, db: Session = Depends(get_db))

Return a minimised status after the requesting account is erased.

Source code in backend/app/api/v1/gdpr.py
@user_router.get("/deletion-requests/{request_id}/status")
def deletion_request_status_with_capability(
    request_id: str,
    request: Request,
    db: Session = Depends(get_db),
):
    """Return a minimised status after the requesting account is erased."""

    job = db.query(DeletionCase).filter(
        DeletionCase.request_id == request_id,
    ).first()
    capability = request.headers.get("x-deletion-status", "")
    if job is None or not verify_status_capability(job, capability):
        raise HTTPException(status_code=404, detail="Deletion request not found")
    return {
        "request_id": job.request_id,
        "state": job.state,
        "submitted_at": job.submitted_at,
        "normal_response_due_at": job.normal_response_due_at,
        "completed_at": job.completed_at,
        "outcome": "verified" if job.state == "complete" else None,
        "retention_reason_code": job.retention_reason_code,
        "retention_review_at": job.retention_review_at,
    }

deletion_request_receipt

deletion_request_receipt(request_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Return one deletion receipt without exposing another user's workflow.

Source code in backend/app/api/v1/gdpr.py
@user_router.get(
    "/deletion-requests/{request_id}/receipt",
    response_model=DeletionRequestResponse,
)
def deletion_request_receipt(
    request_id: str,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Return one deletion receipt without exposing another user's workflow."""
    job = (
        db.query(DeletionCase)
        .filter(
            DeletionCase.request_id == request_id,
            DeletionCase.user_id == current_user.id,
        )
        .first()
    )
    if job is None:
        raise HTTPException(status_code=404, detail="Deletion request not found")
    return _deletion_response(
        job,
        status="pending" if job.state in _OPEN_DELETION_STATES else job.state,
        message="Your deletion request receipt is available.",
    )

withdraw_deletion_request

withdraw_deletion_request(request_id: str, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Record a signed withdrawal before live data has been purged.

Source code in backend/app/api/v1/gdpr.py
@user_router.post(
    "/deletion-requests/{request_id}/withdraw",
    response_model=DeletionRequestResponse,
)
def withdraw_deletion_request(
    request_id: str,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Record a signed withdrawal before live data has been purged."""

    ensure_recent_reauth(current_user, db)
    job = db.query(DeletionCase).filter(
        DeletionCase.request_id == request_id,
        DeletionCase.user_id == current_user.id,
    ).first()
    if job is None:
        raise HTTPException(status_code=404, detail="Deletion request not found")
    if job.state in {
        "live_data_purged",
        "peer_replication_pending",
        "peer_replication_confirmed",
        "awaiting_clean_backup",
        "clean_backup_verified",
        "awaiting_backup_resolution",
        "restricted_retention",
        "awaiting_checklist",
        "awaiting_approvals",
        "ready_for_completion",
        "complete",
    }:
        raise HTTPException(
            status_code=409,
            detail="The request can no longer be withdrawn because live deletion has started.",
        )
    if job.state == "withdrawn":
        return _deletion_response(job, status="withdrawn", message="The deletion request is withdrawn.")
    if job.state == "rejected":
        raise HTTPException(status_code=409, detail="The request has already been rejected")

    try:
        append_evidence_record(
            db,
            workflow_type="deletion_case",
            workflow_id=job.request_id,
            operation_type="withdrawn",
            record_type="data_subject.deletion.withdrawn",
            payload={
                "request_id": job.request_id,
                "event_ref": job.event_evidence_id,
                "subject_ref": job.subject_evidence_id,
                "status": "withdrawn",
            },
        )
    except EvidenceUnavailable as exc:
        raise HTTPException(status_code=503, detail={"code": "EVIDENCE_UNAVAILABLE"}) from exc
    job.state = "withdrawn"
    job.decision_at = datetime.now(timezone.utc)
    current_user.deletion_requested_at = None
    audit(
        db,
        user=current_user,
        action="gdpr.withdraw_deletion",
        resource_type="deletion_request",
        detail=json.dumps({"deletion_request_id": job.request_id}),
        request=request,
    )
    db.commit()
    return _deletion_response(job, status="withdrawn", message="The deletion request is withdrawn.")

History

history

History endpoints - publish snapshot list, detail, delete, and rollback.

SnapshotSummary

Bases: BaseModel

Compact snapshot metadata for history listings.

Source code in backend/app/api/v1/history.py
class SnapshotSummary(BaseModel):
    """Compact snapshot metadata for history listings."""

    id: int
    version: int
    task_count: int
    person_count: int
    edits_count: int
    source: Optional[str] = None
    label: Optional[str] = None
    frozen: bool = False
    created_at: Optional[datetime] = None

    model_config = ConfigDict(from_attributes=True)

SnapshotPatch

Bases: BaseModel

Editable snapshot metadata.

Source code in backend/app/api/v1/history.py
class SnapshotPatch(BaseModel):
    """Editable snapshot metadata."""

    label: Optional[str] = None
    frozen: Optional[bool] = None

SnapshotDetail

Bases: BaseModel

Full snapshot payload including stored schedule data.

Source code in backend/app/api/v1/history.py
class SnapshotDetail(BaseModel):
    """Full snapshot payload including stored schedule data."""

    id: int
    version: int
    task_count: int
    person_count: int
    edits_count: int
    source: Optional[str] = None
    created_at: Optional[datetime] = None
    snapshot: Dict[str, Any]

RestoreResponse

Bases: BaseModel

Result returned after restoring a publish snapshot.

Source code in backend/app/api/v1/history.py
class RestoreResponse(BaseModel):
    """Result returned after restoring a publish snapshot."""

    status: str
    restored_version: int
    tasks_created: int
    persons_created: int
    edits_cleared: int

list_snapshots

list_snapshots(event_id: int, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

List all publish snapshots for an event (newest first).

Source code in backend/app/api/v1/history.py
@router.get(
    "/events/{event_id}/history",
    response_model=List[SnapshotSummary],
)
def list_snapshots(
    event_id: int,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """List all publish snapshots for an event (newest first)."""
    _get_event_or_404(event_id, admin, db)

    rows = (
        db.query(PublishSnapshot)
        .filter(PublishSnapshot.event_id == event_id)
        .order_by(PublishSnapshot.version.desc())
        .all()
    )
    return [
        SnapshotSummary(
            id=r.id,
            version=r.version,
            task_count=r.task_count,
            person_count=r.person_count,
            edits_count=r.edits_count,
            source=r.source,
            label=r.label,
            frozen=r.frozen if r.frozen is not None else False,
            created_at=r.created_at,
        )
        for r in rows
    ]

get_snapshot

get_snapshot(event_id: int, version: int, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Get full snapshot detail for a specific version.

Source code in backend/app/api/v1/history.py
@router.get(
    "/events/{event_id}/history/{version}",
    response_model=SnapshotDetail,
)
def get_snapshot(
    event_id: int,
    version: int,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Get full snapshot detail for a specific version."""
    _get_event_or_404(event_id, admin, db)

    snap = (
        db.query(PublishSnapshot)
        .filter(
            PublishSnapshot.event_id == event_id,
            PublishSnapshot.version == version,
        )
        .first()
    )
    if snap is None:
        raise HTTPException(status_code=404, detail="Snapshot version not found")

    return SnapshotDetail(
        id=snap.id,
        version=snap.version,
        task_count=snap.task_count,
        person_count=snap.person_count,
        edits_count=snap.edits_count,
        source=snap.source,
        created_at=snap.created_at,
        snapshot=json.loads(snap.snapshot_json),
    )

patch_snapshot

patch_snapshot(event_id: int, version: int, body: SnapshotPatch, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Update label and/or frozen status of a snapshot.

Source code in backend/app/api/v1/history.py
@router.patch(
    "/events/{event_id}/history/{version}",
    response_model=SnapshotSummary,
)
def patch_snapshot(
    event_id: int,
    version: int,
    body: SnapshotPatch,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Update label and/or frozen status of a snapshot."""
    _get_event_or_404(event_id, admin, db)

    snap = (
        db.query(PublishSnapshot)
        .filter(
            PublishSnapshot.event_id == event_id,
            PublishSnapshot.version == version,
        )
        .first()
    )
    if snap is None:
        raise HTTPException(status_code=404, detail="Snapshot version not found")

    if body.label is not None:
        snap.label = body.label.strip()[:100] or None

    if body.frozen is not None and body.frozen != snap.frozen:
        if body.frozen:
            frozen_count = (
                db.query(sa_func.count(PublishSnapshot.id))
                .filter(
                    PublishSnapshot.event_id == event_id,
                    PublishSnapshot.frozen == True,  # noqa: E712
                )
                .scalar()
            ) or 0
            if frozen_count >= rt.get_int("max_snapshots_per_event", db):
                raise HTTPException(
                    status_code=409,
                    detail="All snapshot slots are frozen",
                )
        snap.frozen = body.frozen

    audit(
        db,
        user=admin,
        action="history.update",
        resource_type="publish_snapshot",
        resource_id=snap.id,
        detail=json.dumps(
            {
                "event_id": event_id,
                "version": version,
                "changed_fields": sorted(body.model_fields_set),
            }
        ),
        request=request,
    )
    db.commit()
    db.refresh(snap)
    return SnapshotSummary(
        id=snap.id,
        version=snap.version,
        task_count=snap.task_count,
        person_count=snap.person_count,
        edits_count=snap.edits_count,
        source=snap.source,
        label=snap.label,
        frozen=snap.frozen if snap.frozen is not None else False,
        created_at=snap.created_at,
    )

delete_snapshot

delete_snapshot(event_id: int, version: int, request: Request, admin: User = Depends(require_recent_reauth), db: Session = Depends(get_db))

Delete an accessible snapshot after recent passkey verification.

Source code in backend/app/api/v1/history.py
@router.delete(
    "/events/{event_id}/history/{version}",
)
def delete_snapshot(
    event_id: int,
    version: int,
    request: Request,
    admin: User = Depends(require_recent_reauth),
    db: Session = Depends(get_db),
):
    """Delete an accessible snapshot after recent passkey verification."""
    _get_event_or_404(event_id, admin, db)

    snap = (
        db.query(PublishSnapshot)
        .filter(
            PublishSnapshot.event_id == event_id,
            PublishSnapshot.version == version,
        )
        .first()
    )
    if snap is None:
        raise HTTPException(status_code=404, detail="Snapshot version not found")
    if snap.frozen:
        raise HTTPException(status_code=409, detail="Unfreeze snapshot before deleting")

    db.delete(snap)
    audit(
        db,
        user=admin,
        action="history.delete",
        resource_type="publish_snapshot",
        resource_id=snap.id,
        detail=json.dumps({"event_id": event_id, "version": version}),
        request=request,
    )
    db.commit()
    return {"status": "ok", "deleted_version": version}

restore_snapshot

restore_snapshot(event_id: int, version: int, request: Request, admin: User = Depends(require_recent_reauth), db: Session = Depends(get_db))

Restore a snapshot as the live schedule.

  1. Snapshots the current live state first (so rollback is reversible).
  2. Wipes current tasks + edits + persons.
  3. Re-inserts tasks and persons from the snapshot's raw_tasks / persons.
  4. Re-links users by email.
  5. Sends push notification.
Source code in backend/app/api/v1/history.py
@router.post(
    "/events/{event_id}/history/{version}/restore",
    response_model=RestoreResponse,
)
def restore_snapshot(
    event_id: int,
    version: int,
    request: Request,
    admin: User = Depends(require_recent_reauth),
    db: Session = Depends(get_db),
):
    """Restore a snapshot as the live schedule.

    1. Snapshots the current live state first (so rollback is reversible).
    2. Wipes current tasks + edits + persons.
    3. Re-inserts tasks and persons from the snapshot's raw_tasks / persons.
    4. Re-links users by email.
    5. Sends push notification.
    """
    event = _get_event_or_404(event_id, admin, db)

    # Load the target snapshot
    snap = (
        db.query(PublishSnapshot)
        .filter(
            PublishSnapshot.event_id == event_id,
            PublishSnapshot.version == version,
        )
        .first()
    )
    if snap is None:
        raise HTTPException(status_code=404, detail="Snapshot version not found")

    # 1. Snapshot current live state first (makes this rollback reversible)
    create_snapshot(event, db, source=f"pre-rollback to v{version} by {admin.display_name}")

    # 2. Wipe current data
    existing_task_ids = [
        t.id for t in
        db.query(PublishedTask.id).filter(PublishedTask.event_id == event.id).all()
    ]
    if existing_task_ids:
        db.query(TaskEdit).filter(TaskEdit.task_id.in_(existing_task_ids)).delete(
            synchronize_session=False,
        )
    edits_cleared = len(existing_task_ids)

    db.query(PublishedTask).filter(PublishedTask.event_id == event.id).delete(
        synchronize_session=False,
    )
    db.query(PublishedPerson).filter(PublishedPerson.event_id == event.id).delete(
        synchronize_session=False,
    )
    db.query(PublishedPersonUnavailability).filter(
        PublishedPersonUnavailability.event_id == event.id,
    ).delete(synchronize_session=False)

    # 3. Parse snapshot and re-insert
    data = json.loads(snap.snapshot_json)
    raw_tasks = data.get("raw_tasks", [])
    persons = data.get("persons", [])
    unavailabilities = data.get("unavailabilities", [])

    # Restore event metadata from snapshot
    event_meta = data.get("event_meta")
    if event_meta:
        if event_meta.get("name"):
            event.name = event_meta["name"]
        if event_meta.get("start_date"):
            event.start_date = datetime.strptime(event_meta["start_date"], "%Y-%m-%d").date()
        if event_meta.get("end_date"):
            event.end_date = datetime.strptime(event_meta["end_date"], "%Y-%m-%d").date()
        if event_meta.get("metadata_json") is not None:
            event.metadata_json = event_meta["metadata_json"]

    for p in persons:
        db.add(PublishedPerson(
            event_id=event.id,
            external_person_id=p["external_person_id"],
            first_name=p["first_name"],
            last_name=p["last_name"],
            email=p.get("email"),
        ))

    for interval in unavailabilities:
        db.add(PublishedPersonUnavailability(
            event_id=event.id,
            external_person_id=interval["external_person_id"],
            working_date=interval["working_date"],
            start_datetime=interval["start_datetime"],
            end_datetime=interval["end_datetime"],
        ))

    for t in raw_tasks:
        db.add(PublishedTask(
            event_id=event.id,
            external_task_id=t["external_task_id"],
            name=t["name"],
            summary=t.get("summary"),
            description=t.get("description"),
            start_datetime=datetime.fromisoformat(t["start_datetime"]),
            end_datetime=datetime.fromisoformat(t["end_datetime"]),
            location_name=t.get("location_name"),
            location_address=t.get("location_address"),
            task_type_code=t.get("task_type_code"),
            task_type_name=t.get("task_type_name"),
            color=t.get("color"),
            attendees_json=t.get("attendees_json"),
            field_assignments_json=t.get("field_assignments_json"),
            field_values_json=t.get("field_values_json"),
            field_definitions_json=t.get("field_definitions_json"),
            additional_json=t.get("additional_json"),
            sort_order=t.get("sort_order"),
            web_created=t.get("web_created", False),
        ))

    db.flush()

    # 4. Re-link users by email
    from app.api.v1.publish import _auto_link_users_by_email
    _auto_link_users_by_email(event.id, db)

    audit(
        db,
        user=admin,
        action="history.restore",
        resource_type="publish_snapshot",
        resource_id=snap.id,
        detail=json.dumps({"event_id": event_id, "version": version}),
        request=request,
    )
    db.commit()

    # 5. Push notification
    try:
        from app.core.push import send_push_to_event
        send_push_to_event(
            event_id=event.id,
            title="Schedule Restored",
            body=f"{event.name} schedule restored to version {version}.",
            url=f"/calendar?event={event.id}",
            db=db,
            notification_type="schedule",
        )
    except Exception as exc:
        logger.warning("History restore push delivery failed (%s)", type(exc).__name__)

    return RestoreResponse(
        status="ok",
        restored_version=version,
        tasks_created=len(raw_tasks),
        persons_created=len(persons),
        edits_cleared=edits_cleared,
    )

Notifications

notifications

Notification endpoints - push subscription management and announcements.

VapidKeyResponse

Bases: BaseModel

Public VAPID key response for browser push subscription.

Source code in backend/app/api/v1/notifications.py
class VapidKeyResponse(BaseModel):
    """Public VAPID key response for browser push subscription."""

    public_key: str | None

SubscribeRequest

Bases: BaseModel

Browser push subscription payload.

Source code in backend/app/api/v1/notifications.py
class SubscribeRequest(BaseModel):
    """Browser push subscription payload."""

    event_id: int = Field(..., gt=0)
    endpoint: str = Field(..., max_length=2048)
    p256dh: str = Field(..., max_length=256)
    auth: str = Field(..., max_length=256)

SubscribeResponse

Bases: BaseModel

Push subscription mutation status.

Source code in backend/app/api/v1/notifications.py
class SubscribeResponse(BaseModel):
    """Push subscription mutation status."""

    status: str

UnsubscribeRequest

Bases: BaseModel

Push unsubscribe payload containing the browser endpoint.

Source code in backend/app/api/v1/notifications.py
class UnsubscribeRequest(BaseModel):
    """Push unsubscribe payload containing the browser endpoint."""

    endpoint: str = Field(..., max_length=2048)

AnnouncementOut

Bases: BaseModel

Announcement visible to event participants.

Source code in backend/app/api/v1/notifications.py
class AnnouncementOut(BaseModel):
    """Announcement visible to event participants."""

    id: int
    event_id: int
    title: str
    body: str | None
    created_by: str | None
    created_at: str

    model_config = ConfigDict(from_attributes=True)

AnnouncementCreate

Bases: BaseModel

Admin or issuer payload for creating an announcement.

Source code in backend/app/api/v1/notifications.py
class AnnouncementCreate(BaseModel):
    """Admin or issuer payload for creating an announcement."""

    event_id: int
    title: str = Field(..., max_length=256)
    body: str | None = Field(None, max_length=2000)
    push: bool = True  # Whether to also send a push notification

SubscriptionStatusResponse

Bases: BaseModel

Current user's push subscription status for an event.

Source code in backend/app/api/v1/notifications.py
class SubscriptionStatusResponse(BaseModel):
    """Current user's push subscription status for an event."""

    subscribed: bool

ScheduleChangeOut

Bases: BaseModel

Unread schedule change notification for a user.

Source code in backend/app/api/v1/notifications.py
class ScheduleChangeOut(BaseModel):
    """Unread schedule change notification for a user."""

    id: int
    changes: dict
    created_at: str

    model_config = ConfigDict(from_attributes=True)

MarkChangesReadRequest

Bases: BaseModel

Request to mark schedule changes as read for an event.

Source code in backend/app/api/v1/notifications.py
class MarkChangesReadRequest(BaseModel):
    """Request to mark schedule changes as read for an event."""

    event_id: int = Field(..., gt=0)

vapid_key

vapid_key()

Return the VAPID public key for push subscription.

Source code in backend/app/api/v1/notifications.py
@router.get("/vapid-key", response_model=VapidKeyResponse)
def vapid_key():
    """Return the VAPID public key for push subscription."""
    return VapidKeyResponse(public_key=get_application_server_key())

subscribe

subscribe(req: SubscribeRequest, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Save a push subscription for the current user + event.

Source code in backend/app/api/v1/notifications.py
@router.post("/subscribe", response_model=SubscribeResponse)
@limiter.limit("10/minute")
def subscribe(
    req: SubscribeRequest,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Save a push subscription for the current user + event."""
    require_event_access(req.event_id, current_user, db)
    _validate_push_endpoint(req.endpoint)
    if not req.p256dh or not req.auth:
        raise HTTPException(400, "p256dh and auth keys are required")

    # Check if already subscribed with this endpoint
    existing = (
        db.query(PushSubscription)
        .filter(
            PushSubscription.user_id == current_user.id,
            PushSubscription.endpoint == req.endpoint,
        )
        .first()
    )
    if existing:
        # Update keys (browser may rotate them)
        existing.p256dh = req.p256dh
        existing.auth = req.auth
        existing.event_id = req.event_id
    else:
        db.add(PushSubscription(
            user_id=current_user.id,
            event_id=req.event_id,
            endpoint=req.endpoint,
            p256dh=req.p256dh,
            auth=req.auth,
        ))
    db.commit()
    return SubscribeResponse(status="subscribed")

unsubscribe

unsubscribe(req: UnsubscribeRequest, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Remove a push subscription.

Source code in backend/app/api/v1/notifications.py
@router.delete("/subscribe", response_model=SubscribeResponse)
@limiter.limit("10/minute")
def unsubscribe(
    req: UnsubscribeRequest,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Remove a push subscription."""
    deleted = (
        db.query(PushSubscription)
        .filter(
            PushSubscription.user_id == current_user.id,
            PushSubscription.endpoint == req.endpoint,
        )
        .delete(synchronize_session=False)
    )
    db.commit()
    return SubscribeResponse(status="unsubscribed" if deleted else "not_found")

subscription_status

subscription_status(event_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Check if the current user has any push subscription for this event.

Source code in backend/app/api/v1/notifications.py
@router.get("/status/{event_id}", response_model=SubscriptionStatusResponse)
def subscription_status(
    event_id: int,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Check if the current user has any push subscription for this event."""
    require_event_access(event_id, current_user, db)
    exists = (
        db.query(PushSubscription)
        .filter(
            PushSubscription.user_id == current_user.id,
            PushSubscription.event_id == event_id,
        )
        .first()
    )
    return SubscriptionStatusResponse(subscribed=bool(exists))

list_announcements

list_announcements(event_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

List announcements for an event (most recent first).

Source code in backend/app/api/v1/notifications.py
@router.get("/announcements/{event_id}", response_model=List[AnnouncementOut])
def list_announcements(
    event_id: int,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """List announcements for an event (most recent first)."""
    require_event_access(event_id, current_user, db)
    rows = (
        db.query(Announcement, User.display_name)
        .outerjoin(User, Announcement.created_by_id == User.id)
        .filter(Announcement.event_id == event_id)
        .order_by(Announcement.created_at.desc())
        .limit(rt.get_int("announcements_per_event_limit", db))
        .all()
    )
    return [
        AnnouncementOut(
            id=ann.id,
            event_id=ann.event_id,
            title=ann.title,
            body=ann.body,
            created_by=display_name,
            created_at=ann.created_at.isoformat() if ann.created_at else "",
        )
        for ann, display_name in rows
    ]

create_announcement

create_announcement(req: AnnouncementCreate, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Create an announcement (admin or issuer). Optionally sends push notification.

Source code in backend/app/api/v1/notifications.py
@router.post("/announcements", response_model=AnnouncementOut, status_code=201)
def create_announcement(
    req: AnnouncementCreate,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Create an announcement (admin or issuer). Optionally sends push notification."""
    # Issuer scoping: force own event
    if _is_issuer_only(admin):
        req.event_id = admin.event_id
    require_event_access(req.event_id, admin, db)
    require_data_policy_acknowledgement(admin, req.event_id, db)
    ann = Announcement(
        event_id=req.event_id,
        title=req.title,
        body=req.body,
        created_by_id=admin.id,
    )
    db.add(ann)
    db.commit()
    db.refresh(ann)

    audit(db, user=admin, action="announcement.create", resource_type="announcement",
          resource_id=ann.id, request=request)
    db.commit()

    # Send push notification if requested
    if req.push:
        send_push_to_event(
            event_id=req.event_id,
            title=req.title,
            body=req.body or "",
            url=f"/calendar?event={req.event_id}",
            db=db,
            notification_type="announcement",
        )

    return AnnouncementOut(
        id=ann.id,
        event_id=ann.event_id,
        title=ann.title,
        body=ann.body,
        created_by=admin.display_name,
        created_at=ann.created_at.isoformat() if ann.created_at else "",
    )

delete_announcement

delete_announcement(announcement_id: int, request: Request, admin: User = Depends(require_admin_or_issuer), db: Session = Depends(get_db))

Delete an announcement (admin or issuer).

Source code in backend/app/api/v1/notifications.py
@router.delete("/announcements/{announcement_id}", status_code=204)
def delete_announcement(
    announcement_id: int,
    request: Request,
    admin: User = Depends(require_admin_or_issuer),
    db: Session = Depends(get_db),
):
    """Delete an announcement (admin or issuer)."""
    ann = db.query(Announcement).filter(Announcement.id == announcement_id).first()
    if not ann:
        raise HTTPException(status_code=404, detail="Announcement not found")
    # Issuer scoping: verify announcement belongs to their event
    if _is_issuer_only(admin) and ann.event_id != admin.event_id:
        raise HTTPException(status_code=403, detail="No access to announcements from other events")
    audit(db, user=admin, action="announcement.delete", resource_type="announcement",
          resource_id=announcement_id, request=request)
    db.delete(ann)
    db.commit()

list_pending_changes

list_pending_changes(event_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Return unread schedule changes for the current user and event.

Source code in backend/app/api/v1/notifications.py
@router.get("/changes/{event_id}", response_model=List[ScheduleChangeOut])
def list_pending_changes(
    event_id: int,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Return unread schedule changes for the current user and event."""
    require_event_access(event_id, current_user, db)
    import json
    rows = (
        db.query(ScheduleChange)
        .filter(
            ScheduleChange.user_id == current_user.id,
            ScheduleChange.event_id == event_id,
            ScheduleChange.read_at.is_(None),
        )
        .order_by(ScheduleChange.created_at.desc())
        .all()
    )
    return [
        ScheduleChangeOut(
            id=row.id,
            changes=json.loads(row.changes_json) if row.changes_json else {},
            created_at=row.created_at.isoformat() if row.created_at else "",
        )
        for row in rows
    ]

mark_changes_read

mark_changes_read(req: MarkChangesReadRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Mark all unread schedule changes as read for current user + event.

Source code in backend/app/api/v1/notifications.py
@router.post("/changes/read")
def mark_changes_read(
    req: MarkChangesReadRequest,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Mark all unread schedule changes as read for current user + event."""
    require_event_access(req.event_id, current_user, db)
    now = datetime.now(timezone.utc)
    count = (
        db.query(ScheduleChange)
        .filter(
            ScheduleChange.user_id == current_user.id,
            ScheduleChange.event_id == req.event_id,
            ScheduleChange.read_at.is_(None),
        )
        .update({"read_at": now}, synchronize_session=False)
    )
    db.commit()
    return {"status": "ok", "marked": count}

Passkeys

passkey

WebAuthn registration, authentication, and credential management.

BootstrapStatusResponse

Bases: BaseModel

Whether root passkey bootstrap is required and configured.

Source code in backend/app/api/v1/passkey.py
class BootstrapStatusResponse(BaseModel):
    """Whether root passkey bootstrap is required and configured."""

    needs_bootstrap: bool
    bootstrap_configured: bool
    bootstrap_disabled: bool
    stage: Literal["passkey", "setup", "complete"]
    policy_version: str
    policy_sha256: str
    policy_text: str

PasskeyCredentialInfo

Bases: BaseModel

Public metadata for a registered passkey credential.

Source code in backend/app/api/v1/passkey.py
class PasskeyCredentialInfo(BaseModel):
    """Public metadata for a registered passkey credential."""

    id: int
    friendly_name: Optional[str]
    created_at: datetime
    last_used_at: Optional[datetime]

CeremonyCompletion

Bases: BaseModel

Opaque ceremony identifier and browser WebAuthn response.

Source code in backend/app/api/v1/passkey.py
class CeremonyCompletion(BaseModel):
    """Opaque ceremony identifier and browser WebAuthn response."""

    ceremony_id: str = Field(..., min_length=20, max_length=128)
    credential: dict
    policy_version: Optional[str] = Field(None, max_length=64)
    policy_sha256: Optional[str] = Field(None, pattern=r"^[0-9a-f]{64}$")

CredentialRename

Bases: BaseModel

New user-visible name for a passkey.

Source code in backend/app/api/v1/passkey.py
class CredentialRename(BaseModel):
    """New user-visible name for a passkey."""

    friendly_name: str = Field(..., min_length=1, max_length=100)

bootstrap_status

bootstrap_status(db: Session = Depends(get_db))

Return whether root bootstrap is required and operator-enabled.

Source code in backend/app/api/v1/passkey.py
@router.get("/bootstrap-status", response_model=BootstrapStatusResponse)
def bootstrap_status(db: Session = Depends(get_db)):
    """Return whether root bootstrap is required and operator-enabled."""
    disabled = _bootstrap_is_disabled(db)
    return BootstrapStatusResponse(
        needs_bootstrap=_root_needs_bootstrap(db),
        bootstrap_configured=bool(settings.ROOT_BOOTSTRAP_TOKEN) and not disabled,
        bootstrap_disabled=disabled,
        stage=_bootstrap_stage(db),
        policy_version=BOOTSTRAP_POLICY_VERSION,
        policy_sha256=BOOTSTRAP_POLICY_SHA256,
        policy_text=BOOTSTRAP_POLICY_TEXT,
    )

bootstrap_begin

bootstrap_begin(request: Request, db: Session = Depends(get_db))

Start root registration after verifying the operator bootstrap code.

Source code in backend/app/api/v1/passkey.py
@router.post("/bootstrap/begin")
@limiter.limit("5/minute")
def bootstrap_begin(request: Request, db: Session = Depends(get_db)):
    """Start root registration after verifying the operator bootstrap code."""
    _require_bootstrap_token(request, db)
    if _bootstrap_stage(db) != "passkey":
        raise HTTPException(status_code=403, detail="Root passkey registration is already complete")
    root = db.query(User).filter(User.is_root_admin == True).first()  # noqa: E712
    if root is None:
        raise HTTPException(status_code=500, detail="Root admin user not found")
    options = _registration_options(root, db)
    ceremony = create_ceremony(
        options.challenge,
        BOOTSTRAP_REGISTRATION,
        db,
        user_id=root.id,
    )
    return {"options": options_to_json(options), "ceremony_id": ceremony.id}

bootstrap_complete

bootstrap_complete(body: CeremonyCompletion, request: Request, db: Session = Depends(get_db))

Complete the one-time root passkey registration.

Source code in backend/app/api/v1/passkey.py
@router.post("/bootstrap/complete")
@limiter.limit("5/minute")
def bootstrap_complete(
    body: CeremonyCompletion,
    request: Request,
    db: Session = Depends(get_db),
):
    """Complete the one-time root passkey registration."""
    _require_bootstrap_token(request, db)
    if (
        body.policy_version != BOOTSTRAP_POLICY_VERSION
        or body.policy_sha256 != BOOTSTRAP_POLICY_SHA256
    ):
        raise HTTPException(
            status_code=409,
            detail={
                "code": "bootstrap_policy_identity_mismatch",
                "policy_version": BOOTSTRAP_POLICY_VERSION,
                "policy_sha256": BOOTSTRAP_POLICY_SHA256,
                "message": "The setup policy changed. Review the exact policy shown by the server.",
            },
        )
    root = (
        db.query(User)
        .filter(User.is_root_admin == True)  # noqa: E712
        .with_for_update()
        .first()
    )
    if root is None:
        raise HTTPException(status_code=500, detail="Root admin user not found")
    if db.query(WebAuthnCredential).filter(WebAuthnCredential.user_id == root.id).first():
        raise HTTPException(status_code=403, detail="Bootstrap already completed")
    ceremony = consume_ceremony(
        body.ceremony_id,
        BOOTSTRAP_REGISTRATION,
        db,
        user_id=root.id,
    )
    try:
        verification = verify_registration_response(
            credential=body.credential,
            expected_challenge=base64url_to_bytes(ceremony.challenge),
            expected_rp_id=settings.WEBAUTHN_RP_ID,
            expected_origin=settings.WEBAUTHN_ORIGIN,
            require_user_verification=True,
        )
    except Exception as exc:
        logger.warning("Bootstrap registration verification failed (%s)", type(exc).__name__)
        _record_verification_failure(
            db,
            request,
            action="passkey.bootstrap_failed",
            user=root,
        )
        raise HTTPException(status_code=400, detail="Registration verification failed")

    credential = WebAuthnCredential(
        user_id=root.id,
        credential_id=verification.credential_id,
        public_key=verification.credential_public_key,
        sign_count=verification.sign_count,
        aaguid=str(verification.aaguid) if verification.aaguid else None,
        friendly_name="Root Passkey (bootstrap)",
    )
    try:
        with db.begin_nested():
            db.add(credential)
            db.flush()
    except IntegrityError as exc:
        _record_verification_failure(
            db,
            request,
            action="passkey.duplicate_denied",
            user=root,
        )
        raise HTTPException(status_code=409, detail="Passkey is already registered") from exc
    # Authentication is available immediately; commissioning is enforced by
    # authoritative deployment facts rather than the account activation flag.
    root.is_activated = True
    setup_acknowledgement = {
        "governance_setup_ack_instance_id": stable_instance_id(db),
        "governance_setup_ack_root_user_id": str(root.id),
        "governance_setup_ack_version": body.policy_version,
        "governance_setup_ack_sha256": body.policy_sha256,
        "governance_setup_acknowledged_at": datetime.now(timezone.utc).isoformat(),
    }
    for key, value in setup_acknowledgement.items():
        row = db.query(ServerSetting).filter(ServerSetting.key == key).first()
        if row is None:
            db.add(ServerSetting(key=key, value=value))
        else:
            row.value = value
    disabled = db.query(ServerSetting).filter(ServerSetting.key == "root_bootstrap_disabled").first()
    if disabled is None:
        db.add(ServerSetting(key="root_bootstrap_disabled", value="true"))
    else:
        disabled.value = "true"
    raw_code = _create_exchange_code(root.id, db)
    audit(
        db,
        user=root,
        action="passkey.bootstrap",
        resource_type="credential",
        request=request,
    )
    db.commit()
    return {
        "status": "commissioning_required",
        "exchange_code": raw_code,
        "setup_url": "/setup",
        "message": "Root passkey registered. Continue the three-step commissioning wizard.",
    }

register_begin

register_begin(request: Request, db: Session = Depends(get_db))

Start activation-based or recently re-authenticated registration.

Source code in backend/app/api/v1/passkey.py
@router.post("/register/begin")
@limiter.limit(PASSKEY_COARSE_IP_LIMIT, key_func=client_ip_rate_key)
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=passkey_registration_rate_key,
)
def register_begin(request: Request, db: Session = Depends(get_db)):
    """Start activation-based or recently re-authenticated registration."""
    activation = _activation_context(request, db)
    if activation:
        user, link = activation
        purpose = ACTIVATION_REGISTRATION
        session_id = None
        activation_link_id = link.id
    else:
        user, auth_session = _session_registration_context(request, db)
        purpose = ACCOUNT_REGISTRATION
        session_id = auth_session.id
        activation_link_id = None

    options = _registration_options(user, db)
    ceremony = create_ceremony(
        options.challenge,
        purpose,
        db,
        user_id=user.id,
        session_id=session_id,
        activation_link_id=activation_link_id,
    )
    return {"options": options_to_json(options), "ceremony_id": ceremony.id}

register_complete

register_complete(body: CeremonyCompletion, request: Request, db: Session = Depends(get_db))

Verify and store one passkey without losing activation structure.

Source code in backend/app/api/v1/passkey.py
@router.post("/register/complete")
@limiter.limit(PASSKEY_COARSE_IP_LIMIT, key_func=client_ip_rate_key)
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=passkey_registration_rate_key,
)
def register_complete(
    body: CeremonyCompletion,
    request: Request,
    db: Session = Depends(get_db),
):
    """Verify and store one passkey without losing activation structure."""
    activation = _activation_context(request, db, for_update=True)
    if activation:
        user, link = activation
        purpose = ACTIVATION_REGISTRATION
        session_id = None
        activation_link_id = link.id
    else:
        user, auth_session = _session_registration_context(request, db)
        link = None
        purpose = ACCOUNT_REGISTRATION
        session_id = auth_session.id
        activation_link_id = None

    ceremony = consume_ceremony(
        body.ceremony_id,
        purpose,
        db,
        user_id=user.id,
        session_id=session_id,
        activation_link_id=activation_link_id,
    )
    try:
        verification = verify_registration_response(
            credential=body.credential,
            expected_challenge=base64url_to_bytes(ceremony.challenge),
            expected_rp_id=settings.WEBAUTHN_RP_ID,
            expected_origin=settings.WEBAUTHN_ORIGIN,
            require_user_verification=True,
        )
    except Exception as exc:
        logger.warning(
            "Passkey registration verification failed for uid=%s (%s)",
            user.id,
            type(exc).__name__,
        )
        _record_verification_failure(
            db,
            request,
            action="passkey.registration_failed",
            user=user,
        )
        raise HTTPException(status_code=400, detail="Registration verification failed")

    if db.query(WebAuthnCredential).filter(
        WebAuthnCredential.credential_id == verification.credential_id
    ).first():
        _record_verification_failure(
            db,
            request,
            action="passkey.duplicate_denied",
            user=user,
        )
        raise HTTPException(status_code=409, detail="Passkey is already registered")

    credential = WebAuthnCredential(
        user_id=user.id,
        credential_id=verification.credential_id,
        public_key=verification.credential_public_key,
        sign_count=verification.sign_count,
        aaguid=str(verification.aaguid) if verification.aaguid else None,
        friendly_name="Passkey",
    )
    try:
        with db.begin_nested():
            db.add(credential)
            db.flush()
    except IntegrityError as exc:
        _record_verification_failure(
            db,
            request,
            action="passkey.duplicate_denied",
            user=user,
        )
        raise HTTPException(status_code=409, detail="Passkey is already registered") from exc
    replaced_passkeys = _apply_activation_credential_policy(
        user_id=user.id,
        new_credential=credential,
        activation_purpose=link.purpose if link is not None else None,
        db=db,
    )
    if link is not None:
        link.used_at = datetime.now(timezone.utc)
        user.is_activated = True
    audit(
        db,
        user=user,
        action="passkey.register",
        resource_type="credential",
        detail=json.dumps({
            "purpose": link.purpose if link is not None else "account_registration",
            "credentials_replaced": replaced_passkeys,
        }),
        request=request,
    )
    db.commit()

    sessions_revoked = 0
    if not _registration_preserves_sessions(
        link.purpose if link is not None else None
    ):
        sessions_revoked = revoke_all_user_sessions(user.id, db)
    return {
        "status": "ok",
        "message": "Passkey registered",
        "replaced_passkeys": replaced_passkeys,
        "sessions_revoked": sessions_revoked,
    }

list_credentials

list_credentials(current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

List passkeys registered to the current account.

Source code in backend/app/api/v1/passkey.py
@router.get("/credentials", response_model=List[PasskeyCredentialInfo])
def list_credentials(
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """List passkeys registered to the current account."""
    return [
        PasskeyCredentialInfo(
            id=credential.id,
            friendly_name=credential.friendly_name,
            created_at=credential.created_at,
            last_used_at=credential.last_used_at,
        )
        for credential in db.query(WebAuthnCredential)
        .filter(WebAuthnCredential.user_id == current_user.id)
        .all()
    ]

delete_credential

delete_credential(credential_id: int, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Delete a passkey after recent re-authentication.

Source code in backend/app/api/v1/passkey.py
@router.delete("/credentials/{credential_id}")
def delete_credential(
    credential_id: int,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Delete a passkey after recent re-authentication."""
    ensure_recent_reauth(current_user, db)
    # Serialise credential removal per account so concurrent requests cannot
    # both observe two credentials and delete the final two passkeys.
    db.query(User).filter(User.id == current_user.id).with_for_update().one()
    credential = db.query(WebAuthnCredential).filter(
        WebAuthnCredential.id == credential_id,
        WebAuthnCredential.user_id == current_user.id,
    ).first()
    if credential is None:
        raise HTTPException(status_code=404, detail="Credential not found")
    count = db.query(WebAuthnCredential).filter(
        WebAuthnCredential.user_id == current_user.id
    ).count()
    if count <= 1:
        raise HTTPException(status_code=400, detail="Cannot delete the last passkey")
    db.delete(credential)
    audit(
        db,
        user=current_user,
        action="passkey.delete",
        resource_type="credential",
        resource_id=credential_id,
        request=request,
    )
    db.commit()
    revoke_all_user_sessions(current_user.id, db)
    return {"status": "ok", "message": "Credential deleted"}

rename_credential

rename_credential(credential_id: int, body: CredentialRename, request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db))

Update the friendly name of one current-account passkey.

Source code in backend/app/api/v1/passkey.py
@router.patch("/credentials/{credential_id}")
def rename_credential(
    credential_id: int,
    body: CredentialRename,
    request: Request,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    """Update the friendly name of one current-account passkey."""
    credential = db.query(WebAuthnCredential).filter(
        WebAuthnCredential.id == credential_id,
        WebAuthnCredential.user_id == current_user.id,
    ).first()
    if credential is None:
        raise HTTPException(status_code=404, detail="Credential not found")
    credential.friendly_name = body.friendly_name.strip()
    if not credential.friendly_name:
        raise HTTPException(status_code=422, detail="Passkey name is required")
    audit(
        db,
        user=current_user,
        action="passkey.rename",
        resource_type="credential",
        resource_id=credential.id,
        request=request,
    )
    db.commit()
    return {"status": "ok"}

auth_begin

auth_begin(request: Request, db: Session = Depends(get_db))

Start discoverable-passkey authentication.

Source code in backend/app/api/v1/passkey.py
@router.post("/auth/begin")
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=client_ip_rate_key,
)
def auth_begin(request: Request, db: Session = Depends(get_db)):
    """Start discoverable-passkey authentication."""
    options = generate_authentication_options(
        rp_id=settings.WEBAUTHN_RP_ID,
        user_verification=UserVerificationRequirement.REQUIRED,
    )
    ceremony = create_ceremony(options.challenge, AUTHENTICATION, db)
    return {"options": options_to_json(options), "ceremony_id": ceremony.id}

auth_complete

auth_complete(body: CeremonyCompletion, request: Request, db: Session = Depends(get_db))

Verify a discoverable passkey and issue a short-lived exchange code.

Source code in backend/app/api/v1/passkey.py
@router.post("/auth/complete")
@limiter.limit(
    runtime_limit("passkey_requests_per_minute"),
    key_func=client_ip_rate_key,
)
def auth_complete(
    body: CeremonyCompletion,
    request: Request,
    db: Session = Depends(get_db),
):
    """Verify a discoverable passkey and issue a short-lived exchange code."""
    ceremony = consume_ceremony(body.ceremony_id, AUTHENTICATION, db)
    try:
        credential_id = _credential_id(body.credential)
    except HTTPException as exc:
        _record_verification_failure(db, request, action="passkey.auth_failed")
        raise HTTPException(status_code=400, detail="Authentication failed") from exc
    stored_credential = (
        db.query(WebAuthnCredential)
        .filter(WebAuthnCredential.credential_id == credential_id)
        .with_for_update()
        .first()
    )
    if stored_credential is None:
        _record_verification_failure(db, request, action="passkey.auth_failed")
        raise HTTPException(status_code=400, detail="Authentication failed")
    try:
        user = _active_user(
            stored_credential.user_id,
            db,
            allow_root_recovery=True,
        )
    except HTTPException as exc:
        denied_user = db.query(User).filter(User.id == stored_credential.user_id).first()
        _record_verification_failure(
            db,
            request,
            action="passkey.auth_failed",
            user=denied_user,
        )
        raise HTTPException(status_code=401, detail="Authentication failed") from exc
    try:
        _verify_user_handle(body.credential, user.id)
        verification = verify_authentication_response(
            credential=body.credential,
            expected_challenge=base64url_to_bytes(ceremony.challenge),
            expected_rp_id=settings.WEBAUTHN_RP_ID,
            expected_origin=settings.WEBAUTHN_ORIGIN,
            credential_public_key=stored_credential.public_key,
            credential_current_sign_count=stored_credential.sign_count,
            require_user_verification=True,
        )
    except Exception as exc:
        logger.warning(
            "Passkey authentication failed for uid=%s (%s)",
            user.id,
            type(exc).__name__,
        )
        _record_verification_failure(
            db,
            request,
            action="passkey.auth_failed",
            user=user,
        )
        raise HTTPException(status_code=400, detail="Authentication failed")

    stored_credential.sign_count = verification.new_sign_count
    stored_credential.last_used_at = datetime.now(timezone.utc)
    raw_code = _create_exchange_code(user.id, db)
    db.commit()
    return {"status": "ok", "exchange_code": raw_code}

Management and token-authenticated access for shared Public Schedules.

PublicScheduleLinkCreate

Bases: BaseModel

Fields required to create a Public Schedule sharing link.

Source code in backend/app/api/v1/public_schedule_links.py
class PublicScheduleLinkCreate(BaseModel):
    """Fields required to create a Public Schedule sharing link."""

    description: str = Field(..., min_length=1, max_length=256)
    expires_at: datetime
    view_ids: list[int] = Field(..., min_length=1, max_length=100)
    token: str = Field(..., min_length=32, max_length=256)
    idempotency_key: Optional[str] = Field(None, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")

    @field_validator("description")
    @classmethod
    def normalise_description(cls, value: str) -> str:
        """Trim and reject descriptions containing only whitespace."""
        value = value.strip()
        if not value:
            raise ValueError("Description is required")
        return value

    @field_validator("view_ids")
    @classmethod
    def validate_view_ids(cls, value: list[int]) -> list[int]:
        """Require positive, unique Public Schedule view identifiers."""
        if any(view_id <= 0 for view_id in value):
            raise ValueError("View IDs must be positive")
        if len(value) != len(set(value)):
            raise ValueError("View IDs must be unique")
        return value

normalise_description classmethod

normalise_description(value: str) -> str

Trim and reject descriptions containing only whitespace.

Source code in backend/app/api/v1/public_schedule_links.py
@field_validator("description")
@classmethod
def normalise_description(cls, value: str) -> str:
    """Trim and reject descriptions containing only whitespace."""
    value = value.strip()
    if not value:
        raise ValueError("Description is required")
    return value

validate_view_ids classmethod

validate_view_ids(value: list[int]) -> list[int]

Require positive, unique Public Schedule view identifiers.

Source code in backend/app/api/v1/public_schedule_links.py
@field_validator("view_ids")
@classmethod
def validate_view_ids(cls, value: list[int]) -> list[int]:
    """Require positive, unique Public Schedule view identifiers."""
    if any(view_id <= 0 for view_id in value):
        raise ValueError("View IDs must be positive")
    if len(value) != len(set(value)):
        raise ValueError("View IDs must be unique")
    return value

PublicScheduleLinkUpdate

Bases: BaseModel

Editable fields for an active Public Schedule sharing link.

Source code in backend/app/api/v1/public_schedule_links.py
class PublicScheduleLinkUpdate(BaseModel):
    """Editable fields for an active Public Schedule sharing link."""

    description: Optional[str] = Field(None, min_length=1, max_length=256)
    expires_at: Optional[datetime] = None
    view_ids: Optional[list[int]] = Field(None, min_length=1, max_length=100)
    idempotency_key: Optional[str] = Field(None, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")

    @field_validator("description")
    @classmethod
    def normalise_description(cls, value: Optional[str]) -> Optional[str]:
        """Trim an optional description and reject whitespace-only values."""
        if value is None:
            return None
        value = value.strip()
        if not value:
            raise ValueError("Description is required")
        return value

    @field_validator("view_ids")
    @classmethod
    def validate_view_ids(cls, value: Optional[list[int]]) -> Optional[list[int]]:
        """Require positive, unique view identifiers when permissions change."""
        if value is None:
            return None
        if any(view_id <= 0 for view_id in value):
            raise ValueError("View IDs must be positive")
        if len(value) != len(set(value)):
            raise ValueError("View IDs must be unique")
        return value

normalise_description classmethod

normalise_description(value: Optional[str]) -> Optional[str]

Trim an optional description and reject whitespace-only values.

Source code in backend/app/api/v1/public_schedule_links.py
@field_validator("description")
@classmethod
def normalise_description(cls, value: Optional[str]) -> Optional[str]:
    """Trim an optional description and reject whitespace-only values."""
    if value is None:
        return None
    value = value.strip()
    if not value:
        raise ValueError("Description is required")
    return value

validate_view_ids classmethod

validate_view_ids(value: Optional[list[int]]) -> Optional[list[int]]

Require positive, unique view identifiers when permissions change.

Source code in backend/app/api/v1/public_schedule_links.py
@field_validator("view_ids")
@classmethod
def validate_view_ids(cls, value: Optional[list[int]]) -> Optional[list[int]]:
    """Require positive, unique view identifiers when permissions change."""
    if value is None:
        return None
    if any(view_id <= 0 for view_id in value):
        raise ValueError("View IDs must be positive")
    if len(value) != len(set(value)):
        raise ValueError("View IDs must be unique")
    return value

PublicScheduleLinkViewOut

Bases: BaseModel

One view permission shown in the private management interface.

Source code in backend/app/api/v1/public_schedule_links.py
class PublicScheduleLinkViewOut(BaseModel):
    """One view permission shown in the private management interface."""

    id: int
    name: str
    available: bool

PublicScheduleLinkOut

Bases: BaseModel

Private management metadata for a Public Schedule sharing link.

Source code in backend/app/api/v1/public_schedule_links.py
class PublicScheduleLinkOut(BaseModel):
    """Private management metadata for a Public Schedule sharing link."""

    id: int
    event_id: int
    description: str
    expires_at: datetime
    invalidated_at: Optional[datetime] = None
    created_at: datetime
    updated_at: Optional[datetime] = None
    created_by_id: Optional[int] = None
    status: str
    views: list[PublicScheduleLinkViewOut]
    protection_operation_id: Optional[str] = None
    protection_state: Optional[str] = None
    protection_stage: Optional[str] = None

PublicScheduleLinkCreatedOut

Bases: PublicScheduleLinkOut

Creation response containing the sharing URL shown only once.

Source code in backend/app/api/v1/public_schedule_links.py
class PublicScheduleLinkCreatedOut(PublicScheduleLinkOut):
    """Creation response containing the sharing URL shown only once."""

    share_url: Optional[str] = None

SharedScheduleViewOut

Bases: BaseModel

A currently available Public Schedule view exposed by a token.

Source code in backend/app/api/v1/public_schedule_links.py
class SharedScheduleViewOut(BaseModel):
    """A currently available Public Schedule view exposed by a token."""

    id: int
    name: str
    sort_order: float = 0

SharedScheduleAudienceOut

Bases: BaseModel

Public audience label attached to a Session Element.

Source code in backend/app/api/v1/public_schedule_links.py
class SharedScheduleAudienceOut(BaseModel):
    """Public audience label attached to a Session Element."""

    name: Optional[str] = None
    short_name: Optional[str] = None
    colour: Optional[str] = None

SharedScheduleItemOut

Bases: BaseModel

Public programme fields for one shared Session Element occurrence.

Source code in backend/app/api/v1/public_schedule_links.py
class SharedScheduleItemOut(BaseModel):
    """Public programme fields for one shared Session Element occurrence."""

    id: int
    view_id: int
    title: str
    date: str
    start_time: str
    end_time: str
    working_date: str
    location_name: Optional[str] = None
    location_address: Optional[str] = None
    responsible: Optional[str] = None
    audience_teams: list[SharedScheduleAudienceOut] = Field(default_factory=list)
    description: Optional[str] = None
    type_name: Optional[str] = None
    colour: Optional[str] = None
    sort_order: float = 0

SharedScheduleEventOut

Bases: BaseModel

Public event metadata needed to navigate a shared schedule.

Source code in backend/app/api/v1/public_schedule_links.py
class SharedScheduleEventOut(BaseModel):
    """Public event metadata needed to navigate a shared schedule."""

    name: str
    start_date: Optional[str] = None
    end_date: Optional[str] = None
    day_aliases: Optional[dict[str, str]] = None
    schedule_day_range: dict[str, int]

SharedScheduleOut

Bases: BaseModel

Complete privacy-filtered response for a valid sharing token.

Source code in backend/app/api/v1/public_schedule_links.py
class SharedScheduleOut(BaseModel):
    """Complete privacy-filtered response for a valid sharing token."""

    event: SharedScheduleEventOut
    views: list[SharedScheduleViewOut]
    items: list[SharedScheduleItemOut]
list_public_schedule_links(event_id: int, request: Request, user: User = Depends(require_root_or_issuer), db: Session = Depends(get_db))

List retained sharing links for an accessible event.

Source code in backend/app/api/v1/public_schedule_links.py
@admin_router.get(
    "/events/{event_id}/public-schedule-links",
    response_model=list[PublicScheduleLinkOut],
)
@limiter.limit("60/minute")
def list_public_schedule_links(
    event_id: int,
    request: Request,
    user: User = Depends(require_root_or_issuer),
    db: Session = Depends(get_db),
):
    """List retained sharing links for an accessible event."""
    _require_event_access(event_id, user, db)
    links = (
        db.query(PublicScheduleLink)
        .filter(PublicScheduleLink.event_id == event_id)
        .order_by(PublicScheduleLink.created_at.desc(), PublicScheduleLink.id.desc())
        .all()
    )
    values = [_serialise_link(link, db) for link in links]
    db.commit()
    return values
create_public_schedule_link(event_id: int, body: PublicScheduleLinkCreate, request: Request, response: Response, user: User = Depends(require_root_or_issuer), db: Session = Depends(get_db))

Create a sharing link and return its raw URL exactly once.

Source code in backend/app/api/v1/public_schedule_links.py
@admin_router.post(
    "/events/{event_id}/public-schedule-links",
    response_model=PublicScheduleLinkCreatedOut,
    status_code=status.HTTP_201_CREATED,
)
@limiter.limit("20/minute")
def create_public_schedule_link(
    event_id: int,
    body: PublicScheduleLinkCreate,
    request: Request,
    response: Response,
    user: User = Depends(require_root_or_issuer),
    db: Session = Depends(get_db),
):
    """Create a sharing link and return its raw URL exactly once."""
    _require_event_access(event_id, user, db)
    expires_at = _validate_expiry(body.expires_at)
    _validate_new_view_permissions(event_id, body.view_ids, db)
    token_hash = hashlib.sha256(body.token.encode()).hexdigest()
    if settings.HA_MODE == "ha":
        if body.idempotency_key is None:
            raise HTTPException(status_code=422, detail="Idempotency key is required in HA mode")
        existing_operation = find_protection_operation(db, body.idempotency_key)
        if existing_operation is not None:
            if existing_operation.operation_type != "public-link-create":
                raise HTTPException(status_code=409, detail="Idempotency key is already in use")
            link = db.query(PublicScheduleLink).filter(
                PublicScheduleLink.id == int(existing_operation.resource_id or 0)
            ).first()
            if link is None or link.token_hash != token_hash:
                raise HTTPException(status_code=409, detail="Idempotent link request does not match")
            sync_protection_operation(db, existing_operation)
            db.commit()
            response.status_code = status.HTTP_202_ACCEPTED
            return _serialise_link(link, db)
    link = PublicScheduleLink(
        event_id=event_id,
        token_hash=token_hash,
        description=body.description,
        expires_at=expires_at,
        created_by_id=user.id,
    )
    db.add(link)
    db.flush()
    _replace_view_permissions(
        link,
        body.view_ids,
        db,
        allow_existing_unavailable=False,
    )
    audit(
        db,
        user=user,
        action="public_schedule_link.create",
        resource_type="public_schedule_link",
        resource_id=link.id,
        detail=json.dumps(
            {"event_id": event_id, "view_ids": body.view_ids, "expires_at": expires_at.isoformat()}
        ),
        request=request,
    )
    protection: HAProtectionOperation | None = None
    try:
        protection = create_protection_operation(
            db, idempotency_key=body.idempotency_key,
            operation_type="public-link-create", resource_type="public_schedule_link",
            resource_id=str(link.id),
        )
        db.commit()
    except HAWritePermitError as exc:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise HTTPException(status_code=503, detail="The standby protection guard is unavailable") from exc
    except Exception:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise
    db.refresh(link)
    _queue_link_operation(db, protection, response)
    return _serialise_link(
        link,
        db,
        share_url=f"/shared-schedule#token={body.token}" if protection is None else None,
    )
update_public_schedule_link(event_id: int, link_id: int, body: PublicScheduleLinkUpdate, request: Request, response: Response, user: User = Depends(require_root_or_issuer), db: Session = Depends(get_db))

Update an active link without changing its token.

Source code in backend/app/api/v1/public_schedule_links.py
@admin_router.patch(
    "/events/{event_id}/public-schedule-links/{link_id}",
    response_model=PublicScheduleLinkOut,
)
@limiter.limit("30/minute")
def update_public_schedule_link(
    event_id: int,
    link_id: int,
    body: PublicScheduleLinkUpdate,
    request: Request,
    response: Response,
    user: User = Depends(require_root_or_issuer),
    db: Session = Depends(get_db),
):
    """Update an active link without changing its token."""
    if not (body.model_fields_set - {"idempotency_key"}):
        raise HTTPException(status_code=422, detail="No changes supplied")
    link = _load_managed_link(event_id, link_id, user, db)
    if settings.HA_MODE == "ha":
        if body.idempotency_key is None:
            raise HTTPException(status_code=422, detail="Idempotency key is required in HA mode")
        existing_operation = find_protection_operation(db, body.idempotency_key)
        if existing_operation is not None:
            if existing_operation.operation_type != "public-link-update" or existing_operation.resource_id != str(link.id):
                raise HTTPException(status_code=409, detail="Idempotency key is already in use")
            sync_protection_operation(db, existing_operation)
            db.commit()
            response.status_code = status.HTTP_202_ACCEPTED
            return _serialise_link(link, db)
        pending = _link_operation(link.id, db)
        if pending is not None and pending.state in {"pending", "indeterminate"}:
            raise HTTPException(status_code=409, detail={"code": "protection_pending", "operation_id": pending.id})
    _require_active_link(link, db)

    changed_fields: list[str] = []
    if "description" in body.model_fields_set:
        if body.description is None:
            raise HTTPException(status_code=422, detail="Description is required")
        link.description = body.description
        changed_fields.append("description")
    if "expires_at" in body.model_fields_set:
        if body.expires_at is None:
            raise HTTPException(status_code=422, detail="Expiry is required")
        link.expires_at = _validate_expiry(body.expires_at)
        changed_fields.append("expires_at")
    if "view_ids" in body.model_fields_set:
        if body.view_ids is None:
            raise HTTPException(status_code=422, detail="At least one view is required")
        _replace_view_permissions(
            link,
            body.view_ids,
            db,
            allow_existing_unavailable=True,
        )
        changed_fields.append("view_ids")

    audit(
        db,
        user=user,
        action="public_schedule_link.update",
        resource_type="public_schedule_link",
        resource_id=link.id,
        detail=json.dumps({"event_id": event_id, "changed_fields": changed_fields}),
        request=request,
    )
    protection: HAProtectionOperation | None = None
    try:
        protection = create_protection_operation(
            db, idempotency_key=body.idempotency_key,
            operation_type="public-link-update", resource_type="public_schedule_link",
            resource_id=str(link.id),
        )
        db.commit()
    except HAWritePermitError as exc:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise HTTPException(status_code=503, detail="The standby protection guard is unavailable") from exc
    except Exception:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise
    db.refresh(link)
    _queue_link_operation(db, protection, response)
    return _serialise_link(link, db)
invalidate_public_schedule_link(event_id: int, link_id: int, request: Request, response: Response, idempotency_key: Optional[str] = Header(None, alias='Idempotency-Key', pattern='^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$'), user: User = Depends(require_root_or_issuer), db: Session = Depends(get_db))

Permanently invalidate an active Public Schedule sharing link.

Source code in backend/app/api/v1/public_schedule_links.py
@admin_router.post(
    "/events/{event_id}/public-schedule-links/{link_id}/invalidate",
    response_model=PublicScheduleLinkOut,
)
@limiter.limit("30/minute")
def invalidate_public_schedule_link(
    event_id: int,
    link_id: int,
    request: Request,
    response: Response,
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key", pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$"),
    user: User = Depends(require_root_or_issuer),
    db: Session = Depends(get_db),
):
    """Permanently invalidate an active Public Schedule sharing link."""
    link = _load_managed_link(event_id, link_id, user, db)
    if settings.HA_MODE == "ha":
        if idempotency_key is None:
            raise HTTPException(status_code=422, detail="Idempotency key is required in HA mode")
        existing_operation = find_protection_operation(db, idempotency_key)
        if existing_operation is not None:
            if existing_operation.operation_type != "public-link-invalidate" or existing_operation.resource_id != str(link.id):
                raise HTTPException(status_code=409, detail="Idempotency key is already in use")
            sync_protection_operation(db, existing_operation)
            db.commit()
            response.status_code = status.HTTP_202_ACCEPTED
            return _serialise_link(link, db)
        pending = _link_operation(link.id, db)
        if pending is not None and pending.state in {"pending", "indeterminate"}:
            raise HTTPException(status_code=409, detail={"code": "protection_pending", "operation_id": pending.id})
    _require_active_link(link, db)
    link.invalidated_at = datetime.now(timezone.utc)
    audit(
        db,
        user=user,
        action="public_schedule_link.invalidate",
        resource_type="public_schedule_link",
        resource_id=link.id,
        detail=json.dumps({"event_id": event_id}),
        request=request,
    )
    protection: HAProtectionOperation | None = None
    try:
        protection = create_protection_operation(
            db, idempotency_key=idempotency_key,
            operation_type="public-link-invalidate", resource_type="public_schedule_link",
            resource_id=str(link.id),
        )
        db.commit()
    except HAWritePermitError as exc:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise HTTPException(status_code=503, detail="The standby protection guard is unavailable") from exc
    except Exception:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise
    db.refresh(link)
    _queue_link_operation(db, protection, response)
    return _serialise_link(link, db)
delete_public_schedule_link(event_id: int, link_id: int, request: Request, response: Response, idempotency_key: Optional[str] = Header(None, alias='Idempotency-Key', pattern='^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$'), user: User = Depends(require_root_or_issuer), db: Session = Depends(get_db))

Permanently delete a managed Public Schedule sharing link in any state.

Source code in backend/app/api/v1/public_schedule_links.py
@admin_router.delete(
    "/events/{event_id}/public-schedule-links/{link_id}",
)
@limiter.limit("30/minute")
def delete_public_schedule_link(
    event_id: int,
    link_id: int,
    request: Request,
    response: Response,
    idempotency_key: Optional[str] = Header(None, alias="Idempotency-Key", pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$"),
    user: User = Depends(require_root_or_issuer),
    db: Session = Depends(get_db),
):
    """Permanently delete a managed Public Schedule sharing link in any state."""
    if settings.HA_MODE == "ha":
        if idempotency_key is None:
            raise HTTPException(status_code=422, detail="Idempotency key is required in HA mode")
        existing_operation = find_protection_operation(db, idempotency_key)
        if existing_operation is not None:
            if existing_operation.operation_type != "public-link-delete" or existing_operation.resource_id != str(link_id):
                raise HTTPException(status_code=409, detail="Idempotency key is already in use")
            sync_protection_operation(db, existing_operation)
            db.commit()
            response.status_code = status.HTTP_202_ACCEPTED
            return {
                "protection_operation_id": existing_operation.id,
                "protection_state": existing_operation.state,
                "protection_stage": existing_operation.stage,
            }
    link = _load_managed_link(event_id, link_id, user, db)
    pending = _link_operation(link.id, db)
    if pending is not None and pending.state in {"pending", "indeterminate"}:
        raise HTTPException(status_code=409, detail={"code": "protection_pending", "operation_id": pending.id})
    previous_status = _serialise_link(link, db).status
    audit(
        db,
        user=user,
        action="public_schedule_link.delete",
        resource_type="public_schedule_link",
        resource_id=link.id,
        detail=json.dumps(
            {"event_id": event_id, "previous_status": previous_status}
        ),
        request=request,
    )
    db.delete(link)
    protection: HAProtectionOperation | None = None
    try:
        protection = create_protection_operation(
            db, idempotency_key=idempotency_key,
            operation_type="public-link-delete", resource_type="public_schedule_link",
            resource_id=str(link.id),
        )
        db.commit()
    except HAWritePermitError as exc:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise HTTPException(status_code=503, detail="The standby protection guard is unavailable") from exc
    except Exception:
        db.rollback()
        cancel_uncommitted_protection(protection)
        raise
    _queue_link_operation(db, protection, response)
    if protection is None:
        response.status_code = status.HTTP_204_NO_CONTENT
        return None
    return {
        "protection_operation_id": protection.id,
        "protection_state": protection.state,
        "protection_stage": protection.stage,
    }

get_shared_public_schedule

get_shared_public_schedule(request: Request, authorization: Optional[str] = Header(None), db: Session = Depends(get_db))

Return public programme data for a valid bearer sharing token.

Source code in backend/app/api/v1/public_schedule_links.py
@public_router.get("/shared", response_model=SharedScheduleOut)
@limiter.limit("120/minute")
def get_shared_public_schedule(
    request: Request,
    authorization: Optional[str] = Header(None),
    db: Session = Depends(get_db),
):
    """Return public programme data for a valid bearer sharing token."""
    token = _extract_bearer_token(authorization)
    if token is None:
        raise HTTPException(status_code=404, detail=_UNAVAILABLE_DETAIL)
    token_hash = hashlib.sha256(token.encode()).hexdigest()
    link = (
        db.query(PublicScheduleLink)
        .filter(PublicScheduleLink.token_hash == token_hash)
        .first()
    )
    if link is None:
        raise HTTPException(status_code=404, detail=_UNAVAILABLE_DETAIL)
    protection = _link_operation(link.id, db)
    if protection is not None and protection.state != "accepted":
        raise HTTPException(status_code=404, detail=_UNAVAILABLE_DETAIL)

    view_rows = _link_view_rows(link.id, db)
    current_views = _current_views(link.event_id, db)
    available_ids = {
        row.external_view_id
        for row in view_rows
        if row.external_view_id in current_views
    }
    if _link_status(link, view_rows, set(current_views)) != "active" or not available_ids:
        raise HTTPException(status_code=404, detail=_UNAVAILABLE_DETAIL)

    event = db.query(Event).filter(Event.id == link.event_id).first()
    if event is None:
        raise HTTPException(status_code=404, detail=_UNAVAILABLE_DETAIL)
    schedule_day_range = event_schedule_day_range(event.metadata_json)

    views = sorted(
        (current_views[view_id] for view_id in available_ids),
        key=lambda row: ((row.sort_order or 0), row.name),
    )
    items = (
        db.query(PublishedGeneralScheduleItem)
        .filter(
            PublishedGeneralScheduleItem.event_id == link.event_id,
            PublishedGeneralScheduleItem.category_id.in_(available_ids),
        )
        .order_by(
            PublishedGeneralScheduleItem.date.asc(),
            PublishedGeneralScheduleItem.start_time.asc(),
            PublishedGeneralScheduleItem.sort_order.asc(),
            PublishedGeneralScheduleItem.title.asc(),
        )
        .all()
    )
    return SharedScheduleOut(
        event=SharedScheduleEventOut(
            name=event.name,
            start_date=event.start_date.isoformat() if event.start_date else None,
            end_date=event.end_date.isoformat() if event.end_date else None,
            day_aliases=_day_aliases(event),
            schedule_day_range=schedule_day_range,
        ),
        views=[
            SharedScheduleViewOut(
                id=view.external_category_id,
                name=view.name,
                sort_order=view.sort_order or 0,
            )
            for view in views
        ],
        items=[
            SharedScheduleItemOut(
                id=index,
                view_id=item.category_id,
                title=item.title,
                date=item.date,
                start_time=item.start_time,
                end_time=item.end_time,
                working_date=working_date_for_clock(
                    item.date,
                    item.start_time,
                    schedule_day_range,
                ),
                location_name=item.location_name,
                location_address=item.location_address,
                responsible=item.responsible,
                audience_teams=_public_audience(item.audience_teams_json),
                description=item.description,
                type_name=item.type_name,
                colour=item.colour,
                sort_order=item.sort_order or 0,
            )
            for index, item in enumerate(items, start=1)
        ],
    )

Publishing

publish

Publish endpoint - receives masterplan data from the desktop app.

Authentication: Bearer token matching an event's publish_secret_hash. Strategy: full publish replaces the event schedule; date-scoped publish replaces only the requested published days.

AttendeeIn

Bases: BaseModel

Published attendee received from the desktop app.

Source code in backend/app/api/v1/publish.py
class AttendeeIn(BaseModel):
    """Published attendee received from the desktop app."""

    model_config = ConfigDict(extra="forbid")

    name: str = Field(..., max_length=256)
    person_id: int = Field(..., gt=0)

PublishedFieldDefinitionIn

Bases: BaseModel

Reviewed purpose and audience for one bounded published field.

Source code in backend/app/api/v1/publish.py
class PublishedFieldDefinitionIn(BaseModel):
    """Reviewed purpose and audience for one bounded published field."""

    model_config = ConfigDict(extra="forbid")

    id: str = Field(..., min_length=1, max_length=128, pattern=r"^[A-Za-z0-9_.:-]+$")
    name: str = Field(..., min_length=1, max_length=256)
    type: FieldType
    purpose: FieldPurpose
    visibility: FieldVisibility

TaskIn

Bases: BaseModel

Published task received from the desktop app.

Source code in backend/app/api/v1/publish.py
class TaskIn(BaseModel):
    """Published task received from the desktop app."""

    model_config = ConfigDict(extra="forbid")

    id: int = Field(..., gt=0)
    name: str = Field(..., max_length=512)
    summary: Optional[str] = Field(None, max_length=2000)
    description: Optional[str] = Field(None, max_length=10000)
    start: str = Field(..., max_length=64)  # ISO datetime
    end: str = Field(..., max_length=64)    # ISO datetime
    location_name: Optional[str] = Field(None, max_length=512)
    location_address: Optional[str] = Field(None, max_length=1024)
    task_type_code: Optional[str] = Field(None, max_length=64)
    task_type_name: Optional[str] = Field(None, max_length=256)
    color: Optional[str] = Field(None, max_length=32)
    attendees: List[AttendeeIn] = Field(default_factory=list)
    field_assignments: Optional[Dict[str, List[AttendeeIn]]] = None
    field_values: Optional[Dict[str, Any]] = Field(None, max_length=100)
    field_definitions: Optional[List[PublishedFieldDefinitionIn]] = Field(None, max_length=100)
    sort_order: Optional[float] = 0

    @model_validator(mode="after")
    def reject_private_profiling(self):
        """Reject structured fields that the operational service must not hold."""

        _reject_prohibited_profile_fields(
            field_values=self.field_values,
            field_definitions=self.field_definitions,
            additional=None,
        )
        definitions = self.field_definitions or []
        definition_by_id = {definition.id: definition for definition in definitions}
        if len(definition_by_id) != len(definitions):
            raise ValueError("Published field identifiers must be unique")
        values = self.field_values or {}
        assignments = self.field_assignments or {}
        unknown = (set(values) | set(assignments)) - set(definition_by_id)
        if unknown:
            raise ValueError("Published values contain an unclassified field")
        for field_id, definition in definition_by_id.items():
            if definition.visibility == "never_publish" and (
                field_id in values or field_id in assignments
            ):
                raise ValueError("Fields marked never_publish must not cross the publish boundary")
            if field_id in values and not validate_published_field_value(
                definition.type, values[field_id]
            ):
                raise ValueError(f"Published field {field_id} does not match its declared type")
            if definition.type == "persons_list" and field_id in values:
                raise ValueError("persons_list data must use the structured assignment contract")
            if field_id in assignments and definition.type != "persons_list":
                raise ValueError("Only persons_list fields may contain published assignments")
        return self

reject_private_profiling

reject_private_profiling()

Reject structured fields that the operational service must not hold.

Source code in backend/app/api/v1/publish.py
@model_validator(mode="after")
def reject_private_profiling(self):
    """Reject structured fields that the operational service must not hold."""

    _reject_prohibited_profile_fields(
        field_values=self.field_values,
        field_definitions=self.field_definitions,
        additional=None,
    )
    definitions = self.field_definitions or []
    definition_by_id = {definition.id: definition for definition in definitions}
    if len(definition_by_id) != len(definitions):
        raise ValueError("Published field identifiers must be unique")
    values = self.field_values or {}
    assignments = self.field_assignments or {}
    unknown = (set(values) | set(assignments)) - set(definition_by_id)
    if unknown:
        raise ValueError("Published values contain an unclassified field")
    for field_id, definition in definition_by_id.items():
        if definition.visibility == "never_publish" and (
            field_id in values or field_id in assignments
        ):
            raise ValueError("Fields marked never_publish must not cross the publish boundary")
        if field_id in values and not validate_published_field_value(
            definition.type, values[field_id]
        ):
            raise ValueError(f"Published field {field_id} does not match its declared type")
        if definition.type == "persons_list" and field_id in values:
            raise ValueError("persons_list data must use the structured assignment contract")
        if field_id in assignments and definition.type != "persons_list":
            raise ValueError("Only persons_list fields may contain published assignments")
    return self

PersonIn

Bases: BaseModel

Published person received from the desktop app.

Source code in backend/app/api/v1/publish.py
class PersonIn(BaseModel):
    """Published person received from the desktop app."""

    model_config = ConfigDict(extra="forbid")

    id: int = Field(..., gt=0)
    first_name: str = Field(..., max_length=256)
    last_name: str = Field(..., max_length=256)
    email: Optional[str] = Field(None, max_length=512)
    evidence_subject_id: str = Field(
        ...,
        pattern=r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
    )

EventMetaIn

Bases: BaseModel

Published event metadata supplied by the desktop app.

Source code in backend/app/api/v1/publish.py
class EventMetaIn(BaseModel):
    """Published event metadata supplied by the desktop app."""

    model_config = ConfigDict(extra="forbid")

    name: Optional[str] = Field(None, max_length=256)
    start_date: Optional[str] = Field(None, max_length=16)
    end_date: Optional[str] = Field(None, max_length=16)
    day_aliases: Optional[Dict[str, str]] = None  # {"2026-08-28": "Arrival Day"}
    schedule_day_range: Optional[Dict[str, int]] = None

PersonUnavailabilityIn

Bases: BaseModel

Published person-unavailability interval for one working day.

Source code in backend/app/api/v1/publish.py
class PersonUnavailabilityIn(BaseModel):
    """Published person-unavailability interval for one working day."""

    model_config = ConfigDict(extra="forbid")

    person_id: int = Field(..., gt=0)
    working_date: str = Field(..., max_length=10)
    start: str = Field(..., max_length=32)
    end: str = Field(..., max_length=32)

PublishPayload

Bases: BaseModel

Published schedule payload from the desktop app.

Source code in backend/app/api/v1/publish.py
class PublishPayload(BaseModel):
    """Published schedule payload from the desktop app."""

    model_config = ConfigDict(extra="forbid")

    contract_version: Literal[PUBLISH_CONTRACT_VERSION]
    event: Optional[EventMetaIn] = None
    tasks: List[TaskIn]
    persons: List[PersonIn] = Field(default_factory=list)
    unavailabilities: List[PersonUnavailabilityIn] = Field(default_factory=list)
    publish_scope: Optional[Literal["full", "dates"]] = "full"
    dates: Optional[List[str]] = None

ProcessorPublicPackageIn

Bases: BaseModel

Public-only event processor enrolment material.

Source code in backend/app/api/v1/publish.py
class ProcessorPublicPackageIn(BaseModel):
    """Public-only event processor enrolment material."""

    model_config = ConfigDict(extra="forbid")
    format: Literal["mp-opt-processor-public-key-v1"]
    instance_id: None = None
    entity_id: str = Field(pattern=r"^prc-[a-z0-9]{8,48}$")
    key_id: str = Field(pattern=r"^ek-[0-9a-f]{16}$")
    role: Literal["processor"]
    algorithm: Literal["Ed25519"]
    public_key: str = Field(min_length=32, max_length=2048)
    public_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    supersedes_key_id: str | None = Field(default=None, pattern=r"^ek-[0-9a-f]{16}$")
    rotation_reason: Literal["routine", "lost", "compromised"] | None = None
    display_label: str | None = Field(default=None, max_length=128)
    created_at: str = Field(max_length=32)
    signature_namespace: Literal["mp-opt-role-trust-v1"]

PublishResponse

Bases: BaseModel

Summary of rows created and edits cleared by a publish.

Source code in backend/app/api/v1/publish.py
class PublishResponse(BaseModel):
    """Summary of rows created and edits cleared by a publish."""

    status: str
    tasks_created: int
    persons_created: int
    edits_cleared: int

PingResponse

Bases: BaseModel

Publish credential health-check response.

Source code in backend/app/api/v1/publish.py
class PingResponse(BaseModel):
    """Publish credential health-check response."""

    status: str
    event_name: str
    event_id: int
    event_ref: str
    supports_scoped_publish: bool = True
    supports_deletion_work_orders: bool = True

DesktopDeletionCounts

Bases: BaseModel

Bounded deletion counters that contain no personal values.

Source code in backend/app/api/v1/publish.py
class DesktopDeletionCounts(BaseModel):
    """Bounded deletion counters that contain no personal values."""

    model_config = ConfigDict(extra="forbid")

    persons: int = Field(ge=0)
    assignments: int = Field(ge=0)
    capability_links: int = Field(ge=0)
    group_memberships: int = Field(ge=0)
    unavailability_intervals: int = Field(ge=0)
    task_references: int = Field(ge=0)
    optimisation_records: int = Field(ge=0)
    publish_records: int = Field(ge=0)
    cached_records: int = Field(ge=0)
    tracked_exports: int = Field(ge=0)
    integration_references: int = Field(ge=0)

DesktopDeletionReportIn

Bases: BaseModel

Processor-signed Desktop deletion receipt document.

Source code in backend/app/api/v1/publish.py
class DesktopDeletionReportIn(BaseModel):
    """Processor-signed Desktop deletion receipt document."""

    model_config = ConfigDict(extra="forbid")

    format: Literal["mp-opt-desktop-deletion-receipt-v2"]
    instance_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    entity_id: str = Field(pattern=r"^prc-[a-z0-9]{8,48}$")
    key_id: str = Field(pattern=r"^ek-[0-9a-f]{16}$")
    role: Literal["processor"]
    algorithm: Literal["Ed25519"]
    public_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    work_order_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    event_ref: str = Field(pattern=r"^[0-9a-f-]{36}$")
    subject_ref: Optional[str] = Field(None, pattern=r"^[0-9a-f-]{36}$")
    operation: Literal["delete_subject", "delete_event"]
    outcome: Literal["deleted"]
    deleted_counts: DesktopDeletionCounts
    outstanding_actions: List[
        Literal["untracked_external_export", "external_integration_copy"]
    ] = Field(default_factory=list)
    completed_at: str = Field(max_length=40)

DesktopWorkOrderClaimIn

Bases: BaseModel

Processor-signed request to claim its own event work order.

Source code in backend/app/api/v1/publish.py
class DesktopWorkOrderClaimIn(BaseModel):
    """Processor-signed request to claim its own event work order."""

    model_config = ConfigDict(extra="forbid")
    format: Literal["mp-opt-desktop-work-order-claim-v1"]
    instance_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    event_ref: str = Field(pattern=r"^[0-9a-f-]{36}$")
    entity_id: str = Field(pattern=r"^prc-[a-z0-9]{8,48}$")
    key_id: str = Field(pattern=r"^ek-[0-9a-f]{16}$")
    role: Literal["processor"]
    algorithm: Literal["Ed25519"]
    public_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    work_order_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    requested_at: str = Field(max_length=40)

DesktopCopyResolutionIn

Bases: BaseModel

Processor statement about Desktop-local backups and exports.

Source code in backend/app/api/v1/publish.py
class DesktopCopyResolutionIn(BaseModel):
    """Processor statement about Desktop-local backups and exports."""

    model_config = ConfigDict(extra="forbid")
    format: Literal["mp-opt-desktop-copy-resolution-v1"]
    instance_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    event_ref: str = Field(pattern=r"^[0-9a-f-]{36}$")
    entity_id: str = Field(pattern=r"^prc-[a-z0-9]{8,48}$")
    key_id: str = Field(pattern=r"^ek-[0-9a-f]{16}$")
    role: Literal["processor"]
    algorithm: Literal["Ed25519"]
    public_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
    work_order_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    disposition: Literal["no_known_local_copies", "relevant_local_copies_deleted"]
    software_inventory_complete: bool
    operator_confirmation: Literal["LOCAL COPIES RESOLVED"]
    completed_at: str = Field(max_length=40)

begin_processor_event_enrolment

begin_processor_event_enrolment(body: ProcessorPublicPackageIn, request: Request, db: Session = Depends(get_db))

Create an event-bound proof challenge from public Desktop material.

Source code in backend/app/api/v1/publish.py
@router.post("/processor-keys/enrolments", status_code=202)
@limiter.limit("10/minute")
def begin_processor_event_enrolment(
    body: ProcessorPublicPackageIn,
    request: Request,
    db: Session = Depends(get_db),
):
    """Create an event-bound proof challenge from public Desktop material."""

    event = _authenticate_event(request, db)
    try:
        validate_entity("processor", body.entity_id)
        state = initialise(db)
        if state is None:
            raise EvidenceUnavailable("required evidence is unavailable")
        public = canonical_public_key(body.public_key)
        identifier = key_id(public)
        fingerprint = public_key_sha256(public)
        if identifier != body.key_id or fingerprint != body.public_key_sha256:
            raise TrustEvidenceError("the processor public package fingerprint is inconsistent")
        identity = db.query(ProcessorIdentity).filter(
            ProcessorIdentity.instance_id == state.instance_id,
            ProcessorIdentity.entity_id == body.entity_id,
        ).first()
        if identity is not None and identity.event_evidence_id != event.evidence_id:
            raise TrustEvidenceError("this processor identity is immutably assigned to another event")
        existing_key = db.query(EvidenceKey).filter(EvidenceKey.key_id == identifier).first()
        if existing_key is not None and identity is not None and identity.active_key_id == identifier:
            return {"status": "active", "key_id": identifier, "entity_id": body.entity_id, "event_ref": event.evidence_id}
        purpose = "rotate" if body.supersedes_key_id else "register"
        previous = None
        if purpose == "rotate":
            if body.rotation_reason not in ROTATION_REASONS:
                raise TrustEvidenceError("processor rotation requires a bounded reason")
            previous = db.query(EvidenceKey).filter(
                EvidenceKey.key_id == body.supersedes_key_id,
                EvidenceKey.entity_id == body.entity_id,
                EvidenceKey.role == "processor",
                EvidenceKey.revoked_at.is_(None),
            ).first()
            if previous is None or identity is None or identity.active_key_id != previous.key_id:
                raise TrustEvidenceError("the superseded key is not active for this event processor")
        elif body.rotation_reason is not None:
            raise TrustEvidenceError("new processor enrolment cannot include a rotation reason")
        duplicate = db.query(EvidenceKeyRegistrationChallenge).filter(
            EvidenceKeyRegistrationChallenge.key_id == identifier,
            EvidenceKeyRegistrationChallenge.event_evidence_id == event.evidence_id,
            EvidenceKeyRegistrationChallenge.used_at.is_(None),
        ).first()
        if duplicate is not None and _utc(duplicate.expires_at) >= datetime.now(timezone.utc):
            return {"status": "challenge", "challenge": json.loads(duplicate.challenge_json), "challenge_sha256": duplicate.challenge_sha256}
        now = datetime.now(timezone.utc).replace(microsecond=0)
        expires = now + timedelta(minutes=10)
        document = {
            "format": PROCESSOR_EVENT_REGISTRATION_FORMAT,
            "challenge_id": str(uuid.uuid4()),
            "action": purpose,
            "instance_id": state.instance_id,
            "event_ref": event.evidence_id,
            "entity_id": body.entity_id,
            "key_id": identifier,
            "role": "processor",
            "algorithm": "Ed25519",
            "public_key_sha256": fingerprint,
            "supersedes_key_id": previous.key_id if previous else None,
            "reason": body.rotation_reason if previous else None,
            "action_sha256": "",
            "nonce": base64.b64encode(secrets.token_bytes(32)).decode("ascii"),
            "created_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
            "expires_at": expires.strftime("%Y-%m-%dT%H:%M:%SZ"),
        }
        document["action_sha256"] = processor_event_action_sha256(document)
        validate_processor_event_registration(document)
        rendered = canonical_json(document)
        challenge = EvidenceKeyRegistrationChallenge(
            challenge_id=document["challenge_id"], purpose=purpose,
            instance_id=state.instance_id, entity_id=body.entity_id,
            event_id=event.id, event_evidence_id=event.evidence_id,
            event_display_name=event.name, display_label=body.display_label,
            public_key=public, public_key_sha256=fingerprint, key_id=identifier,
            role="processor", supersedes_key_id=previous.key_id if previous else None,
            rotation_reason=body.rotation_reason if previous else None,
            challenge_json=rendered.decode("utf-8"),
            challenge_sha256=hashlib.sha256(rendered).hexdigest(),
            action_sha256=document["action_sha256"], expires_at=expires,
        )
        db.add(challenge)
        db.commit()
        return {"status": "challenge", "challenge": document, "challenge_sha256": challenge.challenge_sha256}
    except (EvidenceUnavailable, TrustEvidenceError, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail={"code": "PROCESSOR_ENROLMENT_REJECTED", "message": str(exc)}) from exc

submit_processor_event_proof

submit_processor_event_proof(challenge_id: str, body: ProcessorPossessionProofIn, request: Request, db: Session = Depends(get_db))

Verify Desktop possession and leave the assignment pending root approval.

Source code in backend/app/api/v1/publish.py
@router.post("/processor-keys/enrolments/{challenge_id}/proof", status_code=202)
@limiter.limit("10/minute")
def submit_processor_event_proof(
    challenge_id: str,
    body: ProcessorPossessionProofIn,
    request: Request,
    db: Session = Depends(get_db),
):
    """Verify Desktop possession and leave the assignment pending root approval."""

    event = _authenticate_event(request, db)
    try:
        validate_processor_event_registration(body.challenge)
        challenge = db.query(EvidenceKeyRegistrationChallenge).filter(
            EvidenceKeyRegistrationChallenge.challenge_id == challenge_id,
            EvidenceKeyRegistrationChallenge.event_evidence_id == event.evidence_id,
            EvidenceKeyRegistrationChallenge.used_at.is_(None),
        ).first()
        rendered = canonical_json(body.challenge)
        if (
            challenge is None
            or challenge.challenge_json.encode("utf-8") != rendered
            or challenge.challenge_sha256 != hashlib.sha256(rendered).hexdigest()
            or _utc(challenge.expires_at) < datetime.now(timezone.utc)
        ):
            raise TrustEvidenceError("the processor enrolment challenge is unavailable or changed")
        if challenge.possession_proof_sha256 is not None:
            return {"status": "pending_root_approval", "challenge_id": challenge.challenge_id, "key_id": challenge.key_id}
        challenge.possession_proof_sha256 = verify_signature(body.challenge, body.proof, challenge.public_key)
        if challenge.purpose == "rotate":
            previous = db.query(EvidenceKey).filter(EvidenceKey.key_id == challenge.supersedes_key_id).first()
            if previous is None:
                raise TrustEvidenceError("the superseded processor key is unavailable")
            if challenge.rotation_reason == "routine" and body.previous_proof is None:
                raise TrustEvidenceError("routine rotation requires proof from the old key")
            if body.previous_proof is not None:
                challenge.previous_proof_sha256 = verify_signature(body.challenge, body.previous_proof, previous.public_key)
        db.commit()
        return {"status": "pending_root_approval", "challenge_id": challenge.challenge_id, "key_id": challenge.key_id, "event_ref": event.evidence_id}
    except (TrustEvidenceError, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail={"code": "PROCESSOR_PROOF_REJECTED", "message": str(exc)}) from exc

current_processor_policy_acknowledgement

current_processor_policy_acknowledgement(request: Request, db: Session = Depends(get_db))

Return the Server-authoritative acknowledgement for this event and policy.

Source code in backend/app/api/v1/publish.py
@router.get("/processor-policy-acknowledgements/current")
@limiter.limit("60/minute")
def current_processor_policy_acknowledgement(
    request: Request,
    db: Session = Depends(get_db),
):
    """Return the Server-authoritative acknowledgement for this event and policy."""

    event = _authenticate_event(request, db)
    current = current_policy_identity(db)
    if current is None:
        return {"acknowledged": False, "policy_version": None, "policy_sha256": None}
    row = db.query(ProcessorPolicyAcknowledgement).filter(
        ProcessorPolicyAcknowledgement.event_evidence_id == event.evidence_id,
        ProcessorPolicyAcknowledgement.policy_version == current[0],
        ProcessorPolicyAcknowledgement.policy_sha256 == current[1],
        ProcessorPolicyAcknowledgement.evidence_package_sha256.isnot(None),
    ).order_by(ProcessorPolicyAcknowledgement.id.desc()).first()
    if row is None:
        return {
            "acknowledged": False,
            "policy_version": current[0],
            "policy_sha256": current[1],
        }
    return {
        "acknowledged": True,
        "policy_version": row.policy_version,
        "policy_sha256": row.policy_sha256,
        "entity_id": row.entity_id,
        "key_id": row.key_id,
        "document_sha256": row.document_sha256,
        "instance_record_sha256": row.instance_record_sha256,
        "evidence_package_sha256": row.evidence_package_sha256,
    }

publish

publish(payload: PublishPayload, request: Request, db: Session = Depends(get_db))

Receive published masterplan data from the desktop app.

Full publish replaces the event's published data. Date-scoped publish replaces only the requested published days.

Source code in backend/app/api/v1/publish.py
@router.post("/publish", response_model=PublishResponse)
@limiter.limit(runtime_limit("masterplan_pushes_per_minute"))
def publish(
    payload: PublishPayload,
    request: Request,
    db: Session = Depends(get_db),
):
    """Receive published masterplan data from the desktop app.

    Full publish replaces the event's published data.
    Date-scoped publish replaces only the requested published days.
    """
    event = _authenticate_event(request, db)
    _require_publishing_allowed(event)
    incoming_schedule_day_range = (
        payload.event.schedule_day_range
        if payload.event and payload.event.schedule_day_range is not None
        else event_schedule_day_range(event.metadata_json)
    )
    schedule_day_range = normalise_schedule_day_range(incoming_schedule_day_range)
    if (
        incoming_schedule_day_range is not None
        and schedule_day_range != incoming_schedule_day_range
    ):
        raise HTTPException(status_code=400, detail="Invalid schedule day range.")
    publish_scope = payload.publish_scope or "full"
    scoped_dates = (
        _normalise_scope_dates(payload.dates)
        if publish_scope == "dates"
        else None
    )

    if scoped_dates is not None:
        for task_in in payload.tasks:
            if _payload_task_day(task_in, schedule_day_range) not in scoped_dates:
                raise HTTPException(
                    status_code=400,
                    detail=f"Task {task_in.id} is outside the requested publish dates.",
                )

    # Update event metadata if provided
    if payload.event:
        if payload.event.name:
            event.name = payload.event.name
        if payload.event.start_date:
            event.start_date = datetime.strptime(payload.event.start_date, "%Y-%m-%d").date()
        if payload.event.end_date:
            new_end_date = datetime.strptime(payload.event.end_date, "%Y-%m-%d").date()
            end_date_changed = event.end_date != new_end_date
            event.end_date = new_end_date
            materialise_event_purge_deadline(
                event,
                db,
                force=end_date_changed,
            )
        if payload.event.day_aliases is not None:
            # Store day_aliases in event metadata_json
            existing_meta = json.loads(event.metadata_json) if event.metadata_json else {}
            existing_meta["day_aliases"] = payload.event.day_aliases
            event.metadata_json = json.dumps(existing_meta)
        if payload.event.schedule_day_range is not None:
            event.metadata_json = merge_schedule_day_range(
                event.metadata_json,
                schedule_day_range,
            )
        event.status = "published"

    # -----------------------------------------------------------------------
    # Capture old state for per-person diff (before delete-and-replace)
    # -----------------------------------------------------------------------
    old_tasks = (
        db.query(PublishedTask)
        .filter(PublishedTask.event_id == event.id)
        .all()
    )
    old_edits_map = {}
    if old_tasks:
        old_task_ids = [t.id for t in old_tasks]
        old_edits = db.query(TaskEdit).filter(TaskEdit.task_id.in_(old_task_ids)).all()
        old_edits_map = {e.task_id: e for e in old_edits}
        # Detach old tasks from session so they survive the delete below
        for t in old_tasks:
            db.expunge(t)
        for e in old_edits:
            db.expunge(e)

    # Delete existing published data + edits for this event/scope.
    existing_tasks_query = (
        db.query(PublishedTask)
        .filter(PublishedTask.event_id == event.id)
    )
    if scoped_dates is None:
        existing_task_ids = [task.id for task in existing_tasks_query.all()]
    else:
        existing_task_ids = [
            task.id
            for task in existing_tasks_query.all()
            if _published_task_day(task, schedule_day_range) in scoped_dates
        ]
    if existing_task_ids:
        edits_cleared = db.query(TaskEdit).filter(
            TaskEdit.task_id.in_(existing_task_ids),
        ).delete(synchronize_session=False)
    else:
        edits_cleared = 0

    if existing_task_ids:
        db.query(PublishedTask).filter(
            PublishedTask.id.in_(existing_task_ids),
        ).delete(synchronize_session=False)

    if scoped_dates is None:
        db.query(PublishedPerson).filter(
            PublishedPerson.event_id == event.id,
        ).delete(synchronize_session=False)

    # Insert persons
    for person_in in payload.persons:
        if scoped_dates is None:
            _insert_person(person_in, event.id, db)
        else:
            _upsert_person(person_in, event.id, db)

    availability_query = db.query(PublishedPersonUnavailability).filter(
        PublishedPersonUnavailability.event_id == event.id,
    )
    if scoped_dates is None:
        availability_query.delete(synchronize_session=False)
    else:
        availability_query.filter(
            PublishedPersonUnavailability.working_date.in_(scoped_dates),
        ).delete(synchronize_session=False)

    valid_person_ids = {person.id for person in payload.persons}
    seen_intervals: set[tuple[int, str, str, str]] = set()
    for interval in payload.unavailabilities:
        try:
            working_date = datetime.strptime(interval.working_date, "%Y-%m-%d").date().isoformat()
            start = datetime.fromisoformat(interval.start)
            end = datetime.fromisoformat(interval.end)
        except ValueError:
            raise HTTPException(status_code=400, detail="Invalid unavailability interval.") from None
        if interval.person_id not in valid_person_ids:
            raise HTTPException(status_code=400, detail="Unavailable person is not part of this event.")
        if scoped_dates is not None and working_date not in scoped_dates:
            raise HTTPException(status_code=400, detail="Unavailability is outside the requested publish dates.")
        if end <= start:
            raise HTTPException(status_code=400, detail="Unavailability end must be after its start.")
        key = (interval.person_id, working_date, start.isoformat(), end.isoformat())
        if key in seen_intervals:
            continue
        seen_intervals.add(key)
        db.add(PublishedPersonUnavailability(
            event_id=event.id,
            external_person_id=interval.person_id,
            working_date=working_date,
            start_datetime=start.isoformat(),
            end_datetime=end.isoformat(),
        ))

    # Insert tasks
    for task_in in payload.tasks:
        _insert_task(task_in, event.id, db)

    db.flush()

    # Auto-link users to persons by matching email
    _auto_link_users_by_email(event.id, db)

    # -----------------------------------------------------------------------
    # Compute per-person diffs and store change records
    # -----------------------------------------------------------------------
    try:
        from app.core.diff import compute_per_person_diffs, store_schedule_changes
        new_tasks = (
            db.query(PublishedTask)
            .filter(PublishedTask.event_id == event.id)
            .all()
        )
        new_task_ids = [task.id for task in new_tasks]
        new_edits_map = {}
        if new_task_ids:
            new_edits = db.query(TaskEdit).filter(TaskEdit.task_id.in_(new_task_ids)).all()
            new_edits_map = {edit.task_id: edit for edit in new_edits}
        diffs = compute_per_person_diffs(
            old_tasks,
            old_edits_map,
            new_tasks,
            new_edits_map,
        )
        store_schedule_changes(event.id, diffs, db)
    except Exception as exc:
        logger.warning("Schedule diff generation failed (%s)", type(exc).__name__)

    # Snapshot the full published state after applying this publish.
    from app.core.snapshots import create_snapshot
    create_snapshot(event, db, source="Publish Secret")

    db.commit()

    audit(db, user=None, action="publish.data", resource_type="event",
          resource_id=event.id, detail=json.dumps({
              "scope": publish_scope,
              "tasks": len(payload.tasks),
          }), request=request)
    db.commit()

    # Send push notification to all subscribers of this event
    try:
        from app.core.push import send_push_to_event
        send_push_to_event(
            event_id=event.id,
            title="Schedule Updated",
            body=f"{event.name} schedule has been republished.",
            url=f"/calendar?event={event.id}",
            db=db,
            notification_type="schedule",
        )
    except Exception as exc:
        logger.warning("Publish push delivery failed (%s)", type(exc).__name__)

    return PublishResponse(
        status="ok",
        tasks_created=len(payload.tasks),
        persons_created=len(payload.persons),
        edits_cleared=edits_cleared,
    )

ping

ping(request: Request, db: Session = Depends(get_db))

Health check for the desktop app. Validates the Bearer token.

Source code in backend/app/api/v1/publish.py
@router.get("/ping", response_model=PingResponse)
@limiter.limit("20/minute")
def ping(
    request: Request,
    db: Session = Depends(get_db),
):
    """Health check for the desktop app. Validates the Bearer token."""
    event = _authenticate_event(request, db)
    return PingResponse(
        status="ok",
        event_name=event.name,
        event_id=event.id,
        event_ref=event.evidence_id,
        supports_scoped_publish=True,
        supports_deletion_work_orders=True,
    )

list_desktop_deletion_work_orders

list_desktop_deletion_work_orders(request: Request, db: Session = Depends(get_db))

List current deletion work orders for the authenticated event.

Source code in backend/app/api/v1/publish.py
@router.get("/deletion-work-orders")
@limiter.limit("20/minute")
def list_desktop_deletion_work_orders(
    request: Request,
    db: Session = Depends(get_db),
):
    """List current deletion work orders for the authenticated event."""

    event = _authenticate_event(request, db)
    work_orders = db.query(DesktopDeletionWorkOrder).filter(
        DesktopDeletionWorkOrder.event_id == event.id,
        DesktopDeletionWorkOrder.state.in_({"open", "claimed", "report_received"}),
    ).order_by(DesktopDeletionWorkOrder.id).all()
    return [_desktop_work_order_detail(work_order) for work_order in work_orders]

claim_desktop_deletion_work_order

claim_desktop_deletion_work_order(work_order_id: str, body: SignedDesktopDocumentIn, request: Request, db: Session = Depends(get_db))

Claim one work order and reveal a short-lived report capability once.

Source code in backend/app/api/v1/publish.py
@router.post("/deletion-work-orders/{work_order_id}/claim")
@limiter.limit("10/minute")
def claim_desktop_deletion_work_order(
    work_order_id: str,
    body: SignedDesktopDocumentIn,
    request: Request,
    db: Session = Depends(get_db),
):
    """Claim one work order and reveal a short-lived report capability once."""

    event = _authenticate_event(request, db)
    work_order = db.query(DesktopDeletionWorkOrder).filter(
        DesktopDeletionWorkOrder.work_order_id == work_order_id,
        DesktopDeletionWorkOrder.event_id == event.id,
    ).first()
    if work_order is None:
        raise HTTPException(status_code=404, detail="Deletion work order not found")
    try:
        document = DesktopWorkOrderClaimIn.model_validate(body.document).model_dump(mode="json")
        identity, key = _processor_key_for_event(
            db, event, entity_id=document["entity_id"], requested_key_id=document["key_id"],
        )
        if document["work_order_id"] != work_order.work_order_id or work_order.processor_entity_id != identity.entity_id:
            raise TrustEvidenceError("the work-order claim belongs to another processor assignment")
        validate_desktop_evidence_document(
            document, instance_id=key.instance_id, event_ref=event.evidence_id,
            entity_id=identity.entity_id, row_key_id=key.key_id, fingerprint=key.public_key_sha256,
        )
        requested_at = datetime.fromisoformat(document["requested_at"].replace("Z", "+00:00"))
        if requested_at.tzinfo is None or abs((datetime.now(timezone.utc) - requested_at.astimezone(timezone.utc)).total_seconds()) > 300:
            raise TrustEvidenceError("the work-order claim time is outside the allowed window")
        verify_signature(document, body.proof, key.public_key, namespace=DESKTOP_EVIDENCE_NAMESPACE)
        capability = claim_work_order(work_order)
    except (TrustEvidenceError, ValueError) as exc:
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    db.commit()
    return {
        **_desktop_work_order_detail(work_order),
        "claim_capability": capability,
    }

report_desktop_deletion_work_order

report_desktop_deletion_work_order(work_order_id: str, body: SignedDesktopDocumentIn, request: Request, db: Session = Depends(get_db))

Record an idempotent deletion report from the authenticated desktop.

Source code in backend/app/api/v1/publish.py
@router.post("/deletion-work-orders/{work_order_id}/report")
@limiter.limit("20/minute")
def report_desktop_deletion_work_order(
    work_order_id: str,
    body: SignedDesktopDocumentIn,
    request: Request,
    db: Session = Depends(get_db),
):
    """Record an idempotent deletion report from the authenticated desktop."""

    event = _authenticate_event(request, db)
    work_order = db.query(DesktopDeletionWorkOrder).filter(
        DesktopDeletionWorkOrder.work_order_id == work_order_id,
        DesktopDeletionWorkOrder.event_id == event.id,
    ).first()
    if work_order is None:
        raise HTTPException(status_code=404, detail="Deletion work order not found")
    case = db.query(DeletionCase).filter(
        DeletionCase.id == work_order.case_id,
    ).first()
    if case is None:
        raise HTTPException(status_code=409, detail="Deletion case no longer exists")
    capability = request.headers.get("x-deletion-claim", "")
    try:
        document = DesktopDeletionReportIn.model_validate(body.document).model_dump(mode="json")
        if document["work_order_id"] != work_order.work_order_id:
            raise TrustEvidenceError("the Desktop deletion receipt targets another work order")
        identity, key = _processor_key_for_event(
            db, event, entity_id=document["entity_id"], requested_key_id=document["key_id"],
        )
        if work_order.processor_entity_id != identity.entity_id:
            raise TrustEvidenceError("the Desktop deletion receipt belongs to another processor assignment")
        validate_desktop_evidence_document(
            document, instance_id=key.instance_id, event_ref=event.evidence_id,
            entity_id=identity.entity_id, row_key_id=key.key_id, fingerprint=key.public_key_sha256,
        )
        package_json, package_digest, _, signature_digest = (
            signed_desktop_evidence_package(document, body.proof, key.public_key)
        )
        digest = apply_desktop_report(
            db,
            case,
            work_order,
            claim_capability=capability,
            report=document,
            signature_sha256=signature_digest,
            evidence_package_json=package_json,
            evidence_package_sha256=package_digest,
            completed_key_id=key.key_id,
            completed_public_key_sha256=key.public_key_sha256,
        )
    except (TrustEvidenceError, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc
    db.commit()
    return {
        "status": "recorded",
        "work_order_id": work_order.work_order_id,
        "report_sha256": digest,
        "case_state": case.state,
    }

report_desktop_copy_resolution

report_desktop_copy_resolution(work_order_id: str, body: SignedDesktopDocumentIn, request: Request, db: Session = Depends(get_db))

Verify the processor's Desktop-local backup/export disposition.

Source code in backend/app/api/v1/publish.py
@router.post("/deletion-work-orders/{work_order_id}/copy-resolution")
@limiter.limit("20/minute")
def report_desktop_copy_resolution(
    work_order_id: str,
    body: SignedDesktopDocumentIn,
    request: Request,
    db: Session = Depends(get_db),
):
    """Verify the processor's Desktop-local backup/export disposition."""

    event = _authenticate_event(request, db)
    work_order = db.query(DesktopDeletionWorkOrder).filter(
        DesktopDeletionWorkOrder.work_order_id == work_order_id,
        DesktopDeletionWorkOrder.event_id == event.id,
    ).first()
    if work_order is None:
        raise HTTPException(status_code=404, detail="Deletion work order not found")
    case = db.query(DeletionCase).filter(DeletionCase.id == work_order.case_id).first()
    if case is None:
        raise HTTPException(status_code=409, detail="Deletion case no longer exists")
    try:
        document = DesktopCopyResolutionIn.model_validate(body.document).model_dump(mode="json")
        if document["work_order_id"] != work_order.work_order_id:
            raise TrustEvidenceError("the local-copy resolution targets another work order")
        identity, key = _processor_key_for_event(
            db, event, entity_id=document["entity_id"], requested_key_id=document["key_id"],
        )
        if work_order.processor_entity_id != identity.entity_id:
            raise TrustEvidenceError("the local-copy resolution belongs to another processor assignment")
        validate_desktop_evidence_document(
            document, instance_id=key.instance_id, event_ref=event.evidence_id,
            entity_id=identity.entity_id, row_key_id=key.key_id, fingerprint=key.public_key_sha256,
        )
        package_json, package_digest, _, signature_digest = (
            signed_desktop_evidence_package(document, body.proof, key.public_key)
        )
        digest = apply_desktop_copy_resolution(
            db, case, work_order, document=document,
            signature_sha256=signature_digest, completed_key_id=key.key_id,
            completed_public_key_sha256=key.public_key_sha256,
            evidence_package_json=package_json,
            evidence_package_sha256=package_digest,
        )
        db.commit()
        return {"status": "recorded", "work_order_id": work_order.work_order_id, "copy_resolution_sha256": digest, "case_state": case.state}
    except (TrustEvidenceError, ValueError) as exc:
        db.rollback()
        raise HTTPException(status_code=409, detail=str(exc)) from exc