"""Participant summary and timeseries logic.""" from typing import Any, Mapping from ddtrace import tracer from analytics.constants import cache from analytics.constants.ordering import ORDER_DIRECTIONS from analytics.logic import data_availability from analytics.queries.participant_summary import ( ParticipantSummary, ParticipantSummaryBySos, ParticipantSummaryDefault, ) from analytics.queries.participant_timeseries import ( ParticipantTimeseriesDownloads, ParticipantTimeseriesStreams, ParticipantTimeseriesStreamsBySos, ) from analytics.schemas.participants import ( ParticipantSummarySchema, ParticipantTimeseriesSchema, ) from analytics.utils.cache import cache_in_redis from analytics.utils.streams import ( check_store_ids_for_sos_detailed, get_streams_sos_detailed_columns, get_streams_sos_detailed_columns_from_stream_sources, ) # user-facing order_by → SQL column name (whitelisted in the query schema) SUMMARY_ORDER_BY_FIELDS = { "streams": "streams", "downloads": "downloads", "skipRate": "skip_rate", "saves": "saves", } # query_type → (query_class, default_params for SQL toggles) SUMMARY_DISPATCH = { "TOTAL": ( ParticipantSummary, { "group_by_column": "global_participant_id", "is_total": True, "exclude_tiktok": True, }, ), "COUNTRY": (ParticipantSummary, {"group_by_column": "country_code"}), "STORE": ( ParticipantSummary, {"group_by_column": "store_id", "exclude_tiktok": True}, ), "PRODUCT": (ParticipantSummary, {"group_by_column": "product_id"}), "SOUND_RECORDING": ( ParticipantSummary, {"group_by_column": "isrc", "is_sound_recording": True}, ), } TIMESERIES_DISPATCH = { "TRACK_STREAMS": ( ParticipantTimeseriesStreams, {"group_by_column": "global_participant_id"}, ), "TRACK_STREAMS_BY_COUNTRY": ( ParticipantTimeseriesStreams, {"group_by_column": "country_code"}, ), "TRACK_STREAMS_BY_STORE": ( ParticipantTimeseriesStreams, {"group_by_column": "store_id", "exclude_tiktok": True}, ), "TRACK_STREAMS_BY_PRODUCT": ( ParticipantTimeseriesStreams, {"group_by_column": "product_id"}, ), "TRACK_STREAMS_BY_SOUND_RECORDING": ( ParticipantTimeseriesStreams, {"group_by_column": "isrc"}, ), "TRACK_DOWNLOADS": ( ParticipantTimeseriesDownloads, {"group_by_column": "global_participant_id", "is_album": False}, ), "TRACK_DOWNLOADS_BY_COUNTRY": ( ParticipantTimeseriesDownloads, {"group_by_column": "country_code", "is_album": False}, ), "TRACK_DOWNLOADS_BY_STORE": ( ParticipantTimeseriesDownloads, {"group_by_column": "store_id", "is_album": False}, ), "TRACK_DOWNLOADS_BY_PRODUCT": ( ParticipantTimeseriesDownloads, {"group_by_column": "product_id", "is_album": False}, ), "TRACK_DOWNLOADS_BY_SOUND_RECORDING": ( ParticipantTimeseriesDownloads, {"group_by_column": "isrc", "is_album": False}, ), "ALBUM_DOWNLOADS": ( ParticipantTimeseriesDownloads, {"group_by_column": "global_participant_id", "is_album": True}, ), "ALBUM_DOWNLOADS_BY_COUNTRY": ( ParticipantTimeseriesDownloads, {"group_by_column": "country_code", "is_album": True}, ), "ALBUM_DOWNLOADS_BY_STORE": ( ParticipantTimeseriesDownloads, {"group_by_column": "store_id", "is_album": True}, ), "ALBUM_DOWNLOADS_BY_PRODUCT": ( ParticipantTimeseriesDownloads, {"group_by_column": "product_id", "is_album": True}, ), } def _is_default_summary_params( query_type: str, query_params: Mapping[str, Any] ) -> bool: """Match the legacy 28-day rollup short-circuit shape. The fast path uses the pre-aggregated METRICS_BY_PARTICIPANT_TRACK_28_DAYS_ROLLUP table. It only kicks in when the request looks like the default artist page — no country/store filters, default ordering, default page size, end_date at the data highwatermark, and exactly 27 days of range. """ end_date = query_params.get("end_date") start_date = query_params.get("start_date") if query_type not in ("TOTAL", "SOUND_RECORDING"): return False if not start_date or not end_date: return False if end_date != data_availability.get_max_available_date(): return False if (end_date - start_date).days != 27: return False if query_params.get("country_ids") or query_params.get("store_ids"): return False return ( query_params.get("order_by") == "streams" and (query_params.get("order_dir") or "").upper() == "DESC" and query_params.get("limit") == 50 and query_params.get("offset") == 0 ) def _resolve_sos_columns(query_params: Mapping[str, Any]) -> list: store_ids = [str(s) for s in query_params.get("store_ids") or []] store_ids = check_store_ids_for_sos_detailed(store_ids) stream_sources = query_params.get("stream_sources") or [] if stream_sources: return get_streams_sos_detailed_columns_from_stream_sources( store_ids, stream_sources ) return get_streams_sos_detailed_columns(store_ids) @cache_in_redis(ttl=cache.ONE_DAY) @tracer.wrap(name="get_participant_summary") def get_summary(query_params: Mapping[str, Any], permissions: Mapping[str, Any]): """Fetch participant summary.""" query_type = query_params.get("query_type") user_order_by = query_params.get("order_by") or "streams" if user_order_by not in SUMMARY_ORDER_BY_FIELDS: raise Exception("invalid order field") order_dir = (query_params.get("order_dir") or "DESC").upper() if order_dir not in ORDER_DIRECTIONS: raise Exception("invalid order direction") base = { **permissions, **query_params, "order_by": SUMMARY_ORDER_BY_FIELDS[user_order_by], "order_dir": order_dir, } if _is_default_summary_params(query_type, query_params): rows = ParticipantSummaryDefault( {**base, "is_sound_recording": query_type == "SOUND_RECORDING"} ).execute() elif query_type in ("SOS", "SOS_DETAILED"): is_detailed = query_type == "SOS_DETAILED" sos_columns = _resolve_sos_columns(query_params) if is_detailed else [] rows = ParticipantSummaryBySos( {**base, "is_detailed": is_detailed, "sos_columns": sos_columns} ).execute() elif query_type in SUMMARY_DISPATCH: klass, extra = SUMMARY_DISPATCH[query_type] rows = klass({**base, **extra}).execute() else: raise Exception("invalid query type") items = [ dict(row._mapping) if hasattr(row, "_mapping") else dict(row) for row in rows ] return ParticipantSummarySchema.normalized_response({"items": items}) @cache_in_redis(ttl=cache.ONE_DAY) @tracer.wrap(name="get_participant_timeseries") def get_timeseries(query_params: Mapping[str, Any], permissions: Mapping[str, Any]): """Fetch participant timeseries.""" query_type = query_params.get("query_type") base = { **permissions, **query_params, "max_available_date": data_availability.get_max_available_date(), } if query_type in ("TRACK_STREAMS_BY_SOS", "TRACK_STREAMS_BY_SOS_DETAILED"): is_detailed = query_type == "TRACK_STREAMS_BY_SOS_DETAILED" sos_columns = _resolve_sos_columns(query_params) if is_detailed else [] rows = ParticipantTimeseriesStreamsBySos( {**base, "is_detailed": is_detailed, "sos_columns": sos_columns} ).execute() elif query_type in TIMESERIES_DISPATCH: klass, extra = TIMESERIES_DISPATCH[query_type] rows = klass({**base, **extra}).execute() else: raise Exception("invalid query type") items = [ dict(row._mapping) if hasattr(row, "_mapping") else dict(row) for row in rows ] return ParticipantTimeseriesSchema.normalized_response({"items": items})