import json from aiohttp.web import Request from apollo_utils.service.exceptions import BadRequest from functools import cmp_to_key, partial from inspect import signature from operator import itemgetter from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union from urllib.parse import urlencode from server import config from server.cache.base import get_cache, get_value_by_key_template json_dumps = partial(json.dumps, default=str) def _get_url_path(request: Request): return request.url.path.replace(config.API_PREFIX, config.REAL_API_PREFIX) def _replace_query_param(request: Request, **kwargs): """Create url with offset and limit query params""" url = _get_url_path(request) params = [(key, value) for key, value in request.url.query.items() if key not in ("limit", "offset")] params = params + [(k, v) for k, v in kwargs.items()] return f"{url}?{urlencode(params)}" if params else url def _get_previous_link(request: Request, offset: int, limit: int) -> str: """Create previous page URL.""" return ( _replace_query_param(request, limit=limit) if offset - limit <= 0 else _replace_query_param(request, limit=limit, offset=offset - limit) ) def _get_next_link(request: Request, offset: int, limit: int) -> str: """Create next page URL.""" return _replace_query_param(request, limit=limit, offset=offset + limit) def _slice_data(offset: int, limit: int, count: int, data: Iterable) -> list: if count == 0 or offset > count: return [] return data[offset : offset + limit] def get_links(request: Request, count: int, offset: int, limit: int): return { "previous": _get_previous_link(request, offset, limit) if offset > 0 else None, "next": _get_next_link(request, offset, limit) if offset + limit < count else None, } def paginate( cache_key_template: str or None = None, cache_ttl: int = config.DEFAULT_PAGINATION_TLL, items_key: str = "items", request_index: int = 0, params_keys: Tuple[str] = ("querystring",), # can specify multiple location, 1st one should contain limit, offset extend_original_dict: bool = False, response_kwargs: bool = False, ) -> Callable: """Paginate function results. Args: cache_key_template: Cache key template. cache_ttl: Cache TTL. For query get all, for iterable it is not a one page but all of them at once. items_key: Response items node field name. request_index: Request obj index in args. params_keys: Params dict keys in request. extend_original_dict: Response items within dict already, need to extend it with pagination fields. response_kwargs: Result contains extra data that should be a part of final response. Returns: Callable: Decorated function. """ def decorator(f: Callable[..., Union[list, Any]]) -> Callable: if not params_keys: raise ValueError("'params_keys' should be specified.") async def wrapped(*args, **kwargs) -> Any: cache_client = get_cache() request = args[request_index] params = {} for pk in params_keys: params.update(request[pk]) pagination_params = request[params_keys[0]] limit, offset = pagination_params.pop("limit"), pagination_params.pop("offset") or 0 cache_value, cache_key = await get_value_by_key_template( cache_key_template=cache_key_template, kwargs=params, cache_client=cache_client, str_to_date=False, ) if cache_value: response = cache_value else: response = await f(*args, **kwargs) response_extra = None if response_kwargs: response, response_extra = response if not cache_value: await cache_client.set( cache_key, cache_ttl, response if not response_extra else [response, response_extra], ) if extend_original_dict: response_items = response[items_key] result = response else: response_items = response result = {items_key: response} count = len(response_items) if limit is None: offset = 0 limit = count result.update( { "count": count, items_key: _slice_data(offset, limit, count, response_items), **get_links(request, count, offset, limit), **(response_extra if response_kwargs else {}), } ) return result wrapped.__signature__ = signature(f) return wrapped return decorator def multikeysort(items: List[Dict], fields: Optional[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. """ if not fields: return items if items: not_found_fields = [i for i in fields if i.lstrip("-") not in items[0]] if not_found_fields: raise BadRequest(f"Incorrect sort by field(s): {', '.join(not_found_fields)}") 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))