from __future__ import annotations from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_campaign.backend.campaign.mail_profile_boundary import ( public_campaign_editor_state, validate_campaign_editor_state, ) from govoplan_campaign.backend.response_security import ( public_campaign_configuration, public_campaign_payload, public_source_filename, ) class CampaignCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") config: dict[str, Any] source_filename: str | None = None source_base_path: str | None = None class CampaignUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") external_id: str | None = None name: str | None = None status: str | None = None description: str | None = None class CampaignCreateMinimalRequest(BaseModel): model_config = ConfigDict(extra="forbid") external_id: str name: str description: str | None = None current_flow: str = "create" current_step: str = "basics" class CampaignVersionUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") campaign_json: dict[str, Any] | None = None current_flow: str | None = None current_step: str | None = None workflow_state: str | None = None is_complete: bool | None = None editor_state: dict[str, Any] | None = None source_filename: str | None = None source_base_path: str | None = None migrate_legacy_mail_settings: bool = False @field_validator("editor_state") @classmethod 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 class CampaignVersionSetStepRequest(BaseModel): model_config = ConfigDict(extra="forbid") current_flow: str | None = None current_step: str class CampaignReviewStateRequest(BaseModel): model_config = ConfigDict(extra="forbid") inspection_complete: bool = False reviewed_message_keys: list[str] = Field(default_factory=list) class CampaignPartialValidationRequest(BaseModel): model_config = ConfigDict(extra="forbid") campaign_json: dict[str, Any] | None = None section: str | None = None class CampaignVersionResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str campaign_id: str version_number: int schema_version: str source_filename: str | None = None workflow_state: str = "editing" current_flow: str = "manual" current_step: str | None = None is_complete: bool = False editor_state: dict[str, Any] = Field(default_factory=dict) autosaved_at: datetime | None = None published_at: datetime | None = None locked_at: datetime | None = None locked_by_user_id: str | None = None user_lock_state: Literal["temporary", "permanent"] | None = None user_locked_at: datetime | None = None user_locked_by_user_id: str | None = None created_at: datetime updated_at: datetime validation_summary: dict[str, Any] | None = None 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_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]: return public_campaign_editor_state( value, include_diagnostics=bool((info.context or {}).get("include_diagnostics")), ) @field_validator("source_filename", mode="before") @classmethod def remove_source_directory(cls, value: Any) -> str | None: return public_source_filename(value) @field_validator("validation_summary", "build_summary", mode="before") @classmethod def remove_internal_summary_fields(cls, value: Any, info: ValidationInfo) -> Any: return public_campaign_payload( value, include_diagnostics=bool((info.context or {}).get("include_diagnostics")), ) class CampaignVersionDetailResponse(CampaignVersionResponse): raw_json: dict[str, Any] mail_profile_migration_required: bool = False @field_validator("raw_json", mode="before") @classmethod def remove_internal_configuration_fields(cls, value: Any) -> Any: return public_campaign_configuration(value) class CampaignPartialValidationResponse(BaseModel): ok: bool section: str | None = None error_count: int warning_count: int info_count: int issues: list[dict[str, Any]] class CampaignResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str external_id: str name: str description: str | None = None status: str current_version_id: str | None = None owner_user_id: str | None = None owner_group_id: str | None = None created_at: datetime updated_at: datetime class CampaignCreateResponse(BaseModel): campaign: CampaignResponse version: CampaignVersionResponse class CampaignListResponse(BaseModel): campaigns: list[CampaignResponse] class CampaignWorkspaceResponse(BaseModel): campaign: CampaignResponse | None = None versions: list[CampaignVersionResponse] = Field(default_factory=list) current_version: CampaignVersionDetailResponse | None = None summary: dict[str, Any] | None = None selected_version_id: str | None = None class CampaignDeltaResponse(BaseModel): campaigns: list[CampaignResponse] = Field(default_factory=list) deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None has_more: bool = False full: bool = False class CampaignWorkspaceDeltaResponse(CampaignWorkspaceResponse): deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None has_more: bool = False full: bool = False class CampaignShareItem(BaseModel): model_config = ConfigDict(from_attributes=True) id: str campaign_id: str target_type: Literal["user", "group"] target_id: str permission: Literal["read", "write"] = "read" revoked_at: datetime | None = None class CampaignShareListResponse(BaseModel): shares: list[CampaignShareItem] class CampaignShareTargetItem(BaseModel): id: str name: str secondary: str | None = None class CampaignShareTargetsResponse(BaseModel): users: list[CampaignShareTargetItem] = Field(default_factory=list) groups: list[CampaignShareTargetItem] = Field(default_factory=list) class CampaignShareUpsertRequest(BaseModel): model_config = ConfigDict(extra="forbid") target_type: Literal["user", "group"] target_id: str permission: Literal["read", "write"] = "read" class CampaignOwnerUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") owner_user_id: str | None = None owner_group_id: str | None = None RecipientImportColumnKind = Literal[ "ignore", "id", "active", "name", "from", "to", "cc", "bcc", "reply_to", "field", "new_field", "attachment_pattern", ] class RecipientImportColumnMappingPayload(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) 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") class RecipientImportMappingProfilePayload(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) 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") 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) @model_validator(mode="after") def validate_column_shape(self) -> "RecipientImportMappingProfilePayload": if len(self.headers) != self.column_count: raise ValueError("headers length must match columnCount") if len(self.normalized_headers) != self.column_count: raise ValueError("normalizedHeaders length must match columnCount") for mapping in self.mappings: if mapping.column_index >= self.column_count: raise ValueError("mapping columnIndex exceeds columnCount") return self class RecipientImportMappingProfileResponse(RecipientImportMappingProfilePayload): model_config = ConfigDict(from_attributes=True, populate_by_name=True) id: str created_at: datetime = Field(alias="createdAt") updated_at: datetime = Field(alias="updatedAt") class RecipientImportMappingProfileListResponse(BaseModel): profiles: list[RecipientImportMappingProfileResponse] = Field(default_factory=list) class CampaignAddressLookupCandidate(BaseModel): contact_id: str address_book_id: str display_name: str email: str | None = None email_label: str | None = None organization: str | None = None role_title: str | None = None tags: list[str] = Field(default_factory=list) source_kind: str = "local" source_ref: str | None = None source_revision: str | None = None provenance: dict[str, Any] = Field(default_factory=dict) class CampaignAddressLookupResponse(BaseModel): available: bool = False candidates: list[CampaignAddressLookupCandidate] = Field(default_factory=list) class CampaignRecipientAddressSource(BaseModel): source_id: str source_label: str source_kind: str source_revision: str recipient_count: int = 0 provenance: dict[str, Any] = Field(default_factory=dict) class CampaignRecipientAddressSourcesResponse(BaseModel): available: bool = False sources: list[CampaignRecipientAddressSource] = Field(default_factory=list) class CampaignRecipientAddressSourceSnapshotRequest(BaseModel): model_config = ConfigDict(extra="forbid") source_id: str = Field(min_length=1) class CampaignRecipientSnapshotItem(BaseModel): contact_id: str display_name: str email: str email_label: str | None = None fields: dict[str, Any] = Field(default_factory=dict) provenance: dict[str, Any] = Field(default_factory=dict) class CampaignRecipientAddressSourceSnapshotResponse(BaseModel): source_id: str source_label: str source_kind: str source_revision: str generated_at: str recipients: list[CampaignRecipientSnapshotItem] = Field(default_factory=list) provenance: dict[str, Any] = Field(default_factory=dict) class CampaignJobsResponse(BaseModel): jobs: list[dict[str, Any]] page: int = 1 page_size: int = 50 total: int = 0 total_unfiltered: int = 0 pages: int = 0 cursor: str | None = None next_cursor: str | None = None counts: dict[str, dict[str, int]] = Field(default_factory=dict) filtered_counts: dict[str, dict[str, int]] = Field(default_factory=dict) review: dict[str, Any] = Field(default_factory=dict) class CampaignJobsDeltaResponse(CampaignJobsResponse): deleted: list[DeltaDeletedItem] = Field(default_factory=list) watermark: str | None = None has_more: bool = False full: bool = False class CampaignJobDetailResponse(BaseModel): job: dict[str, Any] attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) class CampaignJobDiagnosticsResponse(BaseModel): job_id: str campaign_id: str campaign_version_id: str storage: dict[str, Any] = Field(default_factory=dict) worker_claim: dict[str, Any] = Field(default_factory=dict) attempts: dict[str, list[dict[str, Any]]] = Field(default_factory=dict) class CampaignRetryJobsRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None job_ids: list[str] = Field(default_factory=list) include_permanent: bool = False force_max_attempts: bool = False enqueue_celery: bool = True dry_run: bool = False class CampaignSendUnattemptedRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None job_ids: list[str] = Field(default_factory=list) enqueue_celery: bool = True dry_run: bool = False class CampaignSendJobRequest(BaseModel): model_config = ConfigDict(extra="forbid") include_warnings: bool = True dry_run: bool = False use_rate_limit: bool = True enqueue_imap_task: bool = False class CampaignResolveOutcomeRequest(BaseModel): model_config = ConfigDict(extra="forbid") decision: Literal[ "smtp_accepted", "not_sent", "imap_appended", "imap_not_appended", ] note: str | None = Field(default=None, max_length=2000) @model_validator(mode="after") def require_reconciliation_evidence(self) -> "CampaignResolveOutcomeRequest": self.note = (self.note or "").strip() if not self.note: raise ValueError("Reconciliation requires an evidence note") return self class ValidateCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") check_files: bool = False link_unshared_matches: bool = False class BuildCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") write_eml: bool = True class ApiKeyCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str scopes: list[str] = Field(default_factory=list) class ApiKeyCreateResponse(BaseModel): id: str name: str prefix: str scopes: list[str] secret: str class QueueCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None include_warnings: bool = True enqueue_celery: bool = True dry_run: bool = False class QueueCampaignResponse(BaseModel): campaign_id: str version_id: str queued_count: int skipped_count: int blocked_count: int enqueued_count: int delivery_mode: str = "worker_queue" worker_queue_available: bool = False dry_run: bool = False class SendCampaignNowRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None include_warnings: bool = True check_files: bool = False validate_before_send: bool = False build_before_send: bool = False dry_run: bool = False use_rate_limit: bool = True enqueue_imap_task: bool = False class SendCampaignNowResponse(BaseModel): result: dict[str, Any] class CampaignDeliveryOptionsResponse(BaseModel): campaign_id: str version_id: str worker_queue_available: bool synchronous_send: dict[str, Any] = Field(default_factory=dict) class MockCampaignSendRequest(BaseModel): model_config = ConfigDict(extra="forbid") version_id: str | None = None send: bool = False include_warnings: bool = True include_needs_review: bool = False append_sent: bool = True clear_mailbox: bool = False check_files: bool = False class MockCampaignSendResponse(BaseModel): result: dict[str, Any] class AppendSentRequest(BaseModel): model_config = ConfigDict(extra="forbid") enqueue_celery: bool = True run_inline: bool = False dry_run: bool = False class CampaignActionResponse(BaseModel): result: dict[str, Any] class ReportEmailRequest(BaseModel): model_config = ConfigDict(extra="forbid") to: list[str] = Field(min_length=1, max_length=50) version_id: str | None = None include_jobs: bool = False attach_jobs_csv: bool = False attach_report_json: bool = False dry_run: bool = False @field_validator("to", mode="before") @classmethod def normalize_and_validate_recipients(cls, value: Any) -> Any: if not isinstance(value, list): return value if not 1 <= len(value) <= 50: raise ValueError("report email requires between 1 and 50 recipients") recipients: list[str] = [] seen: set[str] = set() for item in value: if not isinstance(item, str): raise ValueError("report recipients must be email-address strings") recipient = item.strip() 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") if recipient.count("@") != 1: raise ValueError("report recipients must be email addresses") local, domain = recipient.split("@", 1) if ( not local or not domain or any(character.isspace() for character in recipient) or any(character in ',;:<>[]()\\"' for character in recipient) or local.startswith(".") or local.endswith(".") or ".." in local or domain.startswith(".") or domain.endswith(".") or ".." in domain or any( not label or label.startswith("-") or label.endswith("-") or not all(character.isalnum() or character == "-" for character in label) for label in domain.split(".") ) ): raise ValueError("report recipients must be email addresses") key = recipient.casefold() if key in seen: continue seen.add(key) recipients.append(recipient) return recipients class ReportEmailResponse(BaseModel): result: dict[str, Any]