import logging from collections.abc import Sequence from anydi import singleton from ows_text_campaigns.assets.utils import create_asset_filename from ows_text_campaigns.audiences.repositories import AudienceRepository from ows_text_campaigns.campaigns.dtos import ( CampaignDetailedAnalytics, CampaignFanCountByCountryChannel, CampaignLinkAnalytics, ) from ows_text_campaigns.campaigns.enums import MessageChannel from ows_text_campaigns.campaigns.exceptions import CampaignNotFoundError from ows_text_campaigns.campaigns.message import PreparedMessage from ows_text_campaigns.campaigns.models import Campaign from ows_text_campaigns.campaigns.repositories import CampaignRepository from ows_text_campaigns.campaigns.types import ( CampaignAnalyticsCriteria, ChannelMessageInfo, MessageInfo, MessagePreview, ShortenedUrl, ) from ows_text_campaigns.campaigns.utils import replace_short_link_tags from ows_text_campaigns.config import Settings logger = logging.getLogger(__name__) @singleton class CampaignService: def __init__( self, campaign_repository: CampaignRepository, audience_repository: AudienceRepository, settings: Settings, ) -> None: self.campaign_repository = campaign_repository self.audience_repository = audience_repository self.settings = settings def get_campaign(self, campaign_id: str) -> Campaign: campaign = self.campaign_repository.get_by_id(campaign_id) if not campaign: raise CampaignNotFoundError return campaign def get_campaign_analytics( self, campaign_id: str, has_shortened_urls: bool ) -> CampaignDetailedAnalytics: response = self.campaign_repository.get_detailed_analytics( campaign_id, has_shortened_urls ) return response def get_campaign_link_analytics( self, *, criteria: CampaignAnalyticsCriteria, long_urls: list[str], short_urls: list[ShortenedUrl], ) -> list[CampaignLinkAnalytics]: """Get analytics for a specific campaign.""" return self.campaign_repository.get_link_analytics( criteria, long_urls, short_urls ) def get_campaign_fan_analytics( self, audience_id: str ) -> list[CampaignFanCountByCountryChannel]: response = self.campaign_repository.get_fan_count_by_country_channel( audience_id, ) return response def save_campaign(self, campaign: Campaign) -> None: self.campaign_repository.save(campaign) def prefetch_audience(self, campaigns: Sequence[Campaign]) -> Sequence[Campaign]: """Prefetch recipients count for draft campaigns.""" audience_ids = [ campaign.audience_id for campaign in campaigns if campaign.audience_id is not None ] audiences_by_id = { audience.id: audience for audience in self.audience_repository.find_by_ids(audience_ids) } for campaign in campaigns: if campaign.audience_id in audiences_by_id: audience = audiences_by_id[campaign.audience_id] campaign.audience = audience return campaigns def prefetch_analytics(self, campaigns: Sequence[Campaign]) -> Sequence[Campaign]: """Prefetch analytics for campaigns.""" campaign_ids = [campaign.id for campaign in campaigns] analytics_by_campaign_id = { analytics.campaign_id: analytics for analytics in self.campaign_repository.get_analytics(campaign_ids) } for campaign in campaigns: if campaign.id in analytics_by_campaign_id: campaign.analytics = analytics_by_campaign_id[campaign.id] return campaigns def create_attachment_key(self, campaign_id: str, *, filename: str) -> str: """Create a new image key for the artist.""" filename = create_asset_filename(filename) return f"{self.settings.assets_path}/campaigns/{campaign_id}/{filename}" def get_channel_message_info( self, campaign: Campaign, *, include_unsubscribe_text: bool = True, ) -> list[ChannelMessageInfo]: """Get channel message info for the campaign.""" shortened_urls = { shortened_url.id: shortened_url.preview_url for shortened_url in campaign.shortened_urls } content = campaign.get_content() return [ ChannelMessageInfo( channel=MessageChannel.SMS, info=MessageInfo.from_message( self.render_message_for_channel( content=content, channel=MessageChannel.SMS, shortened_urls=shortened_urls, include_unsubscribe_text=include_unsubscribe_text, ) ), ), ChannelMessageInfo( channel=MessageChannel.WHATSAPP, info=MessageInfo.from_message( self.render_message_for_channel( content=content, channel=MessageChannel.WHATSAPP, shortened_urls=shortened_urls, include_unsubscribe_text=include_unsubscribe_text, ) ), ), ] def get_message_preview( self, campaign: Campaign, *, channel: MessageChannel, include_unsubscribe_text: bool = True, ) -> MessagePreview: """Get channel message info for the campaign.""" shortened_urls = { shortened_url.id: shortened_url.preview_url for shortened_url in campaign.shortened_urls } content = campaign.get_content() message = self.render_message_for_channel( content=content, channel=channel, shortened_urls=shortened_urls, include_unsubscribe_text=include_unsubscribe_text, ) return MessagePreview(message=message, info=MessageInfo.from_message(message)) def render_message_for_channel( self, *, content: str, channel: MessageChannel, shortened_urls: dict[str, str] | None = None, include_unsubscribe_text: bool = True, ) -> str: content = replace_short_link_tags(content, shortened_urls=shortened_urls or {}) prepared_message = PreparedMessage(content) message = ( prepared_message.to_whatsapp() if channel == MessageChannel.WHATSAPP else prepared_message.to_sms() ) if include_unsubscribe_text: return self._append_unsubscribe_text(message) return message @staticmethod def _append_unsubscribe_text(message: str) -> str: """Append unsubscribe text to the content.""" unsubscribe_text = "\nReply STOP to unsubscribe." return message + unsubscribe_text def should_enqueue_campaign(self, campaign: Campaign) -> bool: return campaign.is_ready_for_queue( window_minutes=self.settings.campaign_schedule_in_queue_minutes, )