""" Common utilities. """ from apollo_utils.service.exceptions import JsonValidationError, UnsupportedMediaType from datetime import date, datetime, time, timedelta, timezone from flask import request from functools import cmp_to_key from marshmallow import ValidationError from operator import itemgetter from typing import Any, Dict, Generator, Iterable, List, Tuple, Union from src.constants.core import CLIENT_MOBILE, CLIENT_PORTAL, MARKET_GLOBAL, SPOTIFY_MARKET_GLOBAL class DateRange: """ Stores date range start and end dates. """ def __init__(self, start: date, end: date): self.start = start self.end = end def __contains__(self, item: Union[str, datetime, date]): if isinstance(item, str): item = datetime.strptime(item, "%Y-%m-%d").date() elif type(item) is datetime: item = item.date() return self.start <= item <= self.end @property def start_isoformat(self): return self.start.isoformat() @property def end_isoformat(self): return self.end.isoformat() def get_range_dates(self) -> List[str]: """ Returns all dates in week range is iso format. Returns: List[str]: List of iso formatted dates. """ result = [] day = self.start while day <= self.end: result.append(day.isoformat()) day += timedelta(days=1) return result def check_in_range(self, date_obj: date) -> bool: """ Check if date in range. Args: date_obj (date): Date object. Returns: bool: True if in range, otherwise - False. """ return self.start <= date_obj <= self.end def __str__(self): return f"{self.start_isoformat} - {self.end_isoformat}" def to_dict(self, isoformat: bool = False): """ Convert object to dict. Args: isoformat (bool): Convert date object to isoformat or not. Returns: dict: Result dictionary start and end range values. """ return { "start": self.start_isoformat if isoformat else self.start, "end": self.end_isoformat if isoformat else self.end, } def get_request_json(schema=None): """ Parse and validate JSON from request. Args: schema (marshmallow.Schema): Optional Marshmallow schema for validating and cleaning the JSON. Returns: JSON parsed to native Python data structure. Raises: BadRequest: If request body cannot be parsed. JsonValidationError: If JSON does not pass schema validation. UnsupportedMediaType: If request media type is neither application/json nor application/*+json. """ if not request.is_json: raise UnsupportedMediaType('Unsupported media type "{}". Expected "application/json".'.format(request.mimetype)) if not schema: return request.get_json() try: data = schema.load(request.get_json()) except ValidationError as error: raise JsonValidationError(extra=error.data) return data def fold_periods(dates, gap_size_close=1): """ Fold dates from list to list of periods [[date_from, date_to], [.., ..]]. Args: dates: List of dates. gap_size_close: it will close 1-day gaps by default Returns: List of periods pairs. """ result = [] if not dates: return result period_start, period_end = dates[0], dates[0] for i in range(1, len(dates)): if (dates[i] - dates[i - 1]).days <= gap_size_close + 1: period_end = dates[i] continue result.append([period_start, period_end]) period_start, period_end = dates[i], dates[i] result.append([period_start, period_end]) return result def sum_periods(dates: List[List[date]]) -> List[List[date]]: """Concat overlapping and consecutive date intervals. Args: dates (List[List[date]]): List of date pairs what are interval starts and ends. Returns: List[List[date]]: Aggregated list of date pairs. """ if not dates: return dates dates = sorted(dates, key=lambda x: (x[0], x[1])) prev_date = dates[0] results = [prev_date] for i in range(1, len(dates)): current_date = dates[i] if prev_date[1] >= (current_date[0] - timedelta(days=2)): if current_date[1] > prev_date[1]: prev_date[1] = current_date[1] else: prev_date = current_date results.append(prev_date) return results def get_client_onboarding_kwargs(client_type, user_id, create=False): """Build kwargs dictionary for onboarding query filtering and instance creating. Args: client_type (str): client type value user_id (str): user identifier create (bool): True - we will use kwargs for onboarding record creating, False - for filtering Returns: dict: Dictionary with fields values """ is_portal = client_type == CLIENT_PORTAL is_mobile = client_type == CLIENT_MOBILE result = {"user_id": user_id} update_dict = {} if create: update_dict = {"is_mobile": is_mobile, "is_portal": is_portal, "created_at": datetime.now()} elif is_portal: update_dict = {"is_portal": is_portal} elif is_mobile: update_dict = {"is_mobile": is_mobile} result.update(update_dict) return result def check_country_code_value(country_code: str or None) -> str or None: """Check and fix country code value. Args: country_code (str or None): Country code value. Returns: (str or None): Country code value or None """ if not country_code: return None not_valid = len(country_code) != 2 and country_code not in (MARKET_GLOBAL, SPOTIFY_MARKET_GLOBAL) return None if not_valid else country_code def multikeysort(items: List[Dict], fields: List[str]) -> List[dict]: """ Custom solution for sorting list of dicts by multiple fields with support for datetime and None objects. Args: items (List[Dict]): List of items that will be sorted. fields (List[str]): Collection of sorting fields. Returns: List[dict]: Sorted list of items. """ comparers = [ ((itemgetter(fld.lstrip("-")), -1) if fld.startswith("-") else (itemgetter(fld.strip()), 1)) for fld in fields ] def cmp(x, y): """ Compare the two objects x and y and return an integer according to the outcome. The return value is negative if x < y, zero if x == y and strictly positive if x > y. """ if x is None and y is None: return 0 elif x is None: return -1 elif y is None: return 1 return (x > y) - (x < y) def comparer(left, right): comparer_iter = (cmp(fn(left), fn(right)) * reverse for fn, reverse in comparers) return next((result for result in comparer_iter if result), 0) primary_field = fields[0].lstrip("-") if fields else None if primary_field: _none_items = filter(lambda itm: itm[primary_field] is None, items) _items = filter(lambda itm: itm[primary_field] is not None, items) return sorted(_items, key=cmp_to_key(comparer)) + sorted(_none_items, key=cmp_to_key(comparer)) return sorted(items, key=cmp_to_key(comparer)) def date_to_timestamp(date_obj: date or datetime) -> int: """ Convert date object to Unix timestamp. Args: date_obj (date or datetime): Date or datetime object. Returns: int: Num of seconds from Unix epoch start. """ if isinstance(date_obj, date): date_obj = datetime.combine(date_obj, time(0, 0), tzinfo=timezone.utc) return int(datetime.timestamp(date_obj)) def dates_range( start_date: date or datetime, end_date: date or datetime, as_timestamp: bool = False ) -> Generator[date or datetime or int, None, None]: """Generator yield dates between two dates.""" days_delta = end_date - start_date for i in range(days_delta.days + 1): day = start_date + timedelta(days=i) yield date_to_timestamp(day) if as_timestamp else day def generate_coordinates(data: dict, start_date: date, end_date: date): """Generate set of coordinates for dates interval with streaming info. Args: data: Streaming data per date. start_date: Interval from. end_date: Interval to. Returns: List of coordinates. """ coordinates_list = [] for ts in dates_range(start_date, end_date, as_timestamp=True): coordinates_list.append({"x": ts, "y": data.get(ts)}) return coordinates_list def get_previous_date_range(current_range: DateRange) -> DateRange: """ Get previous date range dates based on current date values. Args: current_range (DateRange): Named tuple with start and end values of date range. Returns: DateRange: Start and end dates of previous date range. """ delta = current_range.end - current_range.start days_delta = delta.days + 1 start_extended = current_range.start - timedelta(days=days_delta) end_extended = current_range.end - timedelta(days=days_delta) return DateRange(start=start_extended, end=end_extended) def combine_artists_by_track(data: List) -> Tuple[List[Dict], Dict]: """ Combines artists by track Args: data: sqlalchemy.orm.query Returns: Tuple[List[Dict], Dict]: Tuple with personalized playlist data. """ tracklist, tracks_keys = {}, [] for item in data: position = item.current_position if position in tracklist: tracklist[position]["artists"].append(item.artist_name) continue track_data = item._asdict() track_data["artists"] = [track_data.pop("artist_name")] tracks_keys.append(item.track_id) tracklist[position] = track_data return list(tracklist.values()), {"ids": tracks_keys} def flatten_nested_data(data: Dict[str, Any], keys_names: Iterable[str]) -> List[Dict[str, Any]]: """ Function to flatten nested dictionary to a list of dictionaries. data: original nested dictionary. keys_names: names for keys of inner dictionaries, length of keys_names should be equal to flattened depth. Example: data = {'apple': {'Bentley': {'price': 10.2}, 'Cameo': {'price': 7.89}}, 'pear': {'Meadow': {'price': 3.99}}} flatten_nested_data(data, ("fruit", "type")) -> [ {'price': 10.2, 'type': 'Bentley Sweet', 'fruit': 'apple'}, {'price': 7.89, 'type': 'Cameo', 'fruit': 'apple'}, {'price': 3.99, 'type': 'Meadow', 'fruit': 'pear'} ] """ def flatten(_data, inner_keys, outer_data, _result): if not inner_keys: _result.append({**_data, **outer_data}) return key_name = inner_keys[0] for key_value, item_data in _data.items(): flatten(item_data, inner_keys[1:], {key_name: key_value, **outer_data}, _result) keys_names_list = list(keys_names) result = [] flatten(data, keys_names_list, {}, result) return result