Add institutional provenance and approval gates

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent cec3d17bff
commit 2afdd38128
16 changed files with 2472 additions and 509 deletions
+42 -16
View File
@@ -3,7 +3,15 @@ from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from pydantic import (
BaseModel,
BeforeValidator,
ConfigDict,
Field,
ValidationInfo,
field_validator,
model_validator,
)
from govoplan_core.api.v1.schemas import DeltaDeletedItem
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
@@ -25,8 +33,6 @@ class CampaignCreateRequest(BaseModel):
source_base_path: str | None = None
class CampaignUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -64,7 +70,9 @@ class CampaignVersionUpdateRequest(BaseModel):
@field_validator("editor_state")
@classmethod
def validate_editor_state(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
def validate_editor_state(
cls, value: dict[str, Any] | None
) -> dict[str, Any] | None:
return validate_campaign_editor_state(value) if value is not None else None
@@ -129,12 +137,16 @@ class CampaignVersionResponse(BaseModel):
build_summary: dict[str, Any] | None = None
execution_snapshot_hash: str | None = None
execution_snapshot_at: datetime | None = None
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = None
delivery_mode: Literal["synchronous", "worker_queue", "database_queue"] | None = (
None
)
delivery_mode_selected_at: datetime | None = None
@field_validator("editor_state", mode="before")
@classmethod
def remove_unsupported_editor_state(cls, value: Any, info: ValidationInfo) -> dict[str, Any]:
def remove_unsupported_editor_state(
cls, value: Any, info: ValidationInfo
) -> dict[str, Any]:
return public_campaign_editor_state(
value,
include_diagnostics=bool((info.context or {}).get("include_diagnostics")),
@@ -291,7 +303,9 @@ class RecipientImportColumnMappingPayload(BaseModel):
column_index: int = Field(ge=0, alias="columnIndex")
kind: RecipientImportColumnKind
field_name: str | None = Field(default=None, max_length=255, alias="fieldName")
new_field_name: str | None = Field(default=None, max_length=255, alias="newFieldName")
new_field_name: str | None = Field(
default=None, max_length=255, alias="newFieldName"
)
class RecipientImportMappingProfilePayload(BaseModel):
@@ -300,14 +314,22 @@ class RecipientImportMappingProfilePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
column_count: int = Field(ge=0, le=500, alias="columnCount")
headers: list[str] = Field(default_factory=list, max_length=500)
normalized_headers: list[str] = Field(default_factory=list, max_length=500, alias="normalizedHeaders")
ordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="orderedHeaderFingerprint")
unordered_header_fingerprint: str = Field(min_length=1, max_length=64, alias="unorderedHeaderFingerprint")
normalized_headers: list[str] = Field(
default_factory=list, max_length=500, alias="normalizedHeaders"
)
ordered_header_fingerprint: str = Field(
min_length=1, max_length=64, alias="orderedHeaderFingerprint"
)
unordered_header_fingerprint: str = Field(
min_length=1, max_length=64, alias="unorderedHeaderFingerprint"
)
delimiter: Literal[",", ";", "\t"]
header_rows: int = Field(ge=0, le=10, alias="headerRows")
quoted: bool = True
value_separators: str = Field(default=",;|", max_length=50, alias="valueSeparators")
mappings: list[RecipientImportColumnMappingPayload] = Field(default_factory=list, max_length=500)
mappings: list[RecipientImportColumnMappingPayload] = Field(
default_factory=list, max_length=500
)
@model_validator(mode="after")
def validate_column_shape(self) -> "RecipientImportMappingProfilePayload":
@@ -583,6 +605,7 @@ class CampaignDeliveryOptionsResponse(BaseModel):
version_id: str
worker_queue_available: bool
postbox_available: bool = False
approval_gate: dict[str, Any] = Field(default_factory=dict)
synchronous_send: dict[str, Any] = Field(default_factory=dict)
@@ -633,14 +656,17 @@ def _normalize_report_recipient(value: Any) -> str:
if len(recipient) > 320:
raise ValueError("report recipient addresses must be at most 320 characters")
if any(ord(character) < 32 or ord(character) == 127 for character in recipient):
raise ValueError("report recipient addresses must not contain control characters")
raise ValueError(
"report recipient addresses must not contain control characters"
)
if recipient.count("@") != 1:
raise ValueError("report recipients must be email addresses")
local, domain = recipient.split("@", 1)
invalid_local = not local or local.startswith(".") or local.endswith(".") or ".." in local
invalid_address = (
any(character.isspace() for character in recipient)
or any(character in ',;:<>[]()\\"' for character in recipient)
invalid_local = (
not local or local.startswith(".") or local.endswith(".") or ".." in local
)
invalid_address = any(character.isspace() for character in recipient) or any(
character in ',;:<>[]()\\"' for character in recipient
)
if invalid_local or invalid_address or not _valid_report_email_domain(domain):
raise ValueError("report recipients must be email addresses")