import decimal import urllib.parse from collections.abc import Mapping from dataclasses import dataclass from typing import Annotated, Any, cast from fansifter_common.auth.account import Account from pydantic import ( AfterValidator, BaseModel, BeforeValidator, ConfigDict, Field, Json, TypeAdapter, field_validator, model_validator, ) from pydantic_core.core_schema import ValidationInfo from dmp.ad_reporting.enums import ( AdReportingActionType, AdReportingAdEffectiveStatus, AdReportingAdSetEffectiveStatus, AdReportingBudgetType, AdReportingCampaignEffectiveStatus, AdReportingCreativeType, AdReportingObjective, AdReportingPlatform, AdReportingRateType, AdReportingStatus, ) from dmp.ad_reporting.types import ( AwareDatetime, GlobalParticipantIds, TargetingCountries, _make_set, ) # Ad Campaign class AdReportingCampaign(BaseModel): id: str external_id: str name: str account_id: str accounts: set[Account] = Field(default_factory=set) global_participant_ids: GlobalParticipantIds platform: AdReportingPlatform objective: AdReportingObjective targeting_countries: TargetingCountries reporting_url: str effective_status: AdReportingCampaignEffectiveStatus start_at: AwareDatetime end_at: AwareDatetime clicks: int impressions: int reach: int | None = None estimated_ad_recallers: int | None views_p25: int views: int purchases: int | None follows: int | None spend: decimal.Decimal currency: str spend_usd: decimal.Decimal likes: int | None = None complete_payment: int | None = None checkout: int | None = None actions: int action_type: AdReportingActionType rate: decimal.Decimal rate_type: AdReportingRateType cost_per_action: decimal.Decimal cost_per_action_usd: decimal.Decimal frequency: decimal.Decimal | None = None benchmark: decimal.Decimal | None = None artist_benchmark: decimal.Decimal | None = None report_id: str | None = None report_name: str | None = None is_included_in_report: bool = False budget: float | None = None budget_usd: float | None = None budget_type: AdReportingBudgetType @property def vs_benchmark(self) -> decimal.Decimal | None: if self.benchmark is None: return None return self.rate - self.benchmark @property def vs_artist_benchmark(self) -> decimal.Decimal | None: if self.artist_benchmark is None: return None return self.rate - self.artist_benchmark @dataclass(kw_only=True) class AdReportingCampaignCriteria: search: str | None = None platform: AdReportingPlatform | None = None objective: AdReportingObjective | None = None global_participant_id: str | None = None campaign_ids: set[str] | None = None account_ids: set[str] | None = None countries: list[str] | None = None report_id: str | None = None class AdReportingSummary(BaseModel): total: int = 0 clicks: int = 0 impressions: int = 0 reach: int | None = None estimated_ad_recallers: int | None = 0 views_p25: int = 0 views: int = 0 purchases: int = 0 follows: int = 0 spend: float | None = None spend_usd: decimal.Decimal = decimal.Decimal(0) likes: int | None = None complete_payment: int | None = None checkout: int | None = None budget: float | None = None currency: str | None = None budget_usd: float | None = None budget_type: AdReportingBudgetType | None = None actions: int | None = None action_type: AdReportingActionType | None = None rate: decimal.Decimal | None = None rate_type: AdReportingRateType | None = None cost_per_action: decimal.Decimal | None = None cost_per_action_usd: decimal.Decimal | None = None frequency: decimal.Decimal | None = None # Ad Set class AdReportingAdSet(BaseModel): id: str name: str campaign_id: str campaign_name: str account_id: str accounts: set[Account] = Field(default_factory=set) global_participant_ids: GlobalParticipantIds platform: AdReportingPlatform objective: AdReportingObjective targeting_countries: TargetingCountries reporting_url: str effective_status: AdReportingAdSetEffectiveStatus start_at: AwareDatetime end_at: AwareDatetime status: AdReportingStatus clicks: int impressions: int reach: int | None = None estimated_ad_recallers: int | None views_p25: int views: int purchases: int | None follows: int | None currency: str spend: decimal.Decimal spend_usd: decimal.Decimal likes: int | None = None complete_payment: int | None = None checkout: int | None = None actions: int action_type: AdReportingActionType rate: decimal.Decimal rate_type: AdReportingRateType cost_per_action: decimal.Decimal cost_per_action_usd: decimal.Decimal frequency: decimal.Decimal | None = None benchmark: decimal.Decimal | None = None artist_benchmark: decimal.Decimal | None = None budget: float | None = None budget_usd: float | None = None budget_type: AdReportingBudgetType report_id: str | None = None report_name: str | None = None @property def vs_benchmark(self) -> decimal.Decimal | None: if self.benchmark is None: return None return self.rate - self.benchmark @property def vs_artist_benchmark(self) -> decimal.Decimal | None: if self.artist_benchmark is None: return None return self.rate - self.artist_benchmark @dataclass(kw_only=True) class AdReportingAdSetCriteria: search: str | None = None global_participant_id: str | None = None platform: AdReportingPlatform | None = None objective: AdReportingObjective | None = None countries: list[str] | None = None campaign_ids: set[str] | None = None ad_set_ids: set[str] | None = None account_ids: set[str] | None = None # Ad class AdReportingAd(BaseModel): id: str name: str campaign_id: str campaign_name: str ad_set_id: str ad_set_name: str start_at: AwareDatetime end_at: AwareDatetime account_id: str accounts: set[Account] = Field(default_factory=set) global_participant_ids: GlobalParticipantIds platform: AdReportingPlatform objective: AdReportingObjective targeting_countries: TargetingCountries reporting_url: str effective_status: AdReportingAdEffectiveStatus status: AdReportingStatus clicks: int impressions: int reach: int | None = None estimated_ad_recallers: int | None views_p25: int views: int purchases: int | None follows: int | None currency: str spend: decimal.Decimal spend_usd: decimal.Decimal likes: int | None = None complete_payment: int | None = None checkout: int | None = None actions: int action_type: AdReportingActionType rate: decimal.Decimal rate_type: AdReportingRateType cost_per_action: decimal.Decimal cost_per_action_usd: decimal.Decimal frequency: decimal.Decimal | None = None creative_image_url: str | None creative_video_url: str | None creative_post_url: str | None img_s3_obj_key: str | None timg64x64_s3_obj_key: str | None benchmark: decimal.Decimal | None = None artist_benchmark: decimal.Decimal | None = None budget_type: AdReportingBudgetType report_id: str | None = None report_name: str | None = None @property def vs_benchmark(self) -> decimal.Decimal | None: if self.benchmark is None: return None return self.rate - self.benchmark @property def vs_artist_benchmark(self) -> decimal.Decimal | None: if self.artist_benchmark is None: return None return self.rate - self.artist_benchmark @property def creative_type(self) -> AdReportingCreativeType | None: if self.creative_image_url: return self._get_image_creative_type() if self.creative_video_url: return self._get_video_creative_type(self.creative_video_url) if self.creative_post_url and self.platform == AdReportingPlatform.META: return AdReportingCreativeType.INSTAGRAM_POST if self.creative_post_url and self.platform == AdReportingPlatform.TIKTOK: return AdReportingCreativeType.TIKTOK_POST if self.img_s3_obj_key: return AdReportingCreativeType.IMAGE_THUMBNAIL return None def _get_image_creative_type(self) -> AdReportingCreativeType: if self.platform == AdReportingPlatform.GOOGLE: if not self.creative_thumbnail: return AdReportingCreativeType.LINK return AdReportingCreativeType.IMAGE def _get_video_creative_type( self, creative_video_url: str ) -> AdReportingCreativeType: if self.platform == AdReportingPlatform.GOOGLE: for pattern in AdReportingCreativeType.YOUTUBE_VIDEO.url_patterns: if self.creative_thumbnail and pattern in creative_video_url.lower(): return AdReportingCreativeType.YOUTUBE_VIDEO return AdReportingCreativeType.LINK return AdReportingCreativeType.VIDEO @classmethod def get_cdn_url_for_s3_path(cls, cdn_domain: str, path: str | None) -> str | None: if not path: return None return urllib.parse.urljoin( f"https://{cdn_domain}/", urllib.parse.quote_plus(path, safe="/"), ) thumbnail_url: Annotated[str | None, Field(validate_default=True)] = None creative_url: Annotated[str | None, Field(validate_default=True)] = None creative_thumbnail: Annotated[str | None, Field(validate_default=True)] = None @model_validator(mode="before") def setup_fields( cls, values: Mapping[str, Any], info: ValidationInfo, ) -> dict[str, Any]: values = dict(values) if values.get("creative_image_url"): values["creative_url"] = values["creative_image_url"] elif values.get("creative_video_url"): values["creative_url"] = values["creative_video_url"] elif values.get("creative_post_url"): values["creative_url"] = values["creative_post_url"] if context := info.context: assets_cdn_domain = context.get("assets_cdn_domain") values["thumbnail_url"] = cls.get_cdn_url_for_s3_path( assets_cdn_domain, values.get("timg64x64_s3_obj_key") ) if not values.get("creative_url"): values["creative_url"] = cls.get_cdn_url_for_s3_path( assets_cdn_domain, values.get("img_s3_obj_key") ) values["creative_thumbnail"] = cls.get_cdn_url_for_s3_path( assets_cdn_domain, values.get("img_s3_obj_key") ) return values class AdReportingAdGroup(BaseModel): name: str global_participant_ids: Annotated[ list[str], BeforeValidator(_make_set), AfterValidator(sorted) ] campaign_id: str | None campaign_name: str | None ad_set_id: str | None ad_set_name: str | None effective_status: AdReportingAdEffectiveStatus | None account_ids: list[str] | Json[list[str]] platforms: ( Annotated[list[AdReportingPlatform], AfterValidator(sorted)] | Json[Annotated[list[AdReportingPlatform], AfterValidator(sorted)]] ) objectives: list[AdReportingObjective] | Json[list[AdReportingObjective]] targeting_countries: Annotated[ list[str], BeforeValidator(_make_set), AfterValidator(sorted) ] start_at: AwareDatetime end_at: AwareDatetime clicks: int impressions: int views_p25: int views: int purchases: int | None follows: int | None currency: str | None spend: float | None spend_usd: float likes: int | None = None complete_payment: int | None = None checkout: int | None = None actions: int | None action_type: AdReportingActionType | None cost_per_action: float | None cost_per_action_usd: float | None rate: decimal.Decimal | None estimated_ad_recallers: int | None reach: int | None = None frequency: decimal.Decimal | None total_actions_ratio: float | None = None rate_type: AdReportingRateType | None items: list[AdReportingAd] | Json[list[AdReportingAd]] @property def accounts(self) -> set[Account]: return set.union(*[ad.accounts for ad in self.items]) @property def platform(self) -> AdReportingPlatform | None: if len(set(self.platforms)) != 1: return None return self.platforms[0] @property def objective(self) -> AdReportingObjective | None: if len(set(self.objectives)) != 1: return None return self.objectives[0] @property def benchmark(self) -> decimal.Decimal | None: if len(self.accounts) == 1 and self.platform and self.objective: return self.items[0].benchmark return None @property def artist_benchmark(self) -> decimal.Decimal | None: if len(self.accounts) == 1 and self.platform and self.objective: if all( self.items[0].global_participant_ids == item.global_participant_ids for item in self.items ): return self.items[0].artist_benchmark return None @property def vs_benchmark(self) -> decimal.Decimal | None: if (self.benchmark is None) or (self.rate is None): return None return decimal.Decimal(self.rate - self.benchmark) @property def vs_artist_benchmark(self) -> decimal.Decimal | None: if (self.artist_benchmark is None) or (self.rate is None): return None return decimal.Decimal(self.rate - self.artist_benchmark) @classmethod def get_cdn_url_for_s3_path(cls, cdn_domain: str, path: str | None) -> str | None: if not path: return None return urllib.parse.urljoin( f"https://{cdn_domain}/", urllib.parse.quote_plus(path, safe="/"), ) thumbnail_url: Annotated[str | None, Field(validate_default=True)] = None @model_validator(mode="before") def setup_fields( cls, values: Mapping[str, Any], info: ValidationInfo, ) -> dict[str, Any]: values = dict(values) if context := info.context: assets_cdn_domain = context.get("assets_cdn_domain") values["thumbnail_url"] = cls.get_cdn_url_for_s3_path( assets_cdn_domain, values.get("timg64x64_s3_obj_key") ) return values @dataclass(frozen=True) class AdReportingAdCriteria: search: str | None = None ad_set_id: str | None = None global_participant_id: str | None = None platform: AdReportingPlatform | None = None objective: AdReportingObjective | None = None campaign_ids: set[str] | None = None ad_ids: set[str] | None = None account_ids: set[str] | None = None countries: list[str] | None = None # Report @dataclass(frozen=True) class AdReportingReportCriteria: identity_id: str search: str | None = None platform: AdReportingPlatform | None = None global_participant_id: str | None = None class AdReportingReportView(BaseModel): id: str name: str created_at: AwareDatetime created_by: str global_participant_ids: GlobalParticipantIds start_at: AwareDatetime | None end_at: AwareDatetime | None impressions: int clicks: int views_p25: int views: int spend_usd: decimal.Decimal likes: int | None = None complete_payment: int | None = None checkout: int | None = None AdReportingReportViewList = TypeAdapter(list[AdReportingReportView]) class AdReportingCampaignToplinePerformance(BaseModel): platform: AdReportingPlatform objective: AdReportingObjective global_participant_ids: list[str] start_at: AwareDatetime end_at: AwareDatetime campaigns_count: int clicks: int impressions: int reach: int | None = None views_p25: int views: int purchases: int | None follows: int | None estimated_ad_recallers: int | None currency: str | None spend: decimal.Decimal | None spend_usd: decimal.Decimal likes: int | None = None complete_payment: int | None = None checkout: int | None = None actions: int action_type: AdReportingActionType rate: decimal.Decimal rate_type: AdReportingRateType cost_per_action: decimal.Decimal | None cost_per_action_usd: decimal.Decimal frequency: decimal.Decimal | None = None benchmark: decimal.Decimal | None = None artist_benchmark: decimal.Decimal | None = None @field_validator("global_participant_ids", mode="before") # noqa @classmethod def make_global_participant_ids(cls, v: Any) -> list[str]: if isinstance(v, str): return [s for v in v.split(",") if (s := v.strip())] return cast(list[str], v) @property def vs_benchmark(self) -> decimal.Decimal | None: if self.benchmark is None: return None return self.rate - self.benchmark @property def vs_artist_benchmark(self) -> decimal.Decimal | None: if self.artist_benchmark is None: return None return self.rate - self.artist_benchmark class AdReportingCampaignCountryToplinePerformance(BaseModel): country_code: str platform: AdReportingPlatform objective: AdReportingObjective global_participant_ids: list[str] campaigns_count: int clicks: int impressions: int reach: int | None = None views_p25: int views: int purchases: int | None follows: int | None estimated_ad_recallers: int | None currency: str | None spend: decimal.Decimal | None spend_usd: decimal.Decimal likes: int | None = None complete_payment: int | None = None checkout: int | None = None actions: int action_type: AdReportingActionType rate: decimal.Decimal rate_type: AdReportingRateType cost_per_action: decimal.Decimal | None cost_per_action_usd: decimal.Decimal frequency: decimal.Decimal | None = None benchmark: decimal.Decimal | None = None artist_benchmark: decimal.Decimal | None = None @field_validator("global_participant_ids", mode="before") # noqa @classmethod def make_global_participant_ids(cls, v: Any) -> list[str]: if isinstance(v, str): return [s for v in v.split(",") if (s := v.strip())] return cast(list[str], v) @property def vs_benchmark(self) -> decimal.Decimal | None: if self.benchmark is None: return None return self.rate - self.benchmark @property def vs_artist_benchmark(self) -> decimal.Decimal | None: if self.artist_benchmark is None: return None return self.rate - self.artist_benchmark class AdReportingCampaignReport(BaseModel): report_id: str report_name: str has_campaign_included: bool AdReportingCampaignReportList = TypeAdapter(list[AdReportingCampaignReport]) # Campaign objective class AdReportingCampaignObjective(BaseModel): model_config = ConfigDict(frozen=True) platform: AdReportingPlatform objective: AdReportingObjective @dataclass(frozen=True) class AdReportingCampaignObjectiveCriteria: account_ids: set[str] report_id: str | None = None class AdReportingCampaignIdName(BaseModel): id: str name: str class AdReportingAdSetIdName(BaseModel): id: str name: str @dataclass(frozen=True) class AdReportingCampaignBenchmarkByAccountCriteria: accounts: set[str] | None = None countries: list[str] | None = None @dataclass(frozen=True) class AdReportingCampaignBenchmarkByAccountArtistCriteria: accounts: set[str] | None = None artists: set[str] | None = None countries: list[str] | None = None