from __future__ import annotations import datetime import math from operator import attrgetter import sqlalchemy as sa from fansifter_common.adapters.db.types import ChoiceType, PydanticType from fansifter_common.auth.account import Account from fansifter_common.utils import timezone from fansifter_common.utils.uuid import uuid_string from slugify import slugify from sqlalchemy.orm import Mapped, mapped_column, relationship from dmp.adapters.db import Model, ReportingModel from dmp.audiences.enums import ( AudienceExportFormat, AudienceExportJustification, AudienceExportReason, AudienceExportStatus, AudienceSharePlatform, AudienceTarget, ) from dmp.audiences.exceptions import AudienceExportInvalidStatusError from dmp.audiences.filters import AudienceFilters, Operator from dmp.audiences.types import FanDataListId from dmp.fandata.enums import FanSegment from dmp.rosters.enums import FanDataListType class Audience(Model, kw_only=True): __tablename__ = "audience" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) name: Mapped[str] vendor_id: Mapped[int] subaccount_id: Mapped[int] target: Mapped[AudienceTarget] = mapped_column(ChoiceType(AudienceTarget)) filters: Mapped[AudienceFilters] = mapped_column( PydanticType(AudienceFilters, exclude_unset=True), default_factory=AudienceFilters.model_construct, ) fan_count: Mapped[int] = mapped_column(default=0) created_at: Mapped[datetime.datetime] = mapped_column(default_factory=timezone.now) created_by: Mapped[str] updated_at: Mapped[datetime.datetime] = mapped_column( default_factory=timezone.now, onupdate=timezone.now ) updated_by: Mapped[str] refreshed_at: Mapped[datetime.datetime | None] = mapped_column(default=None) refreshed_by: Mapped[str | None] = mapped_column(default=None) archived_at: Mapped[datetime.datetime | None] = mapped_column(default=None) archived_by: Mapped[str | None] = mapped_column(default=None) recipients_count_by_email_provider: Mapped[dict[str, int] | None] = mapped_column( PydanticType(dict[str, int] | None), default=None, ) # relations fandata_lists: Mapped[list[AudienceFanDataList]] = relationship( lazy="raise", default_factory=list, ) snapshots: Mapped[list[AudienceSnapshot]] = relationship( back_populates="audience", lazy="raise", default_factory=list, ) def __str__(self) -> str: return self.name @property def account(self) -> Account: return Account(vendor_id=self.vendor_id, subaccount_id=self.subaccount_id) @property def countries(self) -> list[str]: return self.filters.countries or [] @property def fan_segments(self) -> list[FanSegment]: if not self.filters.fan_segments: return [] if self.filters.fan_segments.operator == Operator.EXCLUDE: return [ segment for segment in FanSegment.audience_choices() if segment not in self.filters.fan_segments.value ] return self.filters.fan_segments.value @property def latest_snapshot(self) -> AudienceSnapshot | None: try: return sorted(self.snapshots, key=attrgetter("created_at"))[-1] except IndexError: pass return None @property def is_archived(self) -> bool: return self.archived_at is not None @property def fandata_list_ids(self) -> list[FanDataListId]: return [ FanDataListId(id=fandata_item.list_id, type=fandata_item.type) for fandata_item in self.fandata_lists ] @property def global_participant_id(self) -> str | None: for fandata_item in self.fandata_lists: if fandata_item.type == FanDataListType.ARTIST: return fandata_item.list_id return None class AudienceFanDataList(Model, kw_only=True): __tablename__ = "audience_fandata_list" __table_args__ = (sa.UniqueConstraint("audience_id", "list_id", "type"),) id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) audience_id: Mapped[str] = mapped_column(sa.ForeignKey("audience.id"), init=False) list_id: Mapped[str] type: Mapped[FanDataListType] = mapped_column(ChoiceType(FanDataListType)) class AudienceSnapshot(Model, kw_only=True): __tablename__ = "audience_snapshot" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) audience_id: Mapped[str] = mapped_column(sa.ForeignKey("audience.id"), init=False) target: Mapped[AudienceTarget | None] = mapped_column( ChoiceType(AudienceTarget), init=False ) filters: Mapped[AudienceFilters] = mapped_column( PydanticType(AudienceFilters, exclude_unset=True), default_factory=AudienceFilters.model_construct, init=False, ) fan_count: Mapped[int] = mapped_column(default=0) created_at: Mapped[datetime.datetime] = mapped_column(default_factory=timezone.now) created_by: Mapped[str] # relations audience: Mapped[Audience] = relationship(back_populates="snapshots", lazy="raise") def __post_init__(self) -> None: self.audience_id = self.audience.id self.target = self.audience.target self.filters = self.audience.filters class AudienceExportMixin: status: Mapped[AudienceExportStatus] status_transitions = { AudienceExportStatus.PENDING: [ AudienceExportStatus.PROCESSING, ], AudienceExportStatus.PROCESSING: [ AudienceExportStatus.FAILED, AudienceExportStatus.COMPLETED, ], AudienceExportStatus.COMPLETED: [ AudienceExportStatus.DELETED, ], } @property def is_completed(self) -> bool: return self.status == AudienceExportStatus.COMPLETED @property def is_pending(self) -> bool: return self.status == AudienceExportStatus.PENDING @property def is_processing(self) -> bool: return self.status == AudienceExportStatus.PROCESSING def update_status(self, status: AudienceExportStatus) -> None: if self.status == status: return try: next_statuses = self.status_transitions[self.status] except KeyError as exc: raise AudienceExportInvalidStatusError( f"`{self.status}` status cannot be changed." ) from exc if status not in next_statuses: raise AudienceExportInvalidStatusError( f"`{self.status}` cannot be changed to `{status}`." ) self.status = status class AudienceExport(Model, AudienceExportMixin, kw_only=True): __tablename__ = "audience_export" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) audience_id: Mapped[str] = mapped_column(sa.ForeignKey("audience.id"), init=False) snapshot_id: Mapped[str] = mapped_column( sa.ForeignKey("audience_snapshot.id"), init=False ) format: Mapped[AudienceExportFormat] = mapped_column( ChoiceType(AudienceExportFormat) ) key: Mapped[str] = mapped_column(init=False) zip_key: Mapped[str | None] = mapped_column(default=None) reason: Mapped[AudienceExportReason] = mapped_column( ChoiceType(AudienceExportReason) ) justification: Mapped[AudienceExportJustification] = mapped_column( ChoiceType(AudienceExportJustification) ) reason_notes: Mapped[str | None] = mapped_column(default=None) status: Mapped[AudienceExportStatus] = mapped_column( ChoiceType(AudienceExportStatus), default=AudienceExportStatus.PENDING, ) created_at: Mapped[datetime.datetime] = mapped_column(default_factory=timezone.now) created_by: Mapped[str] updated_at: Mapped[datetime.datetime] = mapped_column( default_factory=timezone.now, onupdate=timezone.now, ) # relations audience: Mapped[Audience] = relationship(lazy="raise") snapshot: Mapped[AudienceSnapshot] = relationship(lazy="raise") def __post_init__(self) -> None: self.audience_id = self.audience.id self.snapshot_id = self.snapshot.id self.key = self._make_key() @property def audience_name(self) -> str: return self.audience.name def _make_key(self) -> str: """Make key for audience export file.""" filename = "_".join( [ slugify(self.audience.name, separator="_")[:100], self.reason.service_name, self.snapshot.created_at.strftime("%Y_%m_%d_%H_%M_%S"), ] ) return f"raw/{self.id}/{filename}.{self.format.lower()}" class AudienceShare(Model, AudienceExportMixin, kw_only=True): __tablename__ = "audience_share" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) audience_id: Mapped[str] = mapped_column(sa.ForeignKey("audience.id"), init=False) snapshot_id: Mapped[str] = mapped_column( sa.ForeignKey("audience_snapshot.id"), init=False ) key: Mapped[str] = mapped_column(init=False) platform: Mapped[AudienceSharePlatform] = mapped_column( ChoiceType(AudienceSharePlatform) ) justification: Mapped[AudienceExportJustification] = mapped_column( ChoiceType(AudienceExportJustification) ) reason_notes: Mapped[str | None] = mapped_column(default=None) status: Mapped[AudienceExportStatus] = mapped_column( ChoiceType(AudienceExportStatus), default=AudienceExportStatus.PENDING, ) fans_count: Mapped[int] = mapped_column(init=False) processed_fans: Mapped[int] = mapped_column(default=0) created_at: Mapped[datetime.datetime] = mapped_column(default_factory=timezone.now) created_by: Mapped[str] updated_at: Mapped[datetime.datetime] = mapped_column( default_factory=timezone.now, onupdate=timezone.now ) # relations audience: Mapped[Audience] = relationship(lazy="raise") snapshot: Mapped[AudienceSnapshot] = relationship(lazy="raise") def __post_init__(self) -> None: self.audience_id = self.audience.id self.snapshot_id = self.snapshot.id self.fans_count = self.snapshot.fan_count self.key = self._make_key() def _make_key(self) -> str: """Make key for audience share file.""" filename = "_".join( [ slugify(self.audience.name, separator="_")[:100], self.snapshot.created_at.strftime("%Y_%m_%d_%H_%M_%S"), ] ) return f"shares/{self.id}/{filename}.csv" @property def progress(self) -> float: if self.is_completed: return 1.0 if not self.is_pending and self.processed_fans > 0 and self.fans_count > 0: return min( round(self.processed_fans / self.fans_count, 2), 1.0, ) return 0 @property def seconds_remaining(self) -> int | None: if self.is_completed: return 0 progress = self.progress # early predictions can be inaccurate - returning None before 10% completion if not self.is_processing or progress < 0.1: return None seconds_since_start = (timezone.now() - self.created_at).total_seconds() seconds_total_estimated = seconds_since_start / progress seconds_remaining = math.ceil(seconds_total_estimated - seconds_since_start) return seconds_remaining class AudienceFan(ReportingModel, kw_only=True): __tablename__ = "audience_fan" snapshot_id: Mapped[str] = mapped_column(primary_key=True) fan_id: Mapped[str] = mapped_column(primary_key=True) last_engagement_at: Mapped[datetime.datetime | None] = mapped_column(default=None) class AudienceEmailFan(ReportingModel, kw_only=True): __tablename__ = "audience_email_fan" audience_id: Mapped[str] = mapped_column(primary_key=True) fan_id: Mapped[str] = mapped_column(primary_key=True) last_engagement_at: Mapped[datetime.datetime | None] = mapped_column(default=None) class AudienceTextFan(ReportingModel, kw_only=True): __tablename__ = "audience_text_fan" audience_id: Mapped[str] = mapped_column(primary_key=True) fan_id: Mapped[str] = mapped_column(primary_key=True) fan_phone_number: Mapped[str] fan_country: Mapped[str] fan_state: Mapped[str | None] = mapped_column(default=None) fan_area_code: Mapped[str | None] = mapped_column(default=None) channel: Mapped[str]