from __future__ import annotations import abc import datetime import logging from collections.abc import Sequence from typing import ClassVar, Literal from fansifter_common.utils import timezone from pydantic import BaseModel, ConfigDict, Field, ValidationError from preference_center.profile.constants import ( ORIGIN_TYPE_AUTOMATED_EMAIL, ORIGIN_TYPE_EMAIL_CAMPAIGN, ) from preference_center.profile.models import Profile, Subscription from preference_center.profile.types import AnyValueStr logger = logging.getLogger(__name__) ProfileFieldName = Literal[ "firstName", "lastName", "email", "phoneNumber", "dateOfBirth", "birthday", "gender", "countryCode", "city", "address", "zipCode", ] class ProfileUpdateField(BaseModel): field_name: ProfileFieldName = Field( alias="fieldName", title="Field name", description="The target field or column name", ) old_value: AnyValueStr | None = Field(alias="oldValue", title="Old value") new_value: AnyValueStr | None = Field(alias="newValue", title="New value") model_config = ConfigDict(frozen=True) # Events class ProfileEvent(BaseModel, abc.ABC): id: str = Field(title="ID", description="Unique profile identifier in UUID format") crm_id: str | None = Field(alias="crmId", title="CRM ID") class ProfileUpdatedV1(ProfileEvent): updated_at: datetime.datetime = Field(alias="updatedAt", title="Updated at") updated_fields: set[ProfileUpdateField] = Field( alias="updatedFields", title="Updated fields" ) updated_by: str | None = Field(default=None, alias="updatedBy", title="Updated by") fields_map: ClassVar[dict[str, ProfileFieldName]] = { "email": "email", "first_name": "firstName", "last_name": "lastName", "phone_number": "phoneNumber", "date_of_birth": "dateOfBirth", "birthday": "birthday", "gender": "gender", "country_code": "countryCode", "city": "city", "address": "address", "zip_code": "zipCode", } @classmethod def _get_updated_values(cls, profile: Profile) -> set[ProfileUpdateField]: update_fields = set() for origin_field_name, value_change in profile.changed_fields.items(): try: field_name = cls.fields_map[origin_field_name] except KeyError: continue try: update_fields.add( ProfileUpdateField( fieldName=field_name, oldValue=value_change.old_value, newValue=value_change.new_value, ) ) except ValidationError as exc: logger.error("Profile update field validation error", exc_info=exc) continue return update_fields @classmethod def from_profile(cls, profile: Profile) -> ProfileUpdatedV1: return cls.model_construct( id=profile.id, crm_id=profile.crm_id, updated_at=profile.updated_at, updated_fields=cls._get_updated_values(profile), updated_by=profile.updated_by, ) class ProfileDeletedV1(ProfileEvent): email_campaign_id: str | None = Field( default=None, alias="emailCampaignId", title="Email campaign ID", deprecated=True, ) origin_id: str | None = Field(alias="originId", title="Origin ID") origin_type: str | None = Field( alias="originType", title="Origin type (EMAIL_CAMPAIGN|AUTOMATED_EMAIL)" ) automated_email_trigger_id: str | None = Field( default=None, alias="automatedEmailTriggerId", ) deleted_at: datetime.datetime = Field(alias="deletedAt", title="Deleted at") updated_by: str | None = Field(default=None, alias="updatedBy", title="Updated by") def model_post_init(self, __context: object) -> None: """Set email_campaign_id for backward compatibility when origin_type is EMAIL_CAMPAIGN.""" if self.origin_type == ORIGIN_TYPE_EMAIL_CAMPAIGN: self.email_campaign_id = self.origin_id elif self.email_campaign_id is not None: self.origin_type = ORIGIN_TYPE_EMAIL_CAMPAIGN self.origin_id = self.email_campaign_id if self.origin_type != ORIGIN_TYPE_AUTOMATED_EMAIL: self.automated_email_trigger_id = None @classmethod def from_profile( cls, profile: Profile, *, origin_id: str | None, origin_type: str | None, automated_email_trigger_id: str | None = None, ) -> ProfileDeletedV1: return cls.model_construct( id=profile.id, crm_id=profile.crm_id, origin_id=origin_id, origin_type=origin_type, automated_email_trigger_id=automated_email_trigger_id, deleted_at=timezone.now(), updated_by=profile.updated_by, ) class SubscriptionStatusUpdate(BaseModel): id: str = Field( title="ID", description="Unique subscription identifier in UUID format" ) crm_id: str | None = Field(alias="crmId", title="CRM ID") mailing_list_id: str | None = Field(alias="mailingListId", title="Mailing list ID") mailing_list_crm_id: str | None = Field( alias="mailingListCrmId", title="Mailing list CRM ID" ) email_campaign_id: str | None = Field( default=None, alias="emailCampaignId", title="Email campaign ID", deprecated=True, ) origin_id: str | None = Field(alias="originId", title="Origin ID") origin_type: str | None = Field( alias="originType", title="Origin type (EMAIL_CAMPAIGN|AUTOMATED_EMAIL)" ) automated_email_trigger_id: str | None = Field( default=None, alias="automatedEmailTriggerId", ) old_value: bool = Field(alias="oldValue", title="Old value") new_value: bool = Field(alias="newValue", title="New value") def model_post_init(self, __context: object) -> None: """Set email_campaign_id for backward compatibility when origin_type is EMAIL_CAMPAIGN.""" if self.origin_type == ORIGIN_TYPE_EMAIL_CAMPAIGN: self.email_campaign_id = self.origin_id elif self.email_campaign_id is not None: self.origin_type = ORIGIN_TYPE_EMAIL_CAMPAIGN self.origin_id = self.email_campaign_id if self.origin_type != ORIGIN_TYPE_AUTOMATED_EMAIL: self.automated_email_trigger_id = None @classmethod def from_subscription( cls, subscription: Subscription, *, origin_id: str | None, origin_type: str | None, automated_email_trigger_id: str | None = None, ) -> SubscriptionStatusUpdate: crm_id = None mailing_list_crm_id = None return cls.model_construct( id=subscription.id, crm_id=crm_id, mailing_list_id=subscription.mailing_list_id, mailing_list_crm_id=mailing_list_crm_id, origin_id=origin_id, origin_type=origin_type, automated_email_trigger_id=automated_email_trigger_id, old_value=not subscription.is_active, new_value=subscription.is_active, ) class ProfileSubscriptionsUpdatedV1(ProfileEvent): updated_subscriptions: list[SubscriptionStatusUpdate] = Field( alias="updatedSubscriptions", title="Updated subscriptions" ) updated_at: datetime.datetime = Field(alias="updatedAt", title="Updated at") updated_by: str | None = Field(default=None, alias="updatedBy", title="Updated by") @classmethod def from_profile_subscriptions( cls, profile: Profile, *, subscriptions: Sequence[Subscription], origin_id: str | None, origin_type: str | None, automated_email_trigger_id: str | None = None, ) -> ProfileSubscriptionsUpdatedV1: return cls.model_construct( id=profile.id, crm_id=profile.crm_id, updated_subscriptions=[ SubscriptionStatusUpdate.from_subscription( subscription, origin_id=origin_id, origin_type=origin_type, automated_email_trigger_id=automated_email_trigger_id, ) for subscription in subscriptions ], updated_at=timezone.now(), updated_by=profile.updated_by, )