"""Logic for product detail endpoints.""" import itertools from typing import Any, Mapping from ddtrace import tracer from oto import response as oto_response from analytics.constants import cache from analytics.logic.parallel import parallel from analytics.logic.stores import add_outage_error_to_stores from analytics.queries.format import format_row from analytics.queries.product import ( ProductAggregateStreams, ProductMetricsByTrack, ProductStreamsAllTime, ProductTracks, ) from analytics.schemas import product as product_schema from analytics.schemas.product_track_metrics import ProductTrackMetricsSchema from analytics.utils import data_availability, store_availability from analytics.utils.cache import cache_in_redis from analytics.validation.schema import schema_dump def _add_tracks_without_streams_from_all_time_streams( product_streams, product_streams_all_time ): """Add to tracks of all time streams that had no recent streams.""" isrcs_with_streams = {track["isrc"] for track in product_streams} for track in product_streams_all_time: isrc = track["isrc"] if isrc in isrcs_with_streams: continue product_streams.append( { "product_id": product_streams[0]["product_id"], "isrc": isrc, "download_activity_date": None, "streams": 0, "max_available_date": data_availability.get_max_available_date( data_availability.get_available_feeds() ), "track_id": track["track_id"], } ) def _build_tracks_from_all_time_streams(product_streams_all_time, product_id): """Build track rows entirely from all-time ROLLUP streams. The v2 transfer path uses this when a former owner has no recent (14-day) track activity at all: there are no recent rows to carry the Snowflake ``product_id``, so it is taken from the request instead. """ return [ { "product_id": product_id, "isrc": track["isrc"], "download_activity_date": None, "streams": 0, "max_available_date": data_availability.get_max_available_date( data_availability.get_available_feeds() ), "track_id": track["track_id"], } for track in product_streams_all_time ] def _group_tracks_streams_by_track_id(product_response, track_streams_all_time): """Group tracks by track_id and aggregate timeseries data.""" product_tracks = [] product_response.sort(key=lambda x: x["track_id"]) for _, group in itertools.groupby(product_response, key=lambda i: i["track_id"]): group = list(group) track = group[0] track["streams"] = { "aggregate": {"all_time": track_streams_all_time.get(track["isrc"], 0)} } del track["download_activity_date"] product_tracks.append(track) return product_tracks def _get_max_available_date(product_response): """Return current max available date for product.""" try: return product_response[0].get("max_available_date") except IndexError: return None def _fetch_product_tracks(query_params, permissions): return [ format_row(row) for row in ProductTracks({**query_params, **permissions}).execute() ] def _fetch_product_streams_all_time(query_params, permissions): return [ format_row(row) for row in ProductStreamsAllTime({**query_params, **permissions}).execute() ] @tracer.wrap(name="get_product") @cache_in_redis(ttl=cache.ONE_DAY) def get_product(query_params: Mapping[str, Any], permissions: Mapping[str, Any]): """Return product details with track streams and store sources.""" product_id = query_params["product_id"] fan_out_params = { "product_id": product_id, "store_ids": store_availability.get_store_ids(), "distributors": query_params["distributors"], "transfer_product_ownership_enabled": query_params.get( "transfer_product_ownership_enabled", False ), } requests = { "product": { "func": _fetch_product_tracks, "args": ({**fan_out_params, "days_back": 14}, permissions), }, "product_streams_all_time": { "func": _fetch_product_streams_all_time, "args": (fan_out_params, permissions), }, "sources": { "func": add_outage_error_to_stores, "args": (store_availability.get_sources(),), }, } with tracer.trace("get_product_from_model"): with tracer.trace("get_product_streams_all_time"): result = parallel(requests) product_streams = result.message["product"] product_streams_all_time = result.message["product_streams_all_time"] sources = result.message["sources"] response_body = { "product_id": product_id, "streams": { "aggregate": { "growth_percentage": None, "all_time": None, } }, "tracks": [], "sources": sources, } max_available_date = _get_max_available_date(product_streams) if query_params.get("transfer_product_ownership_enabled", False): # v2 transfer path: a former owner has a valid frozen all-time total # from the ROLLUP query even when the recent (14-day) tracks query is # empty, so gate only on that ROLLUP result. if not product_streams_all_time: return oto_response.Response(response_body) else: if not (max_available_date and product_streams_all_time): return oto_response.Response(response_body) growth_pct = product_streams_all_time[0]["growth_percentage"] growth_pct = round(growth_pct, 4) if growth_pct is not None else None response_body["streams"] = { "aggregate": { "growth_percentage": growth_pct, "all_time": sum(e["all_time"] for e in product_streams_all_time), } } schema = product_schema.ProductSchema( exclude=[ "streams.items", "streams.stores", "tracks.streams.items", "tracks.streams.stores", ] ) track_streams_all_time = { e["isrc"]: e["all_time"] for e in product_streams_all_time } if ( query_params.get("transfer_product_ownership_enabled", False) and not product_streams ): # Former owner with no recent activity: every track row is built # from the all-time ROLLUP streams. product_streams = _build_tracks_from_all_time_streams( product_streams_all_time, product_id ) else: _add_tracks_without_streams_from_all_time_streams( product_streams, product_streams_all_time ) response_body["tracks"] = _group_tracks_streams_by_track_id( product_streams, track_streams_all_time ) return oto_response.Response(schema_dump(schema, response_body)) @tracer.wrap(name="get_metrics_by_track") @cache_in_redis(ttl=cache.ONE_DAY) def get_metrics_by_track( query_params: Mapping[str, Any], permissions: Mapping[str, Any] ): """Return per-track metrics for a product.""" store_ids = query_params.get("store_ids") or [] if store_ids: store_ids = sorted( set(store_ids).intersection(store_availability.get_store_ids()) ) else: store_ids = store_availability.get_store_ids() response_body = {"product_id": query_params["product_id"], "tracks": []} if not store_ids: return ProductTrackMetricsSchema.normalized_response(response_body) rows = ProductMetricsByTrack( {**query_params, "store_ids": store_ids, **permissions} ).execute() response_body["tracks"] = [format_row(row) for row in rows] return ProductTrackMetricsSchema.normalized_response(response_body) @tracer.wrap(name="get_product_aggregate_streams") @cache_in_redis(ttl=cache.ONE_DAY) def get_aggregate_streams( query_params: Mapping[str, Any], permissions: Mapping[str, Any] ): """Return aggregate streams for a product.""" product_id = query_params["product_id"] response_body = { "product_id": product_id, "streams_all_time": 0, "growth_percentage_7_days": None, } rows = list( ProductAggregateStreams( { **query_params, "store_ids": store_availability.get_store_ids(), **permissions, } ).execute() ) if rows: formatted = format_row(rows[0]) response_body["streams_all_time"] = formatted["streams_all_time"] response_body["growth_percentage_7_days"] = formatted[ "growth_percentage_7_days" ] schema = product_schema.ProductAggregateStreamsSchema() return oto_response.Response(schema_dump(schema, response_body))