import os import sys import traceback from datetime import datetime, timedelta from typing import Dict, Iterable, List, Set import boto3 import redis import sentry_sdk from apollo_notifications.constants import GLOBAL_MARKET, US_MARKET from apollo_notifications.decorators import loop, time_logger from apollo_notifications.main_db import session_scope from apollo_notifications.playlists import PushTrackInPlaylistSchema, StarredApplePlaylistEntryPushSchema from apollo_notifications.push_client.client import PushClient from apollo_notifications.push_client.data_classes import Push, PushData from apollo_notifications.push_client.exceptions import PushClientError from apollo_notifications.redis import ConstantKeyCache, lock from ddtrace import patch_all, tracer from sentry_sdk.utils import BadDsn from sqlalchemy.exc import SQLAlchemyError from client import ApplePlaylistClient from config import config from constants import SYNONYM_MARKETS from logger import logger from user_data_client import ApplePlaylistsAdditionsUDClient if os.environ.get("DATADOG_SERVICE_NAME"): patch_all() else: tracer.enabled = False try: if config.ENVIRONMENT != "local": sentry_sdk.init(dsn=config.SENTRY_DSN, environment=config.ENVIRONMENT) except BadDsn: pass redis_client = redis.StrictRedis(config.REDIS_HOST, config.REDIS_PORT) def get_query_market(market: str) -> str: """Return parsed market.""" # us is used instead global for real querying AG-4910 if market and market == GLOBAL_MARKET: return US_MARKET return market def get_message_markets_to_check(market: str) -> Iterable[str]: """Return list of markets that should be check for the passed one to detect message existence.""" # To determine whether this message was sent earlier, we are looking for an exact match of the parameters with # the data among the messages sent in a wide range of parameters. Market is one of these "identifying" parameters. # However, for apple playlists, there is # 1) AG-4910 - a requirement according to which we must request us data for the global market # 2) AG-3362 - a user should not receive actual duplicates (albeit nominally marked with different markets) of # messages when switching between us, global. # Therefore filtering by sent messages for us OR global should be carried out for both us AND global markets. if market in SYNONYM_MARKETS: return SYNONYM_MARKETS return [market] def process_starred_playlists_entries_by_market( count: int, market: str, today: str, yesterday: str, include_playlist_id_list: List[str], user_id_list: List[str], include_playlist_to_users_map: Dict[str, Set[str]], existing_messages: Set[str], notifications_client: ApplePlaylistClient, push_client: PushClient, users_map: Dict[str, int] = None, ): starred_playlist_additions_schema = StarredApplePlaylistEntryPushSchema( title=config.STARRED_PLAYLIST_ADDITION_TITLE, topic=config.STARRED_PLAYLIST_ADDITION_TOPIC, vendor=config.VENDOR, message_template=config.STARRED_PLAYLIST_ADDITION_MESSAGE_TEMPLATE, date=today, market=market, push_cls=Push, push_data_cls=PushData, users_map=users_map, ) starred_playlists_for_market = notifications_client.get_updated_playlists_by_date_query( market, today, filter_by_playlists=include_playlist_id_list ) logger.info(f"[starred_processing][{market}] updated playlists: {starred_playlists_for_market}") tracks_added_to_starred_playlists_query = notifications_client.get_added_tracks_query( today, yesterday, starred_playlists_for_market, starred_playlists_for_market, market, user_id_list ) logger.info(f"[starred_processing][{market}] added tracks: " f"{tracks_added_to_starred_playlists_query.all()}") filtered_tracks_added_to_starred_playlists_query = notifications_client.filter_added_tracks_query( tracks_added_to_starred_playlists_query, user_id_list ) logger.info( f"[starred_processing][{market}] filtered tracks: " f"{filtered_tracks_added_to_starred_playlists_query.all()}" ) starred_playlists_messages = notifications_client.get_push_messages( query=filtered_tracks_added_to_starred_playlists_query, push_date=today, markets=get_message_markets_to_check(market), # we don't want to send 'starred playlist' push if 'top playlist' (considering synonym markets logic) # push with the same parameters was already sent topics=[config.STARRED_PLAYLIST_ADDITION_TOPIC, config.TOPIC], push_schema=starred_playlist_additions_schema, existing_messages=existing_messages, include_playlist_id_to_users_map=include_playlist_to_users_map, ) logger.info(f"[starred_processing][{market}] messages: {starred_playlists_messages}") push_client.process_messages(starred_playlists_messages) logger.info( f"2.{count}.1 Created {len(starred_playlists_messages)} starred playlists messages " f"for {market} market." ) def process_top_playlists_entries_by_market( count: int, market: str, today: str, yesterday: str, excluded_playlist_id_list: List[str], user_id_list: List[str], exclude_playlist_to_users_map: Dict[str, Set[str]], existing_messages: Set[str], notifications_client: ApplePlaylistClient, push_client: PushClient, users_map: Dict[str, int] = None, ): top_playlist_additions_schema = PushTrackInPlaylistSchema( title=config.TITLE, topic=config.TOPIC, vendor=config.VENDOR, message_template=config.MESSAGE_TEMPLATE, date=today, market=market, push_cls=Push, push_data_cls=PushData, users_map=users_map, ) # us is used instead global for real querying AG-4910 query_market = get_query_market(market) top_playlist_ids_for_today = notifications_client.get_top_playlists_by_date_query( query_market, today, exclude=excluded_playlist_id_list ) logger.info(f"[top_processing][{market}] today tops: {top_playlist_ids_for_today}") top_playlist_ids_for_yesterday = notifications_client.get_top_playlists_by_date_query( query_market, yesterday, exclude=excluded_playlist_id_list ) logger.info(f"[top_processing][{market}] yesterday tops: {top_playlist_ids_for_yesterday}") tracks_added_to_top_playlists_query = notifications_client.get_added_tracks_query( today, yesterday, top_playlist_ids_for_today, top_playlist_ids_for_yesterday, query_market, user_id_list ) logger.info(f"[top_processing][{market}] added tracks: " f"{tracks_added_to_top_playlists_query.all()}") filtered_tracks_added_to_top_playlists_query = notifications_client.filter_added_tracks_query( tracks_added_to_top_playlists_query, user_id_list ) logger.info(f"[top_processing][{market}] filtered tracks: " f"{filtered_tracks_added_to_top_playlists_query.all()}") top_playlists_messages = notifications_client.get_push_messages( query=filtered_tracks_added_to_top_playlists_query, push_date=today, markets=get_message_markets_to_check(market), # we don't want to send 'top playlist' push if 'starred playlist' (considering synonym markets logic) # push with the same parameters was already sent topics=[config.TOPIC, config.STARRED_PLAYLIST_ADDITION_TOPIC], push_schema=top_playlist_additions_schema, existing_messages=existing_messages, exclude_playlist_id_to_users_map=exclude_playlist_to_users_map, ) logger.info(f"[top_processing][{market}] messages: {top_playlists_messages}") push_client.process_messages(top_playlists_messages) logger.info(f"2.{count}.2 Created {len(top_playlists_messages)} top playlists messages for {market} market.") @loop(logger, config) def job( notifications_client: ApplePlaylistClient, push_client: PushClient, user_data_client: ApplePlaylistsAdditionsUDClient, blacklist_cache: ConstantKeyCache, ): # '_job' is separated from 'job' to allow its testing without wrapping decorators return _job( notifications_client=notifications_client, push_client=push_client, user_data_client=user_data_client, blacklist_cache=blacklist_cache, ) def _job( notifications_client: ApplePlaylistClient, push_client: PushClient, user_data_client: ApplePlaylistsAdditionsUDClient, blacklist_cache: ConstantKeyCache, ): today = datetime.today().strftime("%Y-%m-%d") yesterday = (datetime.today().date() - timedelta(days=1)).strftime("%Y-%m-%d") logger.info(f"0. Started processing update for dates: {today} and {yesterday}") markets_to_users_map, users_map = time_logger(logger, "1.0._ Getting user settings")( user_data_client.parse_v1_mobile_settings )(config.VENDOR, with_account_id=True) users_list = list(users_map.keys()) logger.info( f"1.0. Got {len(users_list)} users to send starred and top playlist additions notifications for: " f"{markets_to_users_map}." ) market_to_starred_playlists_to_users_map, all_starred_playlists_id_set = user_data_client.parse_starred_playlists( config.VENDOR, users_list ) logger.info(f"1.0.1 Got market_to_starred_playlists_to_users_map: {market_to_starred_playlists_to_users_map}") with session_scope(): blacklisted_ids = set(blacklist_cache(notifications_client.get_blacklisted_ids)()) logger.info(f"1.1. Got {len(blacklisted_ids)} blacklisted playlists: {blacklisted_ids}.") all_starred_playlists_id_set = all_starred_playlists_id_set - blacklisted_ids top_markets = ( notifications_client.get_top_markets(today, filter_by_markets=markets_to_users_map.keys()) if markets_to_users_map else set() ) logger.info(f"1.2. Got {len(top_markets)} top markets: {top_markets}") starred_playlists_markets = ( notifications_client.get_updated_tracklist_markets( target_date=today, filter_by_playlists=all_starred_playlists_id_set, filter_by_markets=set(market_to_starred_playlists_to_users_map.keys()), ) if market_to_starred_playlists_to_users_map else set() ) logger.info( f"1.3. Got {len(starred_playlists_markets)} starred playlists markets: {starred_playlists_markets}." ) markets = top_markets | starred_playlists_markets existing_messages = time_logger(logger, "1.4._ Getting existing messages")( notifications_client.get_existing_push_messages )(target_push_date=today, topics=[config.TOPIC, config.STARRED_PLAYLIST_ADDITION_TOPIC]) logger.info( f"1.4. Got {len(existing_messages)} existing push messages for " f"{today} + {config.TOPIC}/{config.STARRED_PLAYLIST_ADDITION_TOPIC} + {config.VENDOR}: " f"{existing_messages}" ) for i, market in enumerate(markets): logger.info(f"2.{i}.1. Started processing for {market} market.") try: with session_scope() as session: push_client.set_db_session(session) starred_playlists_to_users_map = {} if market in starred_playlists_markets: starred_playlists_to_users_map = market_to_starred_playlists_to_users_map[market] process_starred_playlists_entries_by_market( count=i, market=market, today=today, yesterday=yesterday, include_playlist_id_list=list(set(starred_playlists_to_users_map.keys()) - blacklisted_ids), user_id_list=users_list, # we do not filter starred playlists messages by settings markets include_playlist_to_users_map=starred_playlists_to_users_map, existing_messages=existing_messages, notifications_client=notifications_client, push_client=push_client, users_map=users_map, ) if market in markets_to_users_map: process_top_playlists_entries_by_market( count=i, market=market, today=today, yesterday=yesterday, excluded_playlist_id_list=blacklisted_ids, user_id_list=list(markets_to_users_map[market]), # we do filter top playlists messages # by settings markets users_map=users_map, exclude_playlist_to_users_map=starred_playlists_to_users_map, # do not send for top if push # was already sent for starred existing_messages=existing_messages, notifications_client=notifications_client, push_client=push_client, ) except (SQLAlchemyError, PushClientError) as ex: sentry_sdk.capture_exception(ex) logger.error(traceback.format_exception(*sys.exc_info()) + traceback.format_stack()) finally: markets_to_users_map.pop(market, None) @time_logger(logger, config.APP_NAME) @lock(logger, config, redis_client, sentry=sentry_sdk) def handler(): """Save push messages to db and send them to queue.""" sqs_client = boto3.client("sqs") user_data_client = ApplePlaylistsAdditionsUDClient(config, logger) notifications_client = ApplePlaylistClient(config) push_client = PushClient(logger=logger, sqs_client=sqs_client, config=config) blacklist_cache = ConstantKeyCache(redis_client, config.BLACKLIST_KEY, config.BLACKLIST_TTL) job(notifications_client, push_client, user_data_client, blacklist_cache) if __name__ == "__main__": handler()