652 lines
18 KiB
Python
652 lines
18 KiB
Python
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)
|