import hashlib import io import logging from collections.abc import Iterator from dataclasses import dataclass import pandas as pd from app.adapters.aws.kms import KMSClient from app.adapters.ows_dmp import ( AudienceShare, AudienceSharePlatform, OwsDmpClient, TikTokAudienceShare, ) from app.adapters.tiktok import TikTokClient from app.exceptions import HandlerError from app.types import ProcessedFans from .base import AudienceShareStrategy logger = logging.getLogger(__name__) @dataclass(kw_only=True) class TikTokAudienceShareStrategy(AudienceShareStrategy): ows_dmp_client: OwsDmpClient kms_client: KMSClient tiktok_client: TikTokClient read_chunksize = 10000 max_upload_file_size = 150 * 1024 * 1024 platform = AudienceSharePlatform.TIKTOK def _upload_file( self, chunk: pd.DataFrame, user_access_token: str, audience_share: AudienceShare, share_data: TikTokAudienceShare, ) -> str: chunk_csv = io.BytesIO() chunk.to_csv(chunk_csv, index=False) chunk_csv.seek(0) file_signature = hashlib.md5(chunk_csv.getvalue()).hexdigest() chunk_csv.seek(0) audience_file = self.tiktok_client.upload_audience_file( access_token=user_access_token, ad_account_external_id=share_data.ad_account_external_id, file=chunk_csv.getvalue(), file_signature=file_signature, ) logger.info( "Uploaded custom audience file path", extra={ "audience_share_id": audience_share.id, "audience_platform": audience_share.platform, "ad_account_external_id": share_data.ad_account_external_id, "file_path": audience_file.file_path, }, ) return audience_file.file_path @staticmethod def _calculate_df_size_in_csv(df: pd.DataFrame) -> int: """ Returns the actual size of the data when saved in CSV. Each SHA256 hash size in CSV is 64 bytes. The comma size is 1 byte, the same as a newline. """ return int(df.count().sum() * 64 + len(df.columns) * len(df)) def share( self, audience_share: AudienceShare, file_buffer: io.BufferedRandom ) -> Iterator[ProcessedFans]: """Share Tiktok audience and yield ProcessedFans.""" logger.info( "Fetching TikTok Audience Sharing information.", extra={ "audience_share_id": audience_share.id, "audience_platform": audience_share.platform, }, ) try: share_data = self.ows_dmp_client.get_tiktok_audience_share( audience_share.id ) except Exception as exc: raise HandlerError( f"Failed to get TikTok Audience Sharing data `{audience_share.id}`.", code="GET_TIKTOK_AUDIENCE_SHARE_ERROR", extra={ "audience_share_id": audience_share.id, "audience_platform": audience_share.platform, }, ) from exc logger.info( "Decrypting TikTok Audience user access token.", extra={ "audience_share_id": audience_share.id, "audience_platform": audience_share.platform, "ad_account_external_id": share_data.ad_account_external_id, }, ) try: user_access_token = self.kms_client.decrypt( share_data.user_access_token, context={"field": "token"} ) except Exception as exc: raise HandlerError( "Failed to decrypt TikTok Audience User access token.", code="DECRYPT_USER_ACCESS_TOKEN_ERROR", extra={ "audience_share_id": audience_share.id, "audience_platform": audience_share.platform, "ad_account_external_id": share_data.ad_account_external_id, }, ) from exc # Upload Audience file logger.info( "Reading Audience file.", extra={ "audience_share_id": audience_share.id, "audience_platform": audience_share.platform, "ad_account_external_id": share_data.ad_account_external_id, }, ) uploaded_file_paths = [] reader = pd.read_csv(file_buffer, chunksize=self.read_chunksize) first_chunk = next(reader) header = first_chunk.columns chunk = first_chunk current_chunk_size = self._calculate_df_size_in_csv(chunk) for df in reader: df_size = self._calculate_df_size_in_csv(df) if current_chunk_size + df_size > self.max_upload_file_size: uploaded_file_paths.append( self._upload_file( chunk, user_access_token, audience_share, share_data ) ) chunk = pd.DataFrame(columns=header) current_chunk_size = 0 chunk = pd.concat([chunk, df]) current_chunk_size += df_size if not chunk.empty: uploaded_file_paths.append( self._upload_file(chunk, user_access_token, audience_share, share_data) ) custom_audience = self.tiktok_client.create_custom_audience( access_token=user_access_token, ad_account_external_id=share_data.ad_account_external_id, audience_name=share_data.audience_name, file_paths=uploaded_file_paths, ) try: self.ows_dmp_client.update_tiktok_audience_external_id( audience_id=share_data.audience_id, external_id=custom_audience.custom_audience_id, ) except Exception as exc: raise HandlerError( f"Failed to update TikTok Audience external_id `{share_data.audience_id}`.", code="UPDATE_TIKTOK_AUDIENCE_ERROR", extra={ "audience_id": share_data.audience_id, "audience_external_id": custom_audience.custom_audience_id, }, ) from exc yield ProcessedFans(processed_fans=share_data.fans_count, is_last=True)