import logging
from collections.abc import Iterator
from dataclasses import dataclass
from anydi import singleton
from cachetools import LRUCache, cachedmethod
from emval import validate_email
from fansifter_common.adapters.sendgrid.models import FanData, MergeTags
from fansifter_common.encrypter import JWTEncrypter
from fansifter_common.identifiers.types import FansifterProfileIdentifier
from fansifter_common.identifiers.utils import encrypt_profile_token
from fansifter_common.legal_info.services import LegalInfoService
from fansifter_common.legal_info.types import LegalEntity
from fansifter_common.translation.services import TranslationService
from app.config import Settings
from app.repositories import BatchRecipientRepository
logger = logging.getLogger(__name__)
@dataclass(slots=True, frozen=True)
class ComplianceFooter:
opt_in_info: str
opt_in_info_external: str
unsubscribe_text: str
profile_text: str | None = None
@singleton
class FanDataService:
def __init__(
self,
recipient_repository: BatchRecipientRepository,
legal_info_service: LegalInfoService,
translation_service: TranslationService,
preference_center_encrypter: JWTEncrypter,
settings: Settings,
) -> None:
self.recipient_repository = recipient_repository
self.legal_info_service = legal_info_service
self.translation_service = translation_service
self.preference_center_encrypter = preference_center_encrypter
self.settings = settings
# Cached
self._legal_cache: LRUCache[str, LegalEntity] = LRUCache(maxsize=256)
self._privacy_cache: LRUCache[str, str] = LRUCache(maxsize=256)
self._footer_cache: LRUCache[str, ComplianceFooter] = LRUCache(maxsize=256)
def get_fans_data(
self, *, campaign_id: str, batch_id: int, limit: int, offset: int
) -> Iterator[FanData]:
"""Get fans data for the given snapshot id."""
recipients = self.recipient_repository.iter_next_in_batch(
batch_id=batch_id, limit=limit, offset=offset
)
for recipient in recipients:
try:
validated_email = validate_email(
recipient.fan_email, deliverable_address=False
)
except Exception: # noqa
logger.warning(
"Detected invalid email for audience fan: %s",
recipient.fan_email,
extra={
"campaign_id": campaign_id,
"batch_id": batch_id,
},
)
continue
# Skip non Fansifter email domains
if (
self.settings.send_fansifter_only
and validated_email.ascii_domain not in self.settings.fansifter_domains
):
continue
preference_center_profile_token = encrypt_profile_token(
self.preference_center_encrypter,
identifier=FansifterProfileIdentifier.model_construct(
profile_id=recipient.profile_id,
email_campaign_id=campaign_id,
),
)
fan_country_iso2 = recipient.fan_country_iso2 or ""
legal_entity = self.get_legal_entity(fan_country_iso2)
privacy_links_block = self.get_privacy_links_block(fan_country_iso2)
compliance_footer = self.get_compliance_footer(fan_country_iso2)
yield FanData(
email=validated_email.normalized,
profile_token=str(preference_center_profile_token),
privacy_links_block=privacy_links_block,
legal_entity_name=legal_entity.name,
legal_entity_address=legal_entity.address,
country_iso2=fan_country_iso2,
opt_in_info=compliance_footer.opt_in_info,
opt_in_info_external=compliance_footer.opt_in_info_external,
unsubscribe_text=compliance_footer.unsubscribe_text,
profile_text=compliance_footer.profile_text,
merge_tags=MergeTags(first_name=recipient.fan_first_name),
)
@cachedmethod(lambda self: self._legal_cache)
def get_legal_entity(self, country_iso2: str) -> LegalEntity:
return self.legal_info_service.get_legal_entity(country_iso2)
@cachedmethod(lambda self: self._privacy_cache)
def get_privacy_links_block(self, country_iso2: str) -> str:
return self.legal_info_service.prepare_privacy_links_for_country(
country_iso2, self.settings.privacy_footer_utm_source_name
)
@cachedmethod(lambda self: self._footer_cache)
def get_compliance_footer(self, country_iso2: str) -> ComplianceFooter:
translations = self.translation_service.get_translations_by_country_code(
country_iso2
)
opt_in_info = "
".join(
translation.optInInfo for translation in translations.values()
)
opt_in_info_external = "
".join(
translation.optInInfoExternal for translation in translations.values()
)
unsubscribe = " | ".join(
translation.unsubscribe for translation in translations.values()
)
profile_texts = [t.profile for t in translations.values() if t.profile]
profile_text = " | ".join(profile_texts) if profile_texts else None
return ComplianceFooter(
opt_in_info=opt_in_info,
opt_in_info_external=opt_in_info_external,
unsubscribe_text=unsubscribe,
profile_text=profile_text,
)