from __future__ import annotations import datetime from typing import Any, ClassVar, Generic, TypeVar import pydantic from bs4 import BeautifulSoup from pydantic import AnyUrl, BaseModel, Field, validator from pydantic.generics import GenericModel from campaigns.connectors.facebook.enums import ( AdCreativeCallToActionType, AdImageStatus, AdSetFacebookPosition, AdSetInstagramPosition, AdVideoStatus, AudienceBooleanLogicalOperator, AudienceEqualityOperator, AudienceEventSourceType, EngagementAudienceEventFilterField, EngagementAudienceEventValue, InstagramMediaProductType, InstagramMediaType, ObjectStatus, ) from campaigns.core.types import PlainToken from campaigns.meta.enums import CampaignPlatform DataT = TypeVar("DataT") # Generic response models class Cursors(BaseModel): before: str after: str class Paging(BaseModel): cursors: Cursors next: str | None = None class Response(GenericModel, Generic[DataT]): data: DataT paging: Paging | None = None class DeleteResponse(BaseModel): success: bool class Error(BaseModel): message: str type: str code: int fbtrace_id: str error_subcode: int | None = None error_user_title: str | None = None error_user_msg: str | None = None error_data: str | dict[str, Any] | None = None # audience-summary endpoint can return object instead of str as error_data # that points to the field causing error: 'error_data': {'blame_field': 'targeting'} class ErrorResponse(BaseModel): error: Error # Oauth class AccessToken(BaseModel): access_token: PlainToken expires_in: int | None = None class DebugToken(BaseModel): is_valid: bool scopes: list[str] # User class UserPicture(BaseModel): url: str class UserPictureData(BaseModel): data: UserPicture class User(BaseModel): id: str name: str picture: UserPictureData # Page class PagePicture(pydantic.BaseModel): url: AnyUrl class PagePictureResponse(pydantic.BaseModel): data: PagePicture class Page(pydantic.BaseModel): RESPONSE_FIELDS: ClassVar[str] = ",".join( [ "id", "name", "access_token", "tasks", "picture", ] ) id: str name: str access_token: PlainToken tasks: list[str] = Field(default_factory=list) picture: PagePictureResponse @property def is_manageable(self) -> bool: return "MANAGE" in self.tasks class PagePost(pydantic.BaseModel): id: str picture: str | None = None full_picture: str | None = None message: str | None = None is_eligible_for_promotion: bool permalink_url: str created_time: datetime.datetime # Search class TargetingGeolocation(BaseModel): key: str name: str type: str country_code: str | None = None country_name: str | None = None region: str | None = None class TargetingInterestShort(BaseModel): id: str name: str class TargetingInterest(TargetingInterestShort): path: list[str] | None = None disambiguation_category: str | None = None topic: str | None = None class TargetingCity(BaseModel): key: str class TargetingRegion(BaseModel): key: str class TargetingGeolocations(BaseModel): countries: list[str] | None = None regions: list[TargetingRegion] | None = None cities: list[TargetingCity] | None = None class AdCreative(pydantic.BaseModel): RESPONSE_FIELDS: ClassVar[str] = "id,name" id: str name: str class AdReviewFeedback(pydantic.BaseModel): # https://developers.facebook.com/docs/marketing-api/reference/adgroup-review-feedback/ global_feedback: str = Field(alias="global") placement_specific: Any # Campaign class AdCampaign(BaseModel): RESPONSE_FIELDS: ClassVar[str] = ",".join( [ "id", ] ) id: str # Ad class Ad(pydantic.BaseModel): RESPONSE_FIELDS: ClassVar[str] = ",".join( [ "id", "preview_shareable_link", "status", "ad_review_feedback", ] ) id: str preview_shareable_link: str | None status: ObjectStatus ad_review_feedback: AdReviewFeedback | None # AdSet class CustomAudience(BaseModel): id: int class AdSetPosition(BaseModel): # TODO: move to arguments / remove logic publisher_platforms: list[CampaignPlatform] facebook_positions: list[AdSetFacebookPosition] | None = None instagram_positions: list[AdSetInstagramPosition] | None = None @validator("publisher_platforms") def validate_publisher_platforms( cls, publisher_platforms: list[CampaignPlatform] ) -> list[CampaignPlatform]: if not publisher_platforms: raise ValueError("no publisher_platform provided") return publisher_platforms @validator("facebook_positions", always=True) def validate_facebook_positions( cls, facebook_positions: list[AdSetFacebookPosition] | None, values: dict[str, list[CampaignPlatform]], ) -> list[AdSetFacebookPosition] | None: if CampaignPlatform.FACEBOOK in values.get("publisher_platforms", []): if facebook_positions: return facebook_positions else: raise ValueError( "facebook_positions are required for facebook platform" ) if facebook_positions: raise ValueError( "no facebook_positions are needed as facebook is not selected as a platform" ) return None @validator("instagram_positions", always=True) def validate_instagram_positions( cls, instagram_positions: list[AdSetInstagramPosition] | None, values: dict[str, list[CampaignPlatform] | list[AdSetFacebookPosition]], ) -> list[AdSetInstagramPosition] | None: if CampaignPlatform.INSTAGRAM in values.get("publisher_platforms", []): if instagram_positions: return instagram_positions else: raise ValueError( "instagram_positions are required for instagram platform" ) if instagram_positions: raise ValueError( "no instagram_positions are needed as instagram is not selected as a platform" ) return None class AdSetTargetingAudienceTargeting(AdSetPosition): geo_locations: TargetingGeolocations | None = None interests: list[TargetingInterestShort] | None = None age_min: int age_max: int excluded_custom_audiences: list[CustomAudience] | None = None class AdSetCustomAudienceTargeting(AdSetPosition): custom_audiences: list[CustomAudience] excluded_custom_audiences: list[CustomAudience] | None = None class AdSet(pydantic.BaseModel): RESPONSE_FIELDS: ClassVar[str] = ",".join( [ "id", ] ) id: str # AdCreative class AdCreativeCallToActionValue(pydantic.BaseModel): link: str # url class AdCreativeCallToAction(pydantic.BaseModel): type: AdCreativeCallToActionType value: AdCreativeCallToActionValue class AdCreativeLinkData(pydantic.BaseModel): name: str | None = None description: str | None = None message: str link: str image_hash: str call_to_action: AdCreativeCallToAction | None = None class AdCreativeImageStorySpec(pydantic.BaseModel): page_id: str link_data: AdCreativeLinkData instagram_actor_id: str | None = None class AdCreativeVideoData(pydantic.BaseModel): video_id: str image_url: str message: str | None = None call_to_action: AdCreativeCallToAction | None = None title: str | None = None class AdCreativeVideoStorySpec(pydantic.BaseModel): page_id: str video_data: AdCreativeVideoData instagram_actor_id: str | None = None class AdCreativeCustomStorySpec(pydantic.BaseModel): page_id: str instagram_actor_id: str | None = None class AdCreativeInstagramPostStorySpec(pydantic.BaseModel): page_id: str instagram_media_id: str instagram_actor_id: str link_data: AdCreativeLinkData AdCreativeStorySpec = ( AdCreativeCustomStorySpec | AdCreativeInstagramPostStorySpec | AdCreativeImageStorySpec | AdCreativeVideoStorySpec ) # AdImage class AdImage(BaseModel): RESPONSE_FIELDS: ClassVar[str] = ",".join( [ "id", "status", "hash", "url", ] ) id: str status: AdImageStatus hash: str url: str @property def is_valid(self) -> bool: return self.status == AdImageStatus.ACTIVE class CreatedAdImage(BaseModel): hash: str url: str url_256: str class CreatedAdImageBytes(BaseModel): bytes: CreatedAdImage class CreatedAdImageResponse(BaseModel): images: CreatedAdImageBytes # AdVideo class CreatedAdVideo(BaseModel): id: str class VideoStatus(BaseModel): video_status: AdVideoStatus class AdVideoFormat(BaseModel): picture: str filter: str width: int height: int class AdVideo(BaseModel): RESPONSE_FIELDS: ClassVar[str] = ",".join( [ "id", "status", "format", ] ) id: str status: VideoStatus format: list[AdVideoFormat] = Field(default_factory=list) @property def is_valid(self) -> bool | None: if self.status.video_status == AdVideoStatus.READY: return True elif self.status.video_status == AdVideoStatus.PROCESSING: return None return False @property def thumbnail_url(self) -> str | None: if not self.is_valid or not self.format: return None if len(self.format) == 1: return self.format[0].picture formats_sorted = sorted( [fmt for fmt in self.format if fmt.filter != "native"], key=lambda fmt: fmt.width, reverse=True, ) return formats_sorted[0].picture class AdLabel(BaseModel): name: str class AdPreviewData(BaseModel): body: str @property def src(self) -> str | None: if iframe := BeautifulSoup(self.body).iframe: # gets the first element or None if src := iframe.get("src", None): return str(src) return None class AdPreviewResponse(BaseModel): data: list[AdPreviewData] class AudienceSummary(BaseModel): users_lower_bound: int users_upper_bound: int estimate_ready: bool class AudienceSummaryResponse(BaseModel): data: AudienceSummary class EngagementAudienceEventSource(BaseModel): id: str type: AudienceEventSourceType class EngagementAudienceEventFilter(BaseModel): field: EngagementAudienceEventFilterField operator: AudienceEqualityOperator value: EngagementAudienceEventValue class EngagementAudienceRuleFilter(BaseModel): operator: AudienceBooleanLogicalOperator filters: list[EngagementAudienceEventFilter] class EngagementAudienceRuleSetRule(BaseModel): event_sources: list[EngagementAudienceEventSource] retention_seconds: int filter: EngagementAudienceRuleFilter class EngagementAudienceRuleSet(BaseModel): operator: AudienceBooleanLogicalOperator rules: list[EngagementAudienceRuleSetRule] # Naming taken from https://developers.facebook.com/docs/marketing-api/audiences/guides/audience-rules/ class EngagementAudienceRule(BaseModel): inclusions: EngagementAudienceRuleSet class EngagementAudienceDeliveryStatus(BaseModel): code: int description: str class EngagementAudience(BaseModel): RESPONSE_FIELDS: ClassVar[str] = ",".join( [ "id", "name", "approximate_count_lower_bound", "approximate_count_upper_bound", "delivery_status", "retention_days", ] ) id: str name: str approximate_count_lower_bound: int approximate_count_upper_bound: int delivery_status: EngagementAudienceDeliveryStatus retention_days: int # Instagram class InstagramBusinessAccountWithId(BaseModel): id: str class InstagramBusinessAccountResponse(BaseModel): id: str instagram_business_account: InstagramBusinessAccountWithId | None class InstagramBusinessAccount(BaseModel): id: str name: str | None username: str profile_picture_url: str | None class InstagramMedia(BaseModel): id: str permalink: str media_url: str | None # videos don't have this parameter media_type: InstagramMediaType media_product_type: InstagramMediaProductType thumbnail_url: str | None caption: str | None timestamp: datetime.datetime class InstagramUser(BaseModel): id: str class InstagramUserForPageResponse(BaseModel): data: list[InstagramUser]