from __future__ import annotations from datetime import datetime from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_core.mail.config import ImapConfig, SmtpConfig 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 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 source_base_path: 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 class CampaignVersionDetailResponse(CampaignVersionResponse): raw_json: dict[str, Any] 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 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 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 CampaignResolveOutcomeRequest(BaseModel): model_config = ConfigDict(extra="forbid") decision: Literal["smtp_accepted", "not_sent"] note: str | None = None class ValidateCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") check_files: bool = False class BuildCampaignRequest(BaseModel): model_config = ConfigDict(extra="forbid") write_eml: bool = True class MailSmtpTestRequest(SmtpConfig): """SMTP settings supplied directly from the WebUI mail settings form.""" class MailImapTestRequest(ImapConfig): """IMAP settings supplied directly from the WebUI mail settings form.""" MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"] class MailCredentialPolicyPayload(BaseModel): model_config = ConfigDict(extra="forbid") inherit: bool | None = None allow_override: bool | None = None class MailProfilePolicyPayload(BaseModel): model_config = ConfigDict(extra="forbid") allowed_profile_ids: list[str] = Field(default_factory=list) allow_user_profiles: bool | None = None allow_group_profiles: bool | None = None allow_campaign_profiles: bool | None = None smtp_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload) whitelist: dict[str, list[str]] = Field(default_factory=dict) blacklist: dict[str, list[str]] = Field(default_factory=dict) class MailProfilePolicyUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") policy: MailProfilePolicyPayload = Field(default_factory=MailProfilePolicyPayload) class MailProfilePolicyResponse(BaseModel): scope_type: MailProfileScope scope_id: str | None = None policy: dict[str, Any] effective_policy: dict[str, Any] | None = None class MailServerProfileCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=255) slug: str | None = Field(default=None, max_length=100) description: str | None = None is_active: bool = True scope_type: MailProfileScope = "tenant" scope_id: str | None = None smtp: SmtpConfig imap: ImapConfig | None = None class MailServerProfileUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str | None = Field(default=None, max_length=255) slug: str | None = Field(default=None, max_length=100) description: str | None = None is_active: bool | None = None smtp: SmtpConfig | None = None imap: ImapConfig | None = None clear_imap: bool = False class MailServerProfileResponse(BaseModel): id: str tenant_id: str | None = None scope_type: MailProfileScope = "tenant" scope_id: str | None = None name: str slug: str description: str | None = None is_active: bool smtp: dict[str, Any] imap: dict[str, Any] | None = None smtp_password_configured: bool = False imap_password_configured: bool = False created_at: datetime updated_at: datetime class MailServerProfileListResponse(BaseModel): profiles: list[MailServerProfileResponse] = Field(default_factory=list) class MailConnectionTestResponse(BaseModel): ok: bool protocol: Literal["smtp", "imap"] host: str | None = None port: int | None = None security: str | None = None message: str details: dict[str, Any] = Field(default_factory=dict) class MailImapFolderResponse(BaseModel): name: str flags: list[str] = Field(default_factory=list) class MailImapFolderListResponse(BaseModel): ok: bool protocol: Literal["imap"] = "imap" host: str | None = None port: int | None = None security: str | None = None message: str folders: list[MailImapFolderResponse] = Field(default_factory=list) detected_sent_folder: str | None = None details: dict[str, Any] = Field(default_factory=dict) 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 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 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] version_id: str | None = None include_jobs: bool = False attach_jobs_csv: bool = True attach_report_json: bool = False dry_run: bool = False class ReportEmailResponse(BaseModel): result: dict[str, Any] class AuditLogItemResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: str tenant_id: str | None = None user_id: str | None = None api_key_id: str | None = None action: str object_type: str | None = None object_id: str | None = None details: dict[str, Any] | None = None created_at: datetime class AuditLogListResponse(BaseModel): items: list[AuditLogItemResponse] class LoginRequest(BaseModel): model_config = ConfigDict(extra="forbid") email: str password: str # Kept optional for backwards compatibility and future tenant-switch login flows. # The WebUI no longer sends it. If omitted, the backend resolves the user by email. tenant_slug: str | None = None class SwitchTenantRequest(BaseModel): model_config = ConfigDict(extra="forbid") tenant_id: str class TenantInfo(BaseModel): id: str slug: str name: str is_active: bool = True default_locale: str = "en" class TenantMembershipInfo(TenantInfo): roles: list[str] = Field(default_factory=list) is_active: bool = True class UserInfo(BaseModel): id: str account_id: str email: str # Global account identity used by the title bar and account settings. display_name: str | None = None # Optional tenant-local alias used in tenant administration and ownership. tenant_display_name: str | None = None is_tenant_admin: bool = False password_reset_required: bool = False class ProfileUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") display_name: str | None = Field(default=None, max_length=255) tenant_display_name: str | None = Field(default=None, max_length=255) class RoleInfo(BaseModel): id: str slug: str name: str permissions: list[str] = Field(default_factory=list) level: Literal["tenant", "system"] = "tenant" class GroupInfo(BaseModel): id: str slug: str name: str class LoginResponse(BaseModel): access_token: str token_type: str = "bearer" expires_at: datetime user: UserInfo # Backwards-compatible alias for the active tenant. tenant: TenantInfo active_tenant: TenantInfo tenants: list[TenantMembershipInfo] = Field(default_factory=list) scopes: list[str] roles: list[RoleInfo] = Field(default_factory=list) groups: list[GroupInfo] = Field(default_factory=list) class MeResponse(BaseModel): user: UserInfo # Backwards-compatible alias for the active tenant. tenant: TenantInfo active_tenant: TenantInfo tenants: list[TenantMembershipInfo] = Field(default_factory=list) scopes: list[str] roles: list[RoleInfo] = Field(default_factory=list) groups: list[GroupInfo] = Field(default_factory=list)