from __future__ import annotations from datetime import datetime from typing import Self import sqlalchemy as sa from fansifter_common.adapters.db.types import ChoiceType from fansifter_common.auth.account import Account from fansifter_common.utils import timezone from fansifter_common.utils.uuid import uuid_string from sqlalchemy.ext.associationproxy import AssociationProxy, association_proxy from sqlalchemy.orm import Mapped, mapped_column, relationship from dmp.adapters.aws.s3 import S3Client from dmp.adapters.db import Model from dmp.adapters.db.sqltypes import EncryptedTextType from dmp.adapters.meta import models as api_models from dmp.app_connections.enums import AppConnectionStatus from dmp.audiences.models import AudienceShare from dmp.core.types import EncryptedToken class MetaAdAccount(Model, kw_only=True): __tablename__ = "meta_ad_account" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) external_id: Mapped[str] = mapped_column(unique=True) name: Mapped[str | None] = mapped_column(default=None) business_account_id: Mapped[str | None] = mapped_column(default=None) business_account_name: Mapped[str | None] = mapped_column(default=None) business_account_picture_key: Mapped[str | None] = mapped_column(default=None) campaigns_count: Mapped[int] = mapped_column(default=0) created_at: Mapped[datetime] = mapped_column(default_factory=timezone.now) updated_at: Mapped[datetime] = mapped_column( default_factory=timezone.now, onupdate=timezone.now ) @classmethod def from_api_model( cls, api_model: api_models.UserAdAccount, /, s3_client: S3Client, assets_bucket_name: str, assets_cdn_path: str, ) -> Self: if api_model.business is None: raise ValueError("Ad account must have a business account") ad_account = cls( external_id=api_model.id, name=api_model.name, business_account_id=api_model.business.id, business_account_name=api_model.business.name, campaigns_count=api_model.campaigns.summary.total_count, ) ad_account.user_custom_audience_tos_accepted = ( api_model.custom_audience_tos_accepted ) ad_account.business_account_picture_key = s3_client.put_object_from_url( bucket=assets_bucket_name, path_prefix=assets_cdn_path, url=api_model.business.picture.data.url, filename=api_model.business.id, ) return ad_account def update_from_api_model( self, api_model: api_models.UserAdAccount, /, s3_client: S3Client, assets_bucket_name: str, assets_cdn_path: str, ) -> None: self.name = api_model.name self.campaigns_count = api_model.campaigns.summary.total_count if api_model.business is not None: self.business_account_id = api_model.business.id self.business_account_name = api_model.business.name self.business_account_picture_key = s3_client.put_object_from_url( bucket=assets_bucket_name, path_prefix=assets_cdn_path, url=api_model.business.picture.data.url, filename=api_model.business.id, ) self.user_custom_audience_tos_accepted = api_model.custom_audience_tos_accepted @property def user_custom_audience_tos_accepted(self) -> bool: return getattr(self, "_user_custom_audience_tos_accepted", False) @user_custom_audience_tos_accepted.setter def user_custom_audience_tos_accepted(self, value: bool) -> None: self._user_custom_audience_tos_accepted = value # noqa # This property used only for test purposes @property def business_account_picture(self) -> str | None: return self.business_account_picture_key class MetaUserConnectionAdAccount(Model, kw_only=True): __tablename__ = "meta_user_connection_ad_account" user_connection_id: Mapped[str] = mapped_column( sa.ForeignKey("meta_user_connection.id"), primary_key=True, init=False ) ad_account_id: Mapped[str] = mapped_column( sa.ForeignKey("meta_ad_account.id"), primary_key=True, init=False ) custom_audience_tos_accepted: Mapped[bool] = mapped_column(default=False) # relations ad_account: Mapped[MetaAdAccount] = relationship(lazy="raise") user_connection: Mapped[MetaUserConnection] = relationship( lazy="raise", back_populates="ad_account_associations", init=False ) def __post_init__(self) -> None: self.ad_account_id = self.ad_account.id class MetaUserConnection(Model, kw_only=True): __tablename__ = "meta_user_connection" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) identity_id: Mapped[str] = mapped_column(unique=True) user_id: Mapped[str] user_name: Mapped[str] = mapped_column(default="") user_picture: Mapped[str | None] = mapped_column(default=None) token: Mapped[EncryptedToken] = mapped_column(EncryptedTextType) is_valid: Mapped[bool] = mapped_column(default=True) created_at: Mapped[datetime] = mapped_column(default_factory=timezone.now) updated_at: Mapped[datetime] = mapped_column( default_factory=timezone.now, onupdate=timezone.now ) # relations ad_account_associations: Mapped[list[MetaUserConnectionAdAccount]] = relationship( lazy="raise", default_factory=list, repr=False, back_populates="user_connection", cascade="all, delete-orphan", ) ad_accounts: AssociationProxy[list[MetaAdAccount]] = association_proxy( "ad_account_associations", "ad_account", creator=lambda ad_account: MetaUserConnectionAdAccount( ad_account=ad_account, custom_audience_tos_accepted=ad_account.user_custom_audience_tos_accepted, ), default_factory=list, repr=False, ) @property def ad_accounts_count(self) -> int: return len(self.ad_accounts) def remove_ad_account(self, ad_account: MetaAdAccount) -> bool: try: self.ad_accounts.remove(ad_account) except ValueError: return False return True class MetaUserAdAccount(Model, kw_only=True): __tablename__ = "meta_user_ad_account" __table_args__ = (sa.UniqueConstraint("identity_id", "ad_account_id"),) id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) identity_id: Mapped[str] ad_account_id: Mapped[str] = mapped_column(sa.ForeignKey("meta_ad_account.id")) vendor_id: Mapped[int] subaccount_id: Mapped[int] created_at: Mapped[datetime] = mapped_column(default_factory=timezone.now) updated_at: Mapped[datetime] = mapped_column( default_factory=timezone.now, onupdate=timezone.now ) # relations ad_account: Mapped[MetaAdAccount] = relationship(lazy="raise", init=False) @property def account(self) -> Account: return Account(vendor_id=self.vendor_id, subaccount_id=self.subaccount_id) class _MetaAdReportingConnectionAdAccount(Model, kw_only=True): __tablename__ = "meta_ad_reporting_connection_ad_account" connection_id: Mapped[str] = mapped_column( sa.ForeignKey("meta_ad_reporting_connection.id"), primary_key=True ) ad_account_id: Mapped[str] = mapped_column( sa.ForeignKey("meta_ad_account.id"), primary_key=True ) class MetaAdReportingConnection(Model, kw_only=True): __tablename__ = "meta_ad_reporting_connection" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) fivetran_connector_id: Mapped[str] fivetran_schema: Mapped[str] = mapped_column(unique=True) status: Mapped[AppConnectionStatus] = mapped_column( ChoiceType(AppConnectionStatus), default=AppConnectionStatus.PENDING ) identity_id: Mapped[str] = mapped_column(unique=True) is_paused: Mapped[bool] = mapped_column(default=False) initial_email_sent: Mapped[bool] = mapped_column(default=False) synced_at: Mapped[datetime | None] = mapped_column(default=None) created_at: Mapped[datetime] = mapped_column(default_factory=timezone.now) updated_at: Mapped[datetime] = mapped_column( default_factory=timezone.now, onupdate=timezone.now ) # relations ad_accounts: Mapped[list[MetaAdAccount]] = relationship( secondary=_MetaAdReportingConnectionAdAccount.__table__, lazy="raise", default_factory=list, repr=False, ) def __str__(self) -> str: return self.fivetran_schema @property def ad_accounts_count(self) -> int: return len(self.ad_accounts) def remove_ad_account(self, ad_account: MetaAdAccount) -> bool: try: self.ad_accounts.remove(ad_account) except ValueError: return False return True class MetaAudience(Model, kw_only=True): __tablename__ = "meta_audience" id: Mapped[str] = mapped_column(primary_key=True, default_factory=uuid_string) name: Mapped[str] external_id: Mapped[str] = mapped_column(unique=True) ad_account_id: Mapped[str] = mapped_column( sa.ForeignKey("meta_ad_account.id"), init=False ) fans_count: Mapped[int] = mapped_column(init=False) 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 ) share_id: Mapped[str] = mapped_column( sa.ForeignKey("audience_share.id"), unique=True, init=False ) created_at: Mapped[datetime] = mapped_column(default_factory=timezone.now) # relations ad_account: Mapped[MetaAdAccount] = relationship(lazy="raise") share: Mapped[AudienceShare] = relationship(lazy="raise") def __post_init__(self) -> None: self.audience_id = self.share.audience_id self.snapshot_id = self.share.snapshot_id self.share_id = self.share.id self.fans_count = self.share.fans_count self.ad_account_id = self.ad_account.id @property def ad_account_external_id(self) -> str: return self.ad_account.external_id @property def url(self) -> str: # Audiences do not have direct URLs # Returning a URL to a list of Audiences of a given Ad Account return ( "https://adsmanager.facebook.com/adsmanager/audiences" f"?act={self.ad_account_external_id.removeprefix('act_')}" )