from anydi import singleton from dmp.adapters.aws.kms import BaseKMS from dmp.adapters.meta.client import FacebookClient from dmp.adapters.meta.exceptions import FacebookClientError from dmp.config import Settings from dmp.core.types import EncryptedToken, PlainToken from dmp.meta.dtos import ( MetaUser, MetaUserConnectionAdAccount, MetaUserConnectionResult, ) from dmp.meta.enums import MetaUserConnectionStatus from dmp.meta.exceptions import MetaUserConnectionError from dmp.meta.models import MetaAdAccount, MetaUserConnection from dmp.meta.repositories import MetaUserConnectionRepository @singleton class MetaUserConnectionService: def __init__( self, kms: BaseKMS, facebook_client: FacebookClient, user_connection_repository: MetaUserConnectionRepository, settings: Settings, ) -> None: self.kms = kms self.facebook_client = facebook_client self.user_connection_repository = user_connection_repository self.settings = settings def get_user_connection(self, identity_id: str, /) -> MetaUserConnection | None: return self.user_connection_repository.get_by_identity_id(identity_id) def save_user_connection(self, user_connection: MetaUserConnection, /) -> None: self.user_connection_repository.add(user_connection) def delete_user_connection(self, user_connection: MetaUserConnection, /) -> None: self.user_connection_repository.delete(user_connection) def invalidate_user_connection( self, user_connection: MetaUserConnection, / ) -> None: user_connection.is_valid = False self.save_user_connection(user_connection) def refresh_user_connection_ad_accounts_tos( self, user_connection: MetaUserConnection, /, *, user_access_token: PlainToken | None = None, ) -> None: if user_access_token is None: user_access_token = self.decrypt_token(user_connection) api_ad_accounts = self.facebook_client.get_user_ad_accounts( user_connection.user_id, user_access_token=user_access_token, ) api_ad_accounts_by_id = {account.id: account for account in api_ad_accounts} for ad_account_association in user_connection.ad_account_associations: api_ad_account = api_ad_accounts_by_id.get( ad_account_association.ad_account.external_id ) if api_ad_account is None: continue ad_account_association.custom_audience_tos_accepted = ( api_ad_account.custom_audience_tos_accepted ) self.save_user_connection(user_connection) def get_active_audience_sharing_connection( self, *, audience_id: str, ad_account_id: str, identity_id: str, vendor_ids: list[int] | None = None, subaccount_ids: list[int] | None = None, ) -> MetaUserConnection | None: user_connections = ( self.user_connection_repository.find_active_by_audience_and_ad_account_id( audience_id=audience_id, ad_account_id=ad_account_id, identity_id=identity_id, vendor_ids=vendor_ids, subaccount_ids=subaccount_ids, ) ) for user_connection in user_connections: access_token = self.decrypt_token(user_connection.token) try: self.get_user(user_connection, access_token=access_token) except MetaUserConnectionError: self.invalidate_user_connection(user_connection) continue return user_connection return None def get_user( self, user_id_or_user_connection: str | MetaUserConnection, /, *, access_token: PlainToken, ) -> MetaUser: """Get connected user.""" user, user_id = self._get_user_and_user_id(user_id_or_user_connection) try: debug_token = self.facebook_client.debug_token(access_token) except FacebookClientError as exc: raise MetaUserConnectionError( user=user, status=MetaUserConnectionStatus.INVALID, required_scopes=self.settings.meta_user_required_scopes, ) from exc if not debug_token.is_valid: raise MetaUserConnectionError( user=user, status=MetaUserConnectionStatus.INVALID, required_scopes=self.settings.meta_user_required_scopes, ) try: api_user = self.facebook_client.get_user( user_id=user_id, user_access_token=access_token, ) except FacebookClientError as exc: raise MetaUserConnectionError( user=user, status=MetaUserConnectionStatus.INVALID, required_scopes=self.settings.meta_user_required_scopes, ) from exc user = MetaUser( id=api_user.id, name=api_user.name, picture=api_user.picture.data.url, granted_scopes=debug_token.scopes, ) if not user.scopes_sufficient(self.settings.meta_user_required_scopes): raise MetaUserConnectionError( user=user, status=MetaUserConnectionStatus.SCOPES_MISSING, required_scopes=self.settings.meta_user_required_scopes, ) return user def disconnect_user(self, user_connection: MetaUserConnection, /) -> bool: try: return self.facebook_client.disconnect_user( user_id=user_connection.user_id, user_access_token=self.decrypt_token(user_connection.token), ) except FacebookClientError as exc: if exc.is_token_validation_error: return True raise exc def encrypt_token(self, plain_token: PlainToken, /) -> EncryptedToken: """Encrypt token.""" return self.kms.encrypt(plain_token, context={"field": "token"}) def decrypt_token( self, user_connection_or_token: MetaUserConnection | EncryptedToken, / ) -> PlainToken: """Decrypt token.""" if isinstance(user_connection_or_token, MetaUserConnection): encrypted_token = user_connection_or_token.token else: encrypted_token = user_connection_or_token return self.kms.decrypt(encrypted_token, context={"field": "token"}) def make_connected_result( self, user_connection: MetaUserConnection, /, *, user: MetaUser ) -> MetaUserConnectionResult: """Make connected result.""" return self._make_connection_result( user=user, status=MetaUserConnectionStatus.CONNECTED, ad_accounts=[ MetaUserConnectionAdAccount.from_model( ad_account_association, ad_account_id=ad_account_association.ad_account_id, ) for ad_account_association in user_connection.ad_account_associations ], ) def make_not_connected_result(self) -> MetaUserConnectionResult: """Make not connected result.""" return self.make_error_connection_result( user=None, status=MetaUserConnectionStatus.NOT_CONNECTED ) def make_error_connection_result( self, user: MetaUser | None, status: MetaUserConnectionStatus ) -> MetaUserConnectionResult: """Make error connection result.""" return self._make_connection_result(user=user, status=status, ad_accounts=[]) def _make_connection_result( self, *, user: MetaUser | None, status: MetaUserConnectionStatus, ad_accounts: list[MetaUserConnectionAdAccount], ) -> MetaUserConnectionResult: """Make connection result.""" return MetaUserConnectionResult( user=user, status=status, required_scopes=self.settings.meta_user_required_scopes, ad_accounts=ad_accounts, ) @staticmethod def _get_user_and_user_id( user_id_or_user_connection: str | MetaUserConnection, / ) -> tuple[MetaUser | None, str]: """Get user and user id from user id or user connection.""" if isinstance(user_id_or_user_connection, MetaUserConnection): return ( MetaUser( id=user_id_or_user_connection.user_id, name=user_id_or_user_connection.user_name, picture=user_id_or_user_connection.user_picture, granted_scopes=[], ), user_id_or_user_connection.user_id, ) return None, user_id_or_user_connection def delete_user_ad_account( self, identity_id: str, ad_account: MetaAdAccount ) -> MetaUserConnection | None: user_connection = self.get_user_connection(identity_id) if user_connection: user_connection.remove_ad_account(ad_account) if not user_connection.ad_accounts: self.disconnect_user(user_connection) self.delete_user_connection(user_connection) return user_connection return None