import datetime import decimal from typing import Annotated, Any import phonenumbers import pycountry from phonenumbers import geocoder from phonenumbers.phonenumberutil import region_code_for_number from pycountry.db import Subdivision from pydantic import ( AwareDatetime, Base64Str, BaseModel, ConfigDict, Field, HttpUrl, RootModel, SecretStr, ) from ows_text_campaigns.adapters.scanner import ContentRecommendations from ows_text_campaigns.artist.types import PhoneNumberStr from ows_text_campaigns.audiences.enums import AudienceTarget from ows_text_campaigns.campaigns.dtos import AgeRange, Gender from ows_text_campaigns.campaigns.enums import ( CampaignCancelReason, CampaignStatus, LinkType, MessageChannel, ShorteningMethod, ) from ows_text_campaigns.campaigns.types import ( CampaignOrderBy, FanRenderedMessage, MessagePreview, ShortenedUrlPersonalizedAttributes, TimezoneAdjustment, UrlPathLike, ) BaseModel.model_config["populate_by_name"] = True # Common class LimitOffsetPage[T](BaseModel): total: int items: list[T] limit: int offset: int @classmethod def __concrete_name__(cls: type[Any], params: tuple[type[Any], ...]) -> str: return f"{params[0].__name__.title()}LimitOffsetPage" # Infra class HelloResponse(BaseModel): status: str = "ok" # Audience class Audience(BaseModel): id: str name: str fan_count: int = Field(alias="fanCount") is_archived: bool = Field(alias="isArchived") target: AudienceTarget # Assets class UploadCredentials(BaseModel): key: str bucket: str region: str access_key_id: str = Field(alias="accessKeyId") secret_access_key: str = Field(alias="secretAccessKey") session_token: str = Field(alias="sessionToken") expires_at: AwareDatetime = Field(alias="expiresAt") class Asset(BaseModel): id: str original_filename: str = Field(alias="originalFilename") key: str url: str content_type: str = Field(alias="contentType") extension: str file_size: int = Field(alias="fileSize") created_at: datetime.datetime = Field(alias="createdAt") created_by: str | None = Field(alias="createdBy") # Artist class ArtistPhoneNumber(BaseModel): phone_number: str = Field(alias="phoneNumber") country_code: str = Field(alias="countryCode") class ArtistSettings(BaseModel): welcome_message: str = Field(alias="welcomeMessage") welcome_message_instructions: str | None = Field( None, alias="welcomeMessageInstructions" ) opt_in_message: str = Field(alias="optInMessage") opt_in_message_terms: str | None = Field(None, alias="optInMessageTerms") image_url: str | None = Field(default=None, alias="imageUrl") phone_numbers: list[ArtistPhoneNumber] = Field(alias="phoneNumbers") welcome_message_scan_result: ContentRecommendations | None = Field( alias="welcomeMessageScanResult" ) opt_in_message_scan_result: ContentRecommendations | None = Field( alias="optInMessageScanResult" ) class ArtistSettingsByPhoneNumber(BaseModel): artist_name: str = Field(alias="artistName") global_participant_id: str = Field(alias="globalParticipantId") opt_in_message: str = Field(alias="optInMessage") opt_in_message_terms: str = Field(alias="optInMessageTerms") reopt_in_message: str = Field(alias="reOptInMessage") welcome_message: str = Field(alias="welcomeMessage") welcome_message_instructions: str = Field(alias="welcomeMessageInstructions") info_message: str = Field(alias="infoMessage") vcard_url: str | None = Field(default=None, alias="vCardUrl") class CreateArtistImageUploadCredentialsInput(BaseModel): filename: str class CreateArtistImageInput(BaseModel): image_key: str = Field(alias="imageKey") class CreateOrUpdateArtistSettingsInput(BaseModel): welcome_message: str | None = Field(default=None, alias="welcomeMessage") opt_in_message: str | None = Field(default=None, alias="optInMessage") artist_name: str | None = Field(default=None, alias="artistName") phone_numbers: list[PhoneNumberStr] = Field(alias="phoneNumbers", min_length=1) twilio_account_sid: str = Field(alias="twilioAccountSid") twilio_auth_token: SecretStr = Field(alias="twilioAuthToken") twilio_messaging_service_sid: str = Field(alias="twilioMessagingServiceSid") refresh_api_key: bool = Field(False, alias="refreshApiKey") class UpdateArtistSettingsInput(BaseModel): welcome_message: Annotated[str | None, Field(alias="welcomeMessage")] = None opt_in_message: Annotated[str | None, Field(alias="optInMessage")] = None asset_id: Annotated[str | None, Field(alias="assetId")] = None # Campaigns class UrlDomain(BaseModel): id: str domain: str brand: str class GetUrlDomainsInput(BaseModel): vendor_id: int = Field(alias="vendorId") subaccount_id: int = Field(alias="subaccountId") class ShortenedUrl(BaseModel): id: str url: str method: ShorteningMethod domain: str path: str preview_url: str = Field(alias="previewUrl") class ValidateCampaignShortenedUrlInput(BaseModel): id: str | None = None url: HttpUrl method: ShorteningMethod domain: str path: UrlPathLike class CampaignAnalytics(BaseModel): clicks: int click_through_rate: float = Field(alias="clickThroughRate") class Campaign(BaseModel): id: str name: str vendor_id: int = Field(alias="vendorId") subaccount_id: int = Field(alias="subaccountId") global_participant_id: str = Field(alias="globalParticipantId") status: CampaignStatus audience_id: str | None = Field(alias="audienceId") audience: Audience | None = Field(alias="audience") channel: MessageChannel send_at: AwareDatetime | None = Field(alias="sendAt") send_at_timezone: str | None = Field(alias="sendAtTimezone") recipients_count: int | None = Field(alias="recipientsCount") cancel_reason: CampaignCancelReason | None = Field(alias="cancelReason") cancelled_at: datetime.datetime | None = Field(alias="cancelledAt") created_at: datetime.datetime = Field(alias="createdAt") created_by: str = Field(alias="createdBy") updated_at: datetime.datetime = Field(alias="updatedAt") updated_by: str = Field(alias="updatedBy") analytics: CampaignAnalytics | None = None content_scan_result: ContentRecommendations | None = Field( alias="contentScanResult" ) model_config = ConfigDict(from_attributes=True) class CampaignContent(BaseModel): content: str | None class CreateCampaignInput(BaseModel): name: str = Field(min_length=1, max_length=255) vendor_id: int = Field(alias="vendorId") subaccount_id: int = Field(alias="subaccountId") global_participant_id: str = Field(alias="globalParticipantId") channel: MessageChannel | None = Field(default=None) audience_id: str | None = Field(alias="audienceId", default=None) model_config = ConfigDict(str_strip_whitespace=True) class GetCampaignsInput(BaseModel): search: str | None = None vendor_id: int | None = Field(None, alias="vendorId") subaccount_id: int | None = Field(None, alias="subaccountId") global_participant_ids: set[str] | None = Field(None, alias="globalParticipantIds") statuses: list[CampaignStatus] | None = None order_by: list[CampaignOrderBy] | None = Field(None, alias="orderBy") limit: int | None = Field(None, ge=1, le=200) offset: int | None = Field(None, ge=0) class UpdateCampaignInput(BaseModel): name: str = Field(min_length=1, max_length=255) vendor_id: int = Field(alias="vendorId") subaccount_id: int = Field(alias="subaccountId") global_participant_id: str = Field(alias="globalParticipantId") channel: MessageChannel audience_id: str | None = Field(alias="audienceId", default=None) model_config = ConfigDict(str_strip_whitespace=True) class UpdateCampaignContentInput(BaseModel): content: Base64Str | None = None asset_ids: list[str] | None = Field(None, alias="assetIds") model_config = ConfigDict(str_strip_whitespace=True) class CreateCampaignAttachmentUploadCredentialsInput(BaseModel): filename: str class CreateCampaignAttachmentInput(BaseModel): attachment_key: Annotated[str, Field(alias="attachmentKey")] class RenderCampaignMessagesBatchInput(BaseModel): personalized_attributes: Annotated[ list[ShortenedUrlPersonalizedAttributes], Field(max_length=1000) ] class RenderCampaignMessagesBatchOutput(RootModel[list[FanRenderedMessage]]): pass class GetCampaignMessagePreviewInput(BaseModel): channel: MessageChannel include_unsubscribe_text: bool = Field(default=True, alias="includeUnsubscribeText") class GetCampaignMessagePreviewOutput(RootModel[MessagePreview]): pass class CampaignDemographicsAnalyticsItem(BaseModel): label: AgeRange value: int class CampaignDemographicsAnalyticsDataItem(BaseModel): id: Gender total: int items: list[CampaignDemographicsAnalyticsItem] class CampaignDemographicsAnalytics(BaseModel): available_fans_share: float | None = Field(alias="availableFansShare") data: list[CampaignDemographicsAnalyticsDataItem] class CampaignLocationAnalytics(BaseModel): country_code: str = Field(alias="countryCode") sends: int clicks: int | None class CampaignLinkAnalytics(BaseModel): url: str link_type: LinkType = Field(alias="linkType") total_sends: int = Field(alias="totalSends") total_clicks: int | None = Field(alias="totalClicks") clickthrough_rate: float | None = Field(alias="clickthroughRate") clickthrough_rate_change: float | None = Field(alias="clickthroughRateChange") demographics: CampaignDemographicsAnalytics | None locations: list[CampaignLocationAnalytics] | None class CampaignDetailedAnalytics(BaseModel): sends: int clicks: int | None click_through_rate: float | None = Field(alias="clickThroughRate") unsubscribe_rate: float | None = Field(alias="unsubscribeRate") unsubscribe_rate_change: float | None = Field(alias="unsubscribeRateChange") delivery_rate: float = Field(alias="deliveryRate") delivery_rate_change: float | None = Field(alias="deliveryRateChange") delivery_failed_rate: float = Field(alias="deliveryFailedRate") delivery_failed_rate_change: float | None = Field(alias="deliveryFailedRateChange") locations: list[CampaignLocationAnalytics] | None class SendTestCampaignInput(BaseModel): phone_number: PhoneNumberStr = Field(alias="phoneNumber") class ScheduleCampaignInput(BaseModel): send_at: AwareDatetime = Field(alias="sendAt") timezone: str = Field(min_length=1, max_length=255) adjustments: list[TimezoneAdjustment] | None = None class CampaignSchedule(BaseModel): campaign_id: str = Field(alias="campaignId") campaign_status: CampaignStatus | None = Field(alias="campaignStatus") campaign_send_at: AwareDatetime | None = Field(alias="campaignSendAt") queue_enabled: bool | None = Field(alias="queueEnabled") class GetCampaignLiveStatusOutput(BaseModel): status: CampaignStatus messages_to_send: int = Field(alias="messagesToSend") sent_messages: int = Field(alias="sentMessages") class DuplicateCampaignInput(BaseModel): name: str = Field(min_length=1, max_length=255) model_config = ConfigDict(str_strip_whitespace=True) class GetCampaignPricingEstimationInput(BaseModel): reply_share: float = Field( alias="replyShare", ge=0.0, le=1.0, ) segments_count: int | None = Field(alias="segmentsCount", default=None, ge=1) class Cost(BaseModel): min: float max: float class ChannelPricing(BaseModel): name: MessageChannel number_of_fans: int = Field(alias="numberOfFans") outbound_cost: Cost = Field(alias="outboundCost") inbound_cost: Cost = Field(alias="inboundCost") class CountryPricing(BaseModel): name: str channels: list[ChannelPricing] class GetCampaignPricingEstimationResponse(BaseModel): total_cost: Cost = Field(alias="totalCost") countries: list[CountryPricing] class GetCampaignSendTimezonesInput(BaseModel): send_at: AwareDatetime = Field(alias="sendAt") class CampaignSendTimezone(BaseModel): iana_tz_id: str = Field(alias="ianaTzId") utc_offset_std: decimal.Decimal = Field(alias="utcOffsetStd") state_provinces: list[str] | None = Field(alias="stateProvinces") quiet_hours_min: str = Field(alias="quietHoursMin") quiet_hours_max: str = Field(alias="quietHoursMax") next_safe_time: AwareDatetime | None = Field(alias="nextSafeTime") is_in_safe_hours: bool = Field(alias="isInSafeHours") is_adjusted: bool = Field(default=False, alias="isAdjusted") send_at_local: datetime.datetime = Field( alias="sendAtLocal", description="Local time" ) class CampaignSendCountry(BaseModel): country_code: str = Field(alias="countryCode") timezones: list[CampaignSendTimezone] class GetCampaignSendTimezonesOutput(BaseModel): send_at: AwareDatetime = Field(alias="sendAt") countries: list[CampaignSendCountry] class TwilioInboundMessageBody(BaseModel): model_config = ConfigDict(extra="allow", serialize_by_alias=True) account_sid: str = Field(alias="AccountSid") artist_phone_number: str = Field(alias="To") fan_phone_number: str = Field(alias="From") message: str = Field(alias="Body") opt_out_type: str | None = Field(default=None, alias="OptOutType") @property def fan_key(self) -> str: return f"{self.fan_phone_number}:{self.artist_phone_number}" @property def channel(self) -> str: if "whatsapp:" in self.fan_phone_number: return "WHATSAPP" return "SMS" @property def source(self) -> str: return "twilio" @property def fan_number_details(self) -> phonenumbers.PhoneNumber: return phonenumbers.parse(self.fan_phone_number, None) @property def fan_country_code(self) -> str | None: return region_code_for_number(self.fan_number_details) @property def fan_state(self) -> str | None: try: state = geocoder.description_for_number(self.fan_number_details, "en") subdiv: Subdivision = pycountry.subdivisions.lookup(state) return subdiv.code.split("-")[-1] except LookupError: return None @property def fan_area_code(self) -> str | None: if self.fan_number_details.country_code != 1: return None return str(self.fan_number_details.national_number)[:3] class ScanCampaignContentOutput(BaseModel): sensitive_words: list[str] = Field(alias="sensitiveWords") included_artist_name: bool = Field(alias="includedArtistName") class ScanCampaignContentInput(BaseModel): content: str = Field(alias="content", min_length=1) class ScanArtistContentInput(BaseModel): opt_in_message: str = Field(alias="optInMessage", min_length=1) welcome_message: str = Field(alias="welcomeMessage", min_length=1) class ScanArtistContentOutput(BaseModel): opt_in_message: ScanCampaignContentOutput = Field(alias="optInMessage") welcome_message: ScanCampaignContentOutput = Field(alias="welcomeMessage")