from __future__ import annotations import uuid from datetime import datetime from enum import StrEnum from typing import Any from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from govoplan_core.db.base import Base, TimestampMixin def new_uuid() -> str: return str(uuid.uuid4()) class CampaignStatus(StrEnum): DRAFT = "draft" VALIDATED = "validated" NEEDS_REVIEW = "needs_review" READY_TO_QUEUE = "ready_to_queue" QUEUED = "queued" SENDING = "sending" SENT = "sent" PARTIALLY_COMPLETED = "partially_completed" OUTCOME_UNKNOWN = "outcome_unknown" FAILED = "failed" CANCELLED = "cancelled" ARCHIVED = "archived" class CampaignVersionWorkflowState(StrEnum): EDITING = "editing" UNDER_REVIEW = "under_review" APPROVED = "approved" BUILT = "built" QUEUED = "queued" SENDING = "sending" COMPLETED = "completed" PARTIALLY_COMPLETED = "partially_completed" OUTCOME_UNKNOWN = "outcome_unknown" FAILED = "failed" CANCELLED = "cancelled" ARCHIVED = "archived" class CampaignVersionFlow(StrEnum): CREATE = "create" REVIEW = "review" SEND = "send" MANUAL = "manual" JSON = "json" class JobBuildStatus(StrEnum): PENDING = "pending" BUILT = "built" BUILD_FAILED = "build_failed" class JobValidationStatus(StrEnum): READY = "ready" WARNING = "warning" NEEDS_REVIEW = "needs_review" BLOCKED = "blocked" EXCLUDED = "excluded" INACTIVE = "inactive" class JobQueueStatus(StrEnum): DRAFT = "draft" QUEUED = "queued" SENDING = "sending" PAUSED = "paused" CANCELLED = "cancelled" class JobSendStatus(StrEnum): NOT_QUEUED = "not_queued" QUEUED = "queued" CLAIMED = "claimed" SENDING = "sending" SMTP_ACCEPTED = "smtp_accepted" SENT = "sent" # legacy value retained for existing databases/reports OUTCOME_UNKNOWN = "outcome_unknown" FAILED_TEMPORARY = "failed_temporary" FAILED_PERMANENT = "failed_permanent" CANCELLED = "cancelled" class JobImapStatus(StrEnum): NOT_REQUESTED = "not_requested" PENDING = "pending" APPENDED = "appended" FAILED = "failed" SKIPPED = "skipped" class IssueSeverity(StrEnum): INFO = "info" WARNING = "warning" ERROR = "error" class Campaign(Base, TimestampMixin): __tablename__ = "campaigns" __table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) owner_group_id: Mapped[str | None] = mapped_column(ForeignKey("access_groups.id", ondelete="SET NULL"), nullable=True, index=True) external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column(String(50), default=CampaignStatus.DRAFT.value, nullable=False, index=True) current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True) settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) mail_profile_policy: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) tenant: Mapped["Tenant"] = relationship("Tenant") versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan") jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan") class CampaignShare(Base, TimestampMixin): __tablename__ = "campaign_shares" __table_args__ = (UniqueConstraint("campaign_id", "target_type", "target_id", name="uq_campaign_share_target"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) target_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) target_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) permission: Mapped[str] = mapped_column(String(20), default="read", nullable=False) created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) class RecipientImportMappingProfile(Base, TimestampMixin): __tablename__ = "campaign_recipient_import_mapping_profiles" __table_args__ = ( Index("ix_recipient_import_profiles_owner", "tenant_id", "owner_user_id"), Index("ix_recipient_import_profiles_ordered_fp", "tenant_id", "owner_user_id", "ordered_header_fingerprint"), Index("ix_recipient_import_profiles_unordered_fp", "tenant_id", "owner_user_id", "unordered_header_fingerprint"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) owner_user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True) name: Mapped[str] = mapped_column(String(255), nullable=False) column_count: Mapped[int] = mapped_column(Integer, nullable=False) headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) normalized_headers: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False) ordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True) unordered_header_fingerprint: Mapped[str] = mapped_column(String(64), nullable=False, index=True) delimiter: Mapped[str] = mapped_column(String(8), nullable=False) header_rows: Mapped[int] = mapped_column(Integer, default=1, nullable=False) quoted: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) value_separators: Mapped[str] = mapped_column(String(50), default=",;|", nullable=False) mappings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False) tenant: Mapped["Tenant"] = relationship("Tenant") owner: Mapped["User"] = relationship("User") class CampaignVersion(Base, TimestampMixin): __tablename__ = "campaign_versions" __table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) version_number: Mapped[int] = mapped_column(Integer, nullable=False) raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False) source_filename: Mapped[str | None] = mapped_column(String(500)) source_base_path: Mapped[str | None] = mapped_column(String(1000)) # Editor/workflow metadata used by the WebUI and future desktop clients. # A campaign version can be the autosaved working copy of a new or existing # campaign, so no separate CampaignDraft entity is needed. workflow_state: Mapped[str] = mapped_column( String(50), default=CampaignVersionWorkflowState.EDITING.value, nullable=False, index=True, ) current_flow: Mapped[str] = mapped_column( String(50), default=CampaignVersionFlow.MANUAL.value, nullable=False, index=True, ) current_step: Mapped[str | None] = mapped_column(String(100)) is_complete: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) editor_state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) # Explicit user-requested lock. This is deliberately separate from # locked_at, which represents the reversible validation lock used by the # build/send workflow. Temporary user locks may later receive a dedicated # RBAC permission for unlocking; permanent locks never unlock in place. user_lock_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) user_locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) user_locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) execution_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) execution_snapshot_hash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) execution_snapshot_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) campaign: Mapped[Campaign] = relationship(back_populates="versions") class CampaignJob(Base, TimestampMixin): __tablename__ = "campaign_jobs" __table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) entry_index: Mapped[int] = mapped_column(Integer, nullable=False) entry_id: Mapped[str | None] = mapped_column(String(255), index=True) recipient_email: Mapped[str | None] = mapped_column(String(320), index=True) subject: Mapped[str | None] = mapped_column(String(998)) message_id_header: Mapped[str | None] = mapped_column(String(255)) eml_storage_key: Mapped[str | None] = mapped_column(String(1000)) eml_local_path: Mapped[str | None] = mapped_column(String(1000)) eml_size_bytes: Mapped[int | None] = mapped_column(Integer) eml_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True) validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True) queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True) send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True) imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True) attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) last_error: Mapped[str | None] = mapped_column(Text) queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) smtp_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) outcome_unknown_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) campaign: Mapped[Campaign] = relationship(back_populates="jobs") class CampaignIssue(Base, TimestampMixin): __tablename__ = "campaign_issues" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True) job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True) severity: Mapped[str] = mapped_column(String(20), nullable=False, index=True) code: Mapped[str] = mapped_column(String(100), nullable=False, index=True) message: Mapped[str] = mapped_column(Text, nullable=False) source: Mapped[str | None] = mapped_column(String(255)) behavior: Mapped[str | None] = mapped_column(String(50)) class AttachmentBlob(Base, TimestampMixin): __tablename__ = "attachment_blobs" __table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) mime_type: Mapped[str | None] = mapped_column(String(255)) storage_bucket: Mapped[str] = mapped_column(String(255), nullable=False) storage_key: Mapped[str] = mapped_column(String(1000), nullable=False) class AttachmentInstance(Base, TimestampMixin): __tablename__ = "attachment_instances" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True) owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True) blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True) logical_name: Mapped[str | None] = mapped_column(String(500)) filename: Mapped[str] = mapped_column(String(500), nullable=False) tags: Mapped[list[str]] = mapped_column(JSON, default=list) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) class SendAttempt(Base, TimestampMixin): __tablename__ = "send_attempts" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True) attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) status: Mapped[str] = mapped_column(String(50), default="started", nullable=False, index=True) claim_token: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) smtp_status_code: Mapped[int | None] = mapped_column(Integer) smtp_response: Mapped[str | None] = mapped_column(Text) error_type: Mapped[str | None] = mapped_column(String(255)) error_message: Mapped[str | None] = mapped_column(Text) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class ImapAppendAttempt(Base, TimestampMixin): __tablename__ = "imap_append_attempts" id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True) attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) folder: Mapped[str | None] = mapped_column(String(500)) status: Mapped[str] = mapped_column(String(50), nullable=False) error_message: Mapped[str | None] = mapped_column(Text) __all__ = [ "AttachmentBlob", "AttachmentInstance", "Campaign", "CampaignIssue", "CampaignJob", "CampaignShare", "CampaignStatus", "CampaignVersion", "CampaignVersionFlow", "CampaignVersionWorkflowState", "ImapAppendAttempt", "IssueSeverity", "JobBuildStatus", "JobImapStatus", "JobQueueStatus", "JobSendStatus", "JobValidationStatus", "SendAttempt", ]