from apollo_utils.service.schemas.fields.datetime import DateStringField from apollo_utils.service.schemas.fields.enum import EnumField, EnumListField from apollo_utils.service.schemas.mixins.market import MarketRequiredGlobalWWMixin from apollo_utils.service.schemas.mixins.misc import get_dependant_mixin from datetime import date, datetime, timedelta from marshmallow import ValidationError, fields, post_load, validate, validates_schema from server.constants import DSP, DSP_SPOTIFY_APPLE from server.constants.charts import CHART_MIN_DATE, CHART_MOVES_DEFAULT, CHART_ORDER_MAPPING, CHART_POSITION_MAX, \ CHART_POSITION_MIN, EMPTY_POSITION_VALUE, PREFIX_ARTIST, ChartBreakdown, ChartInclude, ChartOrder from server.constants.distributors import DISTRIBUTORS, DISTRIBUTORS_FILTERS from server.schemas.base.chart import ChartBreakdownDefaultDailyMixin from server.schemas.base.pagination import PaginationResultMixin, get_pagination_mixin from server.schemas.base.schemas import RequestSchema, Schema class DSPRequiredMixin: """DSP mixin.""" dsp = EnumField(DSP_SPOTIFY_APPLE, required=True, data_key="vendor") def get_chart_date(chart_date: str, context: dict) -> str: return ( chart_date if ( not chart_date or ( (context["dsp"] != DSP.SPOTIFY.value or context["chart_breakdown"] == ChartBreakdown.DAILY.value) and not context["removals"] ) ) else (date.fromisoformat(chart_date) + timedelta(days=1)).isoformat() ) def get_change(item: dict, context: dict, sub_value: int = 0) -> int: if context["removals"]: result = EMPTY_POSITION_VALUE[context["dsp"]] - item["metrics"]["position"] elif item["metrics"]["is_entry"]: result = item["metrics"]["position"] - EMPTY_POSITION_VALUE[context["dsp"]] else: result = ( ( item["metrics"]["position"] - (item["metrics"].get("previous_position") or EMPTY_POSITION_VALUE[context["dsp"]]) ) if item["metrics"]["position"] else None ) if result and sub_value: result = result - sub_value return result class ChartBase: class Request( RequestSchema, MarketRequiredGlobalWWMixin, DSPRequiredMixin, ChartBreakdownDefaultDailyMixin, get_dependant_mixin( "order_by_list", "field_list", enum=ChartOrder, depends_mapping={ ChartOrder.IS_STARRED: ChartInclude.IS_STARRED, ChartOrder.IS_STARRED_DESC: ChartInclude.IS_STARRED, }, ), ): chart_date = fields.Date(data_key="date", missing=None) field_list = EnumListField( ChartInclude, allow_all=True, as_enum=True, missing=[], data_key="fields", ignore_unknown=True ) only_starred_tracks = fields.Boolean(missing=False, default=False) is_sony = fields.Boolean(missing=None, default=None) order_by_list = EnumListField( ChartOrder, as_enum=False, data_key="order_by", missing=[ChartOrder.POSITION.value] ) distributors = EnumListField(DISTRIBUTORS, unify=True, missing=None, as_enum=True) # empty 'filter_distributors' means that no filtering is needed filter_distributors = EnumListField(DISTRIBUTORS_FILTERS, unify=True, missing=None, as_enum=True) @validates_schema def validate_data(self, data, **kwargs): self._validate_chart_date(data, **kwargs) self._validate_period(data, **kwargs) self._validate_distributors(data, **kwargs) @post_load def prepare_data(self, data, **kwargs): data = self._migrate_distributors(data, **kwargs) data = self._fix_sort_order(data, **kwargs) return data def _validate_chart_date(self, data, **kwargs): chart_date = data.get("chart_date") chart_breakdown = data.get("chart_breakdown") if chart_date: if chart_date > (datetime.utcnow() + timedelta(days=1)).date(): raise ValidationError("Date is too big.") if chart_date < CHART_MIN_DATE[chart_breakdown]: raise ValidationError("Date is too small.") if chart_breakdown == ChartBreakdown.WEEKLY: if chart_date.weekday() not in (3, 4): raise ValidationError("Weekly chart is available for friday only.") elif chart_date.weekday() == 4: data["chart_date"] = chart_date - timedelta(days=1) def _validate_period(self, data, **kwargs): if "start" in data and "end" in data and data["start"] > data["end"]: raise ValidationError("End must be greater than start.") def _validate_distributors(self, data, **kwargs): distributors, filter_distributors = data.get("distributors", []), data.get("filter_distributors", []) if distributors and filter_distributors: uncovered_filters = ( set(f.value for f in filter_distributors) - set(d.value for d in distributors) - {DISTRIBUTORS_FILTERS.OTHER.value} ) if uncovered_filters: raise ValidationError(f"Filters {uncovered_filters} are not covered by distributors {distributors}") def _fix_sort_order(self, data, **kwargs): """Change sort order.""" order_by_list = data.get("order_by_list") if order_by_list: for index, item in enumerate(list(order_by_list)): if item in CHART_ORDER_MAPPING: value = CHART_ORDER_MAPPING[item] if value in order_by_list: del order_by_list[index] else: order_by_list[index] = value return data def _migrate_distributors(self, data, **kwargs): """Migrate distributors from old format (is-sony only) to new (multiple values).""" # remove old-version values from data filter_is_sony, field_list = data.pop("is_sony", None), data.get("field_list", []) add_is_sony = ChartInclude.IS_SONY in field_list if add_is_sony and field_list: data["field_list"].remove(ChartInclude.IS_SONY) # passing 'distributors' / 'filter_distributors' overrides 'is_sony' logic distributors, filter_distributors = data.get("distributors", []), data.get("filter_distributors", []) if distributors or filter_distributors: return data # migrating add_is_sony = add_is_sony or (filter_is_sony is not None) if add_is_sony: data["distributors"] = [DISTRIBUTORS.SME] if filter_is_sony is not None: data["filter_distributors"] = [ DISTRIBUTORS_FILTERS.SME if filter_is_sony else DISTRIBUTORS_FILTERS.OTHER ] return data class Response(Schema): class TrackItem(Schema): class ArtistItem(Schema): id = fields.Str(attribute="artist_id", required=True) name = fields.Str(default="", missing="") uri = fields.Function(lambda i: PREFIX_ARTIST + i["artist_id"]) id = fields.String(attribute="public_meta.track_id") name = fields.String(attribute="public_meta.name") artist = fields.Function( lambda i: ( ", ".join([j["name"] for j in i["public_meta"]["artists"]]) if i["public_meta"]["artists"] else "" ) ) isrc = fields.String(attribute="public_meta.isrc", default="", missing="") position = fields.Integer(attribute="metrics.position") streams = fields.Integer(attribute="metrics.date_streams", missing=None) change = fields.Function(get_change) is_new = fields.Function(lambda i, context: i["metrics"]["is_entry"] and not context["removals"]) is_re_enter = fields.Function( lambda i, context: ( not context["removals"] and i["metrics"]["is_entry"] and i["metrics"]["date"] != i["lifetime_metrics"]["earliest_position_date"] ) ) trend = fields.Function( lambda i, context: ( get_change(i, context, 1000 if i["metrics"]["is_entry"] and not context["removals"] else 0) ) ) is_starred_track = fields.Bool(default=False, missing=False) favorites_id = fields.Int(default=None) chart_date = fields.Function(lambda i, context: get_chart_date(i["metrics"]["date"], context)) is_sony = fields.Bool(default=False, missing=False) artists = fields.List(fields.Nested(ArtistItem), attribute="public_meta.artists") image_url = fields.String(attribute="public_meta.image_url", missing="") distributed_by = fields.String(default=None) chart_date = fields.Function(lambda i, context: get_chart_date(i["chart_date"], context)) available = fields.Bool() tracks = fields.List(fields.Nested(TrackItem)) class ChartTop(ChartBase): class Request(ChartBase.Request): min_position = fields.Integer( validate=validate.Range(CHART_POSITION_MIN, CHART_POSITION_MAX), data_key="start", default=CHART_POSITION_MIN, missing=CHART_POSITION_MIN, ) max_position = fields.Integer( validate=validate.Range(CHART_POSITION_MIN, CHART_POSITION_MAX), data_key="end", default=CHART_POSITION_MAX, missing=CHART_POSITION_MAX, ) class ChartOut(ChartBase): class Request(ChartBase.Request): previous_min_position = fields.Integer( validate=validate.Range(CHART_POSITION_MIN, CHART_POSITION_MAX), data_key="start", default=CHART_POSITION_MIN, missing=CHART_POSITION_MIN, ) previous_max_position = fields.Integer( validate=validate.Range(CHART_POSITION_MIN, CHART_POSITION_MAX), data_key="end", default=CHART_POSITION_MAX, missing=CHART_POSITION_MAX, ) class ChartMoves: class Request(ChartBase.Request, get_pagination_mixin(0, 200, 200)): change = fields.Integer( validate=validate.Range(0, CHART_POSITION_MAX), default=CHART_MOVES_DEFAULT, missing=CHART_MOVES_DEFAULT, ) order_by_list = EnumListField(ChartOrder, as_enum=False, data_key="order_by", missing=[ChartOrder.TREND.value]) class Response(ChartBase.Response, PaginationResultMixin): pass class ChartDateRange: class Request(RequestSchema, MarketRequiredGlobalWWMixin, DSPRequiredMixin, ChartBreakdownDefaultDailyMixin): pass class Response(Schema): min_date = DateStringField() max_date = DateStringField()