from __future__ import annotations import datetime from dataclasses import field import sqlalchemy as sa from fansifter_common.adapters.db.types import ChoiceType from fansifter_common.email import EmailType from fansifter_common.utils import timezone from fansifter_common.utils.uuid import uuid_string from sqlalchemy.orm import Mapped, mapped_column, relationship from email_campaigns.adapters.db import Model from email_campaigns.audiences.models import Audience from email_campaigns.campaigns.enums import ( EmailCampaignCancelReason, EmailCampaignStatus, ) from email_campaigns.emails.models import BaseEmail class EmailCampaign(BaseEmail, Model, kw_only=True, repr=False): __tablename__ = "email_campaign" email_type = EmailType.CAMPAIGN id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) status: Mapped[EmailCampaignStatus] = mapped_column( ChoiceType(EmailCampaignStatus), default=EmailCampaignStatus.DRAFT ) audience_id: Mapped[str | None] = mapped_column(default=None) recipients_count: Mapped[int] = mapped_column(default=0) scheduled_at: Mapped[datetime.datetime | None] = mapped_column(default=None) send_at: Mapped[datetime.datetime | None] = mapped_column(default=None) send_at_timezone: Mapped[str | None] = mapped_column(default=None) cancel_reason: Mapped[EmailCampaignCancelReason | None] = mapped_column( default=None ) cancelled_at: Mapped[datetime.datetime | None] = mapped_column(default=None) _audience: Audience | None = field(default=None, init=False, compare=False) @property def is_draft(self) -> bool: return self.status == EmailCampaignStatus.DRAFT @property def is_scheduled(self) -> bool: return self.status == EmailCampaignStatus.SCHEDULED def is_ready_for_queue(self, window_minutes: int = 10) -> bool: return self.status == EmailCampaignStatus.IN_PROGRESS or ( self.status == EmailCampaignStatus.SCHEDULED and self.send_at is not None and timezone.now() >= self.send_at - datetime.timedelta(minutes=window_minutes) ) @property def audience(self) -> Audience | None: return self._audience @audience.setter def audience(self, audience: Audience | None) -> None: if audience is None: self._audience = None self.audience_id = None self.recipients_count = 0 return self._audience = audience # Override audience if self.audience_id != audience.id: self.audience_id = audience.id self.recipients_count = audience.fan_count class CampaignBatch(Model, kw_only=True): __tablename__ = "email_campaign_batch" id: Mapped[int] = mapped_column(primary_key=True, init=False) campaign_id: Mapped[str] = mapped_column( sa.ForeignKey(EmailCampaign.id, ondelete="RESTRICT") ) domain_id: Mapped[str] provider: Mapped[str] batch_size: Mapped[int] batch_offset: Mapped[int] = mapped_column(default=0, server_default=sa.text("0")) completed_at: Mapped[datetime.datetime | None] = mapped_column(default=None) cancelled_at: Mapped[datetime.datetime | None] = mapped_column(default=None) # relation campaign: Mapped[EmailCampaign | None] = relationship(lazy="raise", init=False) class DeliveryQuota(Model, kw_only=True): """Delivery quota tracking for email sending.""" __tablename__ = "email_campaign_delivery_quota" domain_id: Mapped[str] = mapped_column(primary_key=True) provider: Mapped[str] = mapped_column(primary_key=True) last_sent_at: Mapped[datetime.datetime | None] = mapped_column(default=None) last_hour_sent_emails: Mapped[int] = mapped_column(default=0) last_hourly_quota: Mapped[float | None] = mapped_column(default=None)