import logging from apollo_utils.service.exceptions import BadRequest, NotFound from collections import defaultdict from flask import Response from flask import current_app as app from flask import jsonify, redirect, request from flask.views import MethodView from flask_apispec import MethodResource, doc, marshal_with, use_kwargs from flask_sqlalchemy_replication import ReplicaType, set_replica from http import HTTPStatus from itertools import zip_longest from sqlalchemy import exc, func from urllib import parse from apollo_main_db.apollo.models import ApolloMarketDetail, ApolloRecentSearch, ApolloUserOnboarding, \ ApolloUserVersionCarousel, Market from src.cache.constants import CacheMode from src.constants import core as core_consts from src.constants.core import VENDOR_APPLE, VENDOR_SPOTIFY, IdType, VendorType from src.constants.http_status import HTTP_200_OK, HTTP_201_CREATED, HTTP_202_ACCEPTED, HTTP_401_UNAUTHORIZED from src.constants.include import HHLatestDateInclude, HHTrackInclude from src.db import values from src.db.base import session from src.legacy.apollo_api import serializers from src.legacy.apollo_api.constants import LinkSourcePage, NPSResultStatus, TwilioMessageStatus from src.legacy.apollo_api.exceptions import AlreadyAddedError, AlreadyRemovedError, GTPNotFound, \ OnboardingIntegrityError, VersionCarouselIntegrityError from src.legacy.apollo_api.serializers import OnBoardingOutputSchema from src.legacy.apollo_api.util import common, gtp, hothits, isrc_relation from src.legacy.apollo_api.util.common import get_apollo_recent_search_filters, get_user_viewed_carousel_data, \ get_user_viewed_carousel_fields_dict, get_version_carousel from src.legacy.apollo_api.util.isrc_relation import get_isrc_to_id_map_for_isrc from src.legacy.apollo_api.util.nps_survey import get_active_user_survey, get_nps_result_records, get_survey_results, \ save_or_update_result_record from src.legacy.apple_music.vendor import AppleMusicPlaylists from src.legacy.auth.management_api import Auth0ManagementAPI from src.legacy.core.clients import clients from src.legacy.core.pagination import paginate from src.legacy.core.serializers import EmptySchema from src.legacy.core.sony_check import albums_is_sony, check_is_sony from src.legacy.core.util import get_client_onboarding_kwargs, get_request_json, multikeysort from src.legacy.redis_db import keys from src.legacy.redis_db.decorators import cache_value from src.legacy.redis_db.util import CacheModeManager, delete_cached_response from src.legacy.spotify.constants import SPOTIFY_PLAYLIST_URI_PREFIX, SPOTIFY_URI_PREFIX from src.legacy.spotify.vendor import SpotifyPlaylists from src.swagger import BooleanTemplate, DictTemplate, IntegerTemplate, ListTemplate, StringTemplate from src.utils import auth as auth_util logger = logging.getLogger("onboarding") @doc(tags=["compared tracks"]) class ComparedTracksView(MethodResource): """API for managing saved state of track comparison page. OUTDATED: the endpoint is being migrated to gate-api, use new version. """ @doc(description="Get list of user`s tracks added to comparison") @use_kwargs(serializers.ComparisonTracksQuerySchema, location="query") @marshal_with(serializers.ComparisonTracksGetOutputSchema(many=True), code=200, description="Success", apply=False) @marshal_with(None, code=401, description="Authentication failed") def get(self, **data): result = common.get_comparison_tracks(auth_util.get_user().user_id, data["market"]) return jsonify(result) @doc(description="Add track to comparison") @use_kwargs(serializers.ComparisonTracksQuerySchema, location="query") @use_kwargs( serializers.ComparisonTracksPostInputSchema, location="json", description="Provide one and only one ID of apple_id, spotify_id.", ) @marshal_with(serializers.ComparisonTracksPostOutputSchema(), code=201, description="Created", apply=False) @marshal_with(None, code=400, description="Bad request") @marshal_with(None, code=401, description="Authentication failed") @marshal_with(None, code=404, description="Not Found. Non existing id") @marshal_with(None, code=409, description="Conflict, Track is already in comparison") def post(self, **data): """ NOTE: Works only with Spotify IDs right now as comparison page search uses Spotify API only. Need to understand why we look for other vendor IDs and if we need to do is sony check for apple tracks by Spotify ID received from DB by ISRC. """ user_id = auth_util.get_user().user_id result = common.add_track_to_comparison(user_id, data) response = jsonify(result) response.status_code = HTTP_201_CREATED return response @doc("Delete a track from comparison") @use_kwargs( serializers.ComparisonTracksDeleteInputSchema, location="json", description="Provide one and only one ID of apple_id, spotify_id.", ) @marshal_with(None, code=200) @marshal_with(None, code=400, description="Bad request") @marshal_with(None, code=401, description="Authentication failed") def delete(self, **data): common.delete_comparison_track(auth_util.get_user().user_id, data) return jsonify({"status": "OK"}) @doc(tags=["onboarding"]) class UserOnboardingView(MethodResource): @doc(description="Retrieve fact of user onboarding") @use_kwargs(serializers.OnBoardingInputSchema, location="query") @marshal_with(OnBoardingOutputSchema, code=200, description="Success") @marshal_with(None, code=400, description="Bad request") @marshal_with(None, code=401, description="Authentication failed") @set_replica(ReplicaType.MASTER) def get(self, **data): """Retrieve fact of user onboarding.""" fields_dict = get_client_onboarding_kwargs(data["client_type"], auth_util.get_user().user_id) user_boarded = session.query(ApolloUserOnboarding.created_at).filter_by(**fields_dict).first() if user_boarded: return jsonify({"user_onboarding": True, "created_at": user_boarded.created_at}) return jsonify({"user_onboarding": False}) @doc(description="Log user onboarding fact") @use_kwargs(serializers.OnBoardingInputSchema, location="json") @marshal_with(OnBoardingOutputSchema, code=200, description="Success") @marshal_with(None, code=400, description="Bad request") @marshal_with(None, code=401, description="Authentication failed") def post(self, **data): """This method return fact of first user login.""" client_type = data["client_type"] user_id = auth_util.get_user().user_id user_query = session.query(ApolloUserOnboarding).filter_by(user_id=user_id) user_boarded = user_query.first() if not user_boarded: fields_dict = get_client_onboarding_kwargs(client_type, user_id, create=True) user_boarded = ApolloUserOnboarding(**fields_dict) session.add(user_boarded) else: fields_dict = get_client_onboarding_kwargs(client_type, user_id) del fields_dict["user_id"] user_query.update(fields_dict) try: session.commit() except exc.IntegrityError: # We are doing this to handle race condition on insert to db for the case when the front sends a batch # of identical requests by mistake instead of one which in some cases cause an IntegrityError # Solved in AG-8374 - Add extra layer of validation to avoid IntegrityError # This logic can be removed after front solves AG-8412 - Redundant queries session.rollback() logger.warning( "Batch of identical requests was detected which could cause an IntegrityError for \n" f"\nUser_id: <{user_id}>" f"\nClient_type: {client_type}." ) raise OnboardingIntegrityError return jsonify({"user_onboarding": True, "created_at": user_boarded.created_at}) class VisitedTracksView(MethodView): def get(self): """ OUTDATED: the endpoint is being migrated to gate-api, use new version. Retrieve list of most visited tracks for a user. This method return array of most visited track ordered by number of visits. [{"artist_name":, "id":, "name":, "vendor":, "visit_count":}, {}]. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ data = serializers.VisitedTracksSchema().load_from_request() return jsonify(common.get_most_visited_tracks(auth_util.get_user().user_id, data["market"])) def post(self): """Log track page visit. Each track page visit is logged. For performance and security reasons events are not written to the database immediately. They are written to SQS first. An event triggered lambda process SQS messages and inserts log entries to the database in batch. Returns: HTTP 202: Request is accepted. Data added to the buffer and is waiting for processing. Raises: HTTP 400: If request data is invalid or SQS error. HTTP 401: If authentication failed. """ data = get_request_json(serializers.TrackIdInput()) if not common.add_visited_track(auth_util.get_user().user_id, **data): raise BadRequest("Server failed to process some requests of logging track page visits.", "visited_tracks") return jsonify({"status": "OK"}), HTTP_202_ACCEPTED def update_starred_track_detailed_page(data: list, extra_data: dict, *args, **kwargs) -> list: """Add details to starred tracks page and dump it using output schema. Args: data: Page items. extra_data: Additional data from base function. Returns: Updated page. """ output_schema = serializers.StarredTrackOutputDetailedSchema() market, add_related_id = kwargs["market"], kwargs["related"] vendor_to_page_keys = common.separate_vendor_keys(data) sony_ids = check_is_sony( vendor_to_page_keys[VENDOR_SPOTIFY].ids_list, id_type=IdType.ID, vendor=VendorType.SPOTIFY, market=market ) isrc_to_related = dict() if add_related_id: vendor_to_related_keys = common.get_related_search_keys(vendor_to_page_keys, extra_data) isrc_to_related = common.get_related_vendors_data(vendor_to_related_keys, market) output_schema.context = {"sony_ids": sony_ids, "extra_data": extra_data, "related": isrc_to_related} return output_schema.dump(data, many=True) @doc("Endpoint to get user's starred tracks with additional data.") class StarredTracksDetailedView(MethodResource): @use_kwargs(serializers.StarredTrackPaginatedSchema, location="query") @marshal_with( serializers.StarredTrackOutputDetailedPaginatedSchema, code=200, description="User's starred tracks with additional data.", apply=False, ) @marshal_with(None, code=401, description="Authentication failed") @set_replica(ReplicaType.MASTER) @paginate(full_data=True, extra_data=True, update_page=update_starred_track_detailed_page) def get(self, **data): """Retrieve list of starred tracks for a user. This method return array of starred track IDs. [{"id":, "vendor":, "isrc":}]. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ market = data["market"] vendors = data["vendors"] base_query = common.get_starred_tracks_query(auth_util.get_user().user_id) vendor_to_keys = common.separate_vendor_keys(base_query) extra_data = common.get_vendor_extra_starred_data( vendor_to_keys[VENDOR_SPOTIFY], vendor_to_keys[VENDOR_APPLE], market ) names_subquery = common.get_names_subquery(extra_data) starred_isrc_list = [t.isrc for t in base_query] start_date, end_date = clients.dsp.get_streams_latest_date_range() isrc_to_streams = clients.dsp.get_isrc_to_streams_map(starred_isrc_list, vendors, start_date, end_date, market) streams_subquery = common.get_streams_subquery(isrc_to_streams) result_query = common.get_starred_tracks_by_streams_and_name_query(base_query, streams_subquery, names_subquery) return result_query, extra_data class StarredTracksCheckView(MethodView): def get(self): data = serializers.StarredTracksCheckSchema().load_from_request() isrc_list = data["isrc"] user_id = auth_util.get_user().user_id query = common.get_starred_tracks_query(user_id, isrc_list=isrc_list) saved_isrc_list = [q.isrc for q in query] result_data = {isrc: isrc in saved_isrc_list for isrc in isrc_list} return jsonify(result_data) class StarredTracksView(MethodView): def get(self): """Retrieve list of starred tracks (uri and isrc) for a user.""" query = common.get_starred_tracks_query(auth_util.get_user().user_id) result = serializers.StarredTrackOutputSchema(many=True).dump(query) return jsonify(result) def post(self): """Add starred tracks. Add starred tracks for the current user. Returns: HTTP 201: Starred tracks were added. Raises: HTTP 400: If request data is invalid. HTTP 401: If authentication failed. """ data = get_request_json(serializers.StarredTrackPostSchema()) isrc = data["isrc"] user_id = auth_util.get_user().user_id common.add_starred_track(user_id, data["uri"], isrc) count = common.get_starred_tracks_query(user_id).count() return ( jsonify( {"status": "OK", "count": count, "code": "starring.success", "detail": "Track successfully starred"} ), HTTP_201_CREATED, ) def delete(self): """Delete starred track. Delete starred tracks for the current user. Returns: HTTP 200: Starred tracks were removed. Raises: HTTP 400: If request data is invalid. HTTP 401: If authentication failed. """ data = serializers.StarredTrackDeleteSchema().load_from_request() user_id = auth_util.get_user().user_id common.del_starred_track(user_id, data["isrc"]) count = common.get_starred_tracks_query(user_id).count() return ( jsonify( {"status": "OK", "count": count, "code": "starring.success", "detail": "Track successfully unstarred"} ), HTTP_200_OK, ) @doc("V1 View for isrc relations.") class IsrcRelationViewV1(MethodResource): @use_kwargs(serializers.IsrcRelationGetSchema, location="query") @marshal_with( DictTemplate( ListTemplate(StringTemplate()), example={"GBARL1300107": ["GBARL1300522", "USSM12005545"], "USQX92004785": ["QZMEN2099087"]}, ), code=200, description="Get all isrc relations or relations by specific isrc.", apply=False, ) @marshal_with(None, code=401, description="Authentication failed.") @set_replica(ReplicaType.MASTER) def get(self, isrc: str = None, **data): main_to_related = defaultdict(list) for relation in isrc_relation.get_relation_query( main_isrc=isrc, related_isrc=isrc and [isrc] or None, union=True ): main_to_related[relation.main_isrc].append(relation.isrc) if not main_to_related: return {} return jsonify(main_to_related) @use_kwargs( serializers.IsrcRelationBaseSchema, location="json", description="Add relation between main isrc and related.", ) @marshal_with(serializers.IsrcRelationBaseSchema, code=201, description="Add isrc relation.") @marshal_with(None, code=401, description="Authentication failed") def post(self, **data): main_isrc, new_isrc = data["main_isrc"], data["related"] existing_isrc = [r.isrc for r in isrc_relation.get_relation_query(main_isrc=main_isrc, related_isrc=new_isrc)] isrc_to_add = list(set(new_isrc) - set(existing_isrc)) if existing_isrc else new_isrc if not isrc_to_add: raise AlreadyAddedError isrc_relation.add_relation(main_isrc=main_isrc, related_isrc=isrc_to_add) return jsonify({"main_isrc": main_isrc, "related": isrc_to_add}) @marshal_with(EmptySchema, code=200, description="Delete isrc relation by main isrc or main and related isrc pair.") @marshal_with(None, code=401, description="Authentication failed") def delete(self, isrc: str, related_isrc: str = None): rows_affected = isrc_relation.delete_relation(isrc, related_isrc) if not rows_affected: raise AlreadyRemovedError class IsrcToIdRelationView(MethodResource): @use_kwargs(serializers.IsrcToIdRelation.Request, location="query") @marshal_with( DictTemplate(StringTemplate(), example={"GBARL2000346": "1var6aw8gXxihraYsvJEA8"}), code=200, description="Get all isrc to id relations or relations by specific isrc.", apply=False, ) @marshal_with(None, code=401, description="Authentication failed.") def get(self, **data): isrc_list = data["isrc_list"] result = get_isrc_to_id_map_for_isrc(isrc_list=isrc_list) return jsonify(result) class CheckMarketIsSupportedView(MethodResource): @use_kwargs(serializers.CheckMarket.Request, location="query") @marshal_with(None, code=401, description="Authentication failed.") def get(self, **data): market, vendor, by_isrc = data.get("market"), data.get("vendor"), data.get("by_isrc") result = clients.vendor.check_market_is_supported(market, vendor, by_isrc) return result class JobCategoriesView(MethodView): def get(self): """Retrieve list of job categories. Returns: HTTP 200: Ok, return job categories. Raises: HTTP 401: If authentication failed. """ job_categories_dict = dict(core_consts.JOB_CHOICES) job_categories = [] for k, v in job_categories_dict.items(): job_categories.append({"name": k, "id": v}) return jsonify(job_categories) def post(self): """Save job category to Auth0 user metadata field. Returns: HTTP 200: returned 'OK' status. Raises: HTTP 401: If authentication failed. """ auth_management = Auth0ManagementAPI() user_id = auth_util.get_user().user_id data = get_request_json(serializers.SetJobCategory()) category_id = data["category"] category_name = common.get_job_category_name_by_id(category_id) job_category = {"id": category_id, "name": category_name} new_meta_data = {"apollo_go_job_category": job_category} auth_management.update_user_metadata(user_id, new_meta_data) return jsonify({"status": "OK"}) @doc(description="Return application markets values form tblApolloMarket table.") class MarketsView(MethodView): """View for markets instances.""" @use_kwargs(serializers.MarketInputSchema, location="query") @marshal_with(serializers.MarketSchema, code=200, description="Markets objects list.", apply=False) @marshal_with(None, code=401, description="Authentication failed") def get(self, **data): """Retrieve list of available markets. Returns: HTTP 200: Ok, return list of markets. Raises: HTTP 401: If authentication failed. """ additional_sorting = data.pop("additional_sorting") markets_data = common.get_markets_data(**data) if additional_sorting: query = session.query(ApolloMarketDetail).order_by(ApolloMarketDetail.rank) primary_codes_flat = [row.code for row in query] primary_codes_dict = {el["code"]: el for el in markets_data if el["code"] in primary_codes_flat} primary_codes = [primary_codes_dict[code] for code in primary_codes_flat] markets_data_cleaned = [el for el in markets_data if el["code"] not in primary_codes_flat] markets_data = primary_codes + markets_data_cleaned return jsonify(markets_data) @doc(description="Get is sony track or not. 'spotify_ids' is only available key for now.") class TracksIsSonyView(MethodResource): """Proxy for consumer analytics to get tracks release dates.""" @use_kwargs(serializers.TracksIsSonySchema, location="query") @marshal_with( DictTemplate(BooleanTemplate(), example={"7DFNE7NO0raLIUbgzY2rzm": True, "2cbic3TiUENlJX91y67ARR": False}), code=200, description="Key-values pairs of Sony track IDs/ISRC and is_sony flags.", apply=False, ) @marshal_with(None, code=401, description="Authentication failed") def get(self, **data): """Get tracks release dates from consumer analytics or cache. This method return dict of release dates if exist. {%isrc%: %release date%, }. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ spotify_ids = data.get("spotify_ids") apple_ids = data.get("apple_ids") isrc_list = data.get("isrc") market = data.get("market") sony_items = ( check_is_sony(spotify_ids, id_type=IdType.ID, vendor=VendorType.SPOTIFY, market=market) + check_is_sony(apple_ids, id_type=IdType.ID, vendor=VendorType.APPLE, market=market) + check_is_sony(isrc_list, id_type=IdType.ISRC, vendor=VendorType.APPLE, market=market) ) result = {} if spotify_ids: result.update({_id: _id in sony_items for _id in spotify_ids}) if isrc_list: result.update({isrc: isrc in sony_items for isrc in isrc_list}) if apple_ids: result.update({_id: _id in sony_items for _id in apple_ids}) return jsonify(result) @doc(description="Get if albums are Sony or not by UPC.") class AlbumsIsSonyView(MethodResource): """Get if album is Sony or not by UPC.""" @use_kwargs(serializers.AlbumsIsSonySchema, location="query") @marshal_with(ListTemplate(StringTemplate()), code=200, description="List of Sony album's UPC.", apply=False) @marshal_with(None, code=401, description="Authentication failed") def get(self, **data): """Check UPC list is Sony or not. This method returns list of Sony album UPC. [%SONY_UPC1%, %SONY_UPC2%]. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ sony_upc_list = albums_is_sony(data["upc_list"], data.get("market")) return jsonify(sony_upc_list) class MobileVersionView(MethodView): """View to retrieve latest saved in DB mobile apps versions.""" def get(self): """Get latest versions data record and serialize it. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ data = serializers.MobileVersionsParams().load_from_request() os = data["os"] version_data = common.get_latest_mobile_version(os) if version_data: copies_data = common.get_os_version_copies(version_data["version"], os) version_data["changelog"] = copies_data.get("changelog") return jsonify(version_data) class TwilioMessagingStatusCallbackView(MethodView): """Callback for twilio messaging status.""" def post(self): logger = logging.getLogger("twilio.messaging.status") data = dict(parse.parse_qsl(request.get_data(as_text=True))) errors = serializers.TwilioMessagingStatusCallbackSchema().validate(data) if errors or app.config["TWILIO_ACCOUNT_SID"] != data.get("AccountSid"): logger.error("Twilio messaging error", extra=data) raise BadRequest() message_status = data.get("MessageStatus") if message_status not in ( TwilioMessageStatus.ACCEPTED, TwilioMessageStatus.QUEUED, TwilioMessageStatus.SENT, TwilioMessageStatus.DELIVERED, ): logger.error(f"Twilio messaging status: {message_status}", extra=data) return jsonify([]) @doc("Application markets data list. Moved from .NET '/markets' e-point") class ApplicationMarkets(MethodResource): @marshal_with(serializers.ApplicationMarketSchema(many=True), code=200, description="List markets data.") @marshal_with(None, code=401, description="Authentication failed") @set_replica(ReplicaType.MASTER) def get(self): """Get application markets. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ @cache_value(keys.APPLICATION_MARKETS, keys.APPLICATION_MARKETS_TTL) def _get(): return serializers.ApplicationMarketSchema(many=True).dump(session.query(Market).all()) return jsonify(_get()) def update(self, data: dict): result = common.save_market(data) delete_cached_response(keys.APPLICATION_MARKETS) return result @use_kwargs(serializers.ApplicationMarketSchema, location="json") @marshal_with(serializers.ApplicationMarketSchema(), code=200, description="Create a new market.") @marshal_with(None, code=401, description="Authentication failed") @set_replica(ReplicaType.MASTER) def post(self, **data): return self.update(data) @use_kwargs(serializers.ApplicationMarketSchema, location="json") @marshal_with(serializers.ApplicationMarketSchema(), code=200, description="Update market data.") @marshal_with(None, code=401, description="Authentication failed") @set_replica(ReplicaType.MASTER) def put(self, market_id: int, **data): data["market_id"] = market_id return self.update(data) @marshal_with(EmptySchema, code=200, description="Delete market.") @marshal_with(None, code=401, description="Authentication failed") @set_replica(ReplicaType.MASTER) def delete(self, market_id: int): rows_affected = common.delete_market(market_id) if not rows_affected: raise BadRequest("Market does not exist") delete_cached_response(keys.APPLICATION_MARKETS) @doc("GTP track list for specific date") class GTPTracksHistoryView(MethodResource): @use_kwargs(serializers.GTPTracksHistoryInputSchema, location="query") @marshal_with(serializers.GTPTracksHistoryOutputSchema(), code=HTTP_200_OK, description="GTP tracklist.") @marshal_with(None, code=HTTP_401_UNAUTHORIZED, description="Authentication failed") def get(self, **data): """Get GTP tracklist. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. HTTP 404: If GTP history for specified date is not found. """ history_records = gtp.get_gtp_history_records(**data) if not history_records: raise GTPNotFound() return jsonify( { "tracks": [{"id": r.track_id, "isrc": r.isrc} for r in history_records if r.track_id], "gtp_date": next(r.date for r in history_records), } ) @doc("Track's weeks count in the GTP playlists") class GTPTracksWeeksView(MethodResource): @use_kwargs(serializers.GTPTracksDataInputSchema, location="query") @marshal_with(DictTemplate(IntegerTemplate()), code=HTTP_200_OK, description="ISRC to weeks count mapping.") @marshal_with(None, code=HTTP_401_UNAUTHORIZED, description="Authentication failed") def get(self, **data): """Get tracks weeks count in GTP. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ return jsonify(gtp.get_gtp_weeks(**data)) @doc("Check tracks re-entry in GTP.") class GTPTracksReentryView(MethodResource): @use_kwargs(serializers.GTPTracksDataInputSchema, location="query") @marshal_with(ListTemplate(StringTemplate()), code=HTTP_200_OK, description="Re-entered ISRC list.") @marshal_with(None, code=HTTP_401_UNAUTHORIZED, description="Authentication failed") def get(self, **data): """Get GTP tracks re-entry. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ return jsonify(gtp.check_re_entry(**data)) @doc("Get latest available hot hits date.") class GTPHotHitsLatestDateView(MethodResource): @use_kwargs(serializers.GTPHotHitsLatestDate.Request, location="query") @marshal_with(StringTemplate("date"), code=HTTP_200_OK, description="Latest hot hits date.") @marshal_with(None, code=HTTP_401_UNAUTHORIZED, description="Authentication failed") def get(self, **data): """Get latest hot hits date. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ return jsonify(hothits.get_latest_hot_hits_date(with_time=(HHLatestDateInclude.TIME.value in data["include"]))) @doc("Tracks entries to hot hits playlists") class TracksHotHits(MethodResource): @use_kwargs(serializers.TracksHotHits.Request, location="query") @marshal_with(serializers.TracksHotHits.Response, code=200, description="Tracks hot hits.") @marshal_with(None, code=401, description="Authentication failed") def get(self, **data): """Get tracks hot hits. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ result = hothits.get_hot_hits_multiple_tracks_in_playlists(**data) return jsonify(serializers.TracksHotHits.Response().dump(result)) @doc("Version carousel data.") class VersionCarouselView(MethodResource): @use_kwargs(serializers.VersionCarouselGetSchema, location="query") @marshal_with(None, code=401, description="Authentication failed") @marshal_with(None, code=400, description="Bad request") @set_replica(ReplicaType.MASTER) def get(self, **data): """Get GTP charts positions. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. HTTP 400: If smth goes wrong. """ version, os = data["version"], data["os"] with CacheModeManager(data.get("_cache_mode")): carousel_version = get_version_carousel(os, version) if carousel_version is None: raise BadRequest(f"Carousel for OS <{os}> and version <{version}> does not exist!") user_id = auth_util.get_user().user_id user_viewed_carousel = get_user_viewed_carousel_data(user_id, carousel_version["id"]) return jsonify({"viewed": bool(user_viewed_carousel), "slides": carousel_version["slides"]}) @use_kwargs(serializers.VersionCarouselPostSchema, location="json") @marshal_with(None, code=401, description="Authentication failed") @marshal_with(None, code=400, description="Bad request") @marshal_with(None, code=409, description="Conflict") @set_replica(ReplicaType.MASTER) def post(self, os: str, version: str): """Get GTP charts positions. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. HTTP 400: If smth goes wrong. """ with CacheModeManager(CacheMode.IGNORE.value): carousel_version = get_version_carousel(os, version) if carousel_version is None: raise BadRequest(f"Carousel for OS <{os}> and version <{version}> does not exist!") user_id = auth_util.get_user().user_id user_viewed_carousel = get_user_viewed_carousel_data(user_id, carousel_version["id"]) if user_viewed_carousel: raise VersionCarouselIntegrityError model_fields = get_user_viewed_carousel_fields_dict(user_id=user_id, carousel_id=carousel_version["id"]) session.add(ApolloUserVersionCarousel(**model_fields)) session.commit() return jsonify({"viewed": True, "os": os, "version": version}) class AppStoresLinks(MethodResource): decorators = [auth_util.no_authorize] def head(self): # Should declare HEAD request handler to prevent HEAD -> GET redirect from WSGI side. # reference http://blog.dscpl.com.au/2009/10/wsgi-issues-with-http-head-requests.html response = Response() return response @use_kwargs(serializers.AppLinksInputSchema, location="query") @marshal_with(None, code=400, description="Bad request") def get(self, **data): """ Redirects user to Apollo GO pages in Play Market or App Store and create an event for this in Amplitude. Returns: HTTP 301: Request is accepted. Redirect to Apollo GO page in store. Raises: HTTP 400: Input parameters validation fail. """ import amplitude amplitude_logger = amplitude.AmplitudeLogger(api_key=app.config["AMPLITUDE_CLIENT_ID"]) client, source_page = data["client"], data["source_page"] event_args = {"device_id": app.config["AMPLITUDE_DEVICE_ID"]} if source_page in (LinkSourcePage.TRACK_PAGE, LinkSourcePage.STARED_TRACK_PAGE): event_args.update( { "event_type": "QR code download", "event_properties": {"target_os": client, "source_page": LinkSourcePage.MAP.get(source_page)}, } ) else: event_args.update({"event_type": "mobile link download", "event_properties": {"target_os": client}}) amplitude_logger.log_event(amplitude_logger.create_event(**event_args)) return redirect(app.config["AGO_STORES_LINKS"][client], code=301) @doc("ElasticSearch playlists search view.") class PlaylistsSearchView(MethodResource): @use_kwargs(serializers.PlaylistsSearchInputSchema, location="query") @marshal_with(None, code=400, description="Bad request") @paginate(cache_key_template=keys.PLAYLISTS_SEARCH, full_data=True) def get(self, **data): """ Perform search for playlists data in ES. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. HTTP 400: Input parameters validation fail. """ vendors, query = data["vendors"], data["query"] image_size, items_size = data["image_size"], data["items_size"] result = [] if not query: return result if app.elasticsearch is None: raise ValueError("Elasticsearch connection string value is not set.") if not app.elasticsearch.ping(): raise ValueError("Elasticsearch connection error.") spotify_hits = ( SpotifyPlaylists().playlists_search_handler(query, items_size) if VENDOR_SPOTIFY in vendors else [] ) apple_hits = ( AppleMusicPlaylists().playlists_search_handler(query, items_size, image_size) if VENDOR_APPLE in vendors else [] ) for spotify_item, apple_item in zip_longest(spotify_hits, apple_hits): if spotify_item: result.append(spotify_item) if apple_item: result.append(apple_item) return result @doc("Tracks and playlists search history.") class SearchHistory(MethodResource): @use_kwargs(serializers.SearchHistoryInputSchema, location="query") @marshal_with(None, code=401, description="Authentication failed") @marshal_with(None, code=400, description="Bad request") @paginate(full_data=True, response_kwargs=True) def get(self, **data): """Get recent search history for tracks and playlists. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. HTTP 400: If smth goes wrong. """ # TODO this view and all related methods and data can be removed when AGO FE moves to search/recent/ endpoint user_id = auth_util.get_user().user_id type = data["type"] filters = get_apollo_recent_search_filters(user_id, type, data["vendors"]) query = ( session.query( ApolloRecentSearch.uri, func.max(ApolloRecentSearch.created_at).label("timestamp"), func.lower(ApolloRecentSearch.source).label("source"), ) .filter(*filters) .group_by(ApolloRecentSearch.user_id, ApolloRecentSearch.uri) ) items = [] for item in query: search_item = {**item._asdict(), "type": type} uri, source = search_item["uri"], search_item.pop("source") if source: vendor = VENDOR_SPOTIFY if source == VENDOR_SPOTIFY else VENDOR_APPLE else: vendor = VENDOR_SPOTIFY if uri.startswith("spotify:") else VENDOR_APPLE search_item["vendor"] = vendor search_item["id"] = uri.replace(SPOTIFY_URI_PREFIX, "").replace(SPOTIFY_PLAYLIST_URI_PREFIX, "") items.append(search_item) items = multikeysort(items, data["order_by"]) return items, dict(user_id=user_id) @doc("NPS Survey related endpoints - retrieving and results saving.") class NPSSurveyView(MethodResource): @marshal_with(serializers.NPSSurveyOutputSchema, code=200, description="NPS survey data retrieving endpoint.") @marshal_with(None, code=404, description="No active survey for this user.") @marshal_with(None, code=400, description="Bad request.") @set_replica(ReplicaType.MASTER) def get(self): """Returns active NPS survey data for user. Returns: HTTP 200: Request is accepted. Response successful. NPS survey data for selected user. Raises: HTTP 401: If authentication failed. HTTP 400: If smth goes wrong. HTTP 404: If survey does not exist for user. """ user_id = auth_util.get_user().user_id active_survey = get_active_user_survey(user_id) if not active_survey: raise NotFound("No active survey for this user.") user_result = get_survey_results(user_id, active_survey.id) if user_result and ( user_result.status == NPSResultStatus.FINISHED or user_result.skips_num > app.config["NPS_SURVEY_RETRY_COUNT"] ): raise NotFound("User has already taken part in the survey or decided to skip it.") return {"survey": active_survey, "user_response": user_result, "questions": active_survey.questions} @use_kwargs( serializers.NPSSurveyInputSchema, location="json", description="NPS survey user results saving validation schema.", ) @marshal_with(serializers.NPSSurveyOutputSchema, code=200, description="NPS survey data retrieving endpoint.") @marshal_with(None, code=400, description="Bad request.") def post(self, **data): """Create or update NPS survey results. Returns: HTTP 200: Request is accepted. Response successful. NPS survey result data for selected user. Raises: HTTP 401: If authentication failed. HTTP 400: If smth goes wrong. """ user_id = auth_util.get_user().user_id data["user_id"] = user_id active_survey = get_active_user_survey(user_id, survey_id=data["survey_id"]) if not active_survey: raise BadRequest("Survey is not active or does not exist.") user_result = get_survey_results(user_id, active_survey.id) if user_result and ( user_result.status == NPSResultStatus.FINISHED or user_result.skips_num > app.config["NPS_SURVEY_RETRY_COUNT"] ): raise BadRequest("User has already taken part in the survey or decided to skip it.") survey_result = save_or_update_result_record(data, user_result) return {"survey": active_survey, "user_response": survey_result, "questions": active_survey.questions} @doc("NPS Survey - results downloading endpoint.") class NPSSurveyResultsView(MethodResource): @marshal_with(None, code=404, description="Survey does not exists") @marshal_with(None, code=400, description="Bad request.") def get(self, survey_id: int): """Generates CSV output file with user answers for specified NPS survey. Args: survey_id (int): Survey identifier. Returns: HTTP 200: Request is accepted. Response successful. NPS survey results data in CSV format. """ import flask_excel as excel survey, survey_results = get_nps_result_records(survey_id) if not survey: raise NotFound("Survey does not exists.") if not survey_results: raise NotFound("No results for requested survey.") file_name = f"NPS survey ({survey.start_date} - {survey.end_date}) results" return excel.make_response_from_records(survey_results, "csv", file_name=file_name) @doc("Apollo key value storage.") class KeyValueStorage(MethodResource): @use_kwargs(serializers.KeyValueStorageInputSchema, location="query") @marshal_with(StringTemplate(), code=HTTPStatus.OK, description="Value.", apply=False) @marshal_with(None, code=HTTPStatus.UNAUTHORIZED, description="Authentication failed") @marshal_with(None, code=HTTPStatus.BAD_REQUEST, description="Bad request") @marshal_with(None, code=HTTPStatus.NOT_FOUND, description="Not found") def get(self, **data): """Get value from apollo key value storage. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: Authentication failed. HTTP 400: Incorrect input schema. HTTP 404: Not found. """ return values.get_value(data["key"]) @doc("Get track information") class Tracks(MethodResource): @use_kwargs(serializers.Tracks.Request, description="Get track_id(s) map to track information", location="query") @marshal_with( DictTemplate( DictTemplate(StringTemplate()), example={ "46IZ0fSY2mpAiktS3KOqds": {"id": "46IZ0fSY2mpAiktS3KOqds", "artist_name": "Adele", "sony_track": None} }, ), code=HTTPStatus.OK, apply=False, ) @marshal_with(None, code=HTTPStatus.BAD_REQUEST, description="Bad request") def get(self, **data): vendor = data["vendor"] track_ids = data["track_ids"] result = common.get_track_id_to_track_info(vendor=vendor, track_ids=track_ids) return result @doc("Track in Hot Hits playlists.") class HHTracksView(MethodResource): @use_kwargs(serializers.HHTracks.Request, location="query") @marshal_with( serializers.HHTracks.Response, code=HTTPStatus.OK, description="Hot Hits track playlists.", apply=False ) @marshal_with(None, code=HTTPStatus.UNAUTHORIZED, description="Authentication failed") @paginate( cache_key_template=keys.SPOTIFY_HH_TRACK, cache_ttl=keys.SPOTIFY_HH_TRACK_TTL, full_data=True, response_kwargs=True, ) def get(self, **data): """Get Hot Hits tracks. Returns: HTTP 200: Request is accepted. Response successful. Raises: HTTP 401: If authentication failed. """ isrc, region, include, order_by = data["isrc"], data["region"], data["include"], data["order_by"] ( items, region_list, region_data, average_position, top_10_total, ) = hothits.get_hot_hits_single_track_in_playlists( isrc, region_code=region, include_inactive_markets=(HHTrackInclude.INACTIVE_MARKETS.value in include) ) items = multikeysort(items, order_by) extra = { "positions_avg": average_position, "positions_top_total": top_10_total, "total": region_data.get("total", 0), } if HHTrackInclude.REGIONS.value in include: extra["regions"] = region_list return items, extra