from typing import Any, Callable, Iterable, Tuple, Union from urllib.parse import urlencode from flask import current_app as app from flask import request from sqlalchemy.orm.query import Query from redis_db import keys from redis_db.decorators import get_redis_value, set_redis_value def _get_url_path(): return request.path.replace(app.config["API_PREFIX"], app.config["REAL_API_PREFIX"]) def _replace_query_param(url: str, **kwargs): """Create url with offset and limit query params""" params = [ (key, value) for key, values in request.args.to_dict(flat=False).items() if key not in ("limit", "offset") for value in (values[0].split(",") if len(values) == 1 and isinstance(values[0], str) else values) ] params = params + [(k, v) for k, v in kwargs.items()] return f"{url}?{urlencode(params)}" def _get_previous_link(offset: int, limit: int) -> str: """Create previous page URL. """ return ( _replace_query_param(_get_url_path()) if offset - limit <= 0 else _replace_query_param(_get_url_path(), limit=limit, offset=offset - limit) ) def _get_next_link(offset: int, limit: int) -> str: """Create next page URL. """ return _replace_query_param(_get_url_path(), 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 paginate( cache_key_template: str or None = None, cache_ttl: int = keys.DEFAULT_PAGINATION_TLL, full_data: bool = True, extra_data: bool = False, response_kwargs: bool = False, update_page: Callable[..., list] or None = None, items_key: str = "items", ) -> Callable: """Paginate function results. Allow results to be SQLAlchemy query, list or tuple of query/list and dict, that just would be a part of result page. List result and full_data = False is not allowed use case. Should be applied after use_kwargs decorator. Args: cache_key_template: Cache key template. cache_ttl: Cache TTL. full_data: Working with full data, not specific page only. For query get all, for iterable it is not a one page but all of them at once. extra_data: Result contains extra data that can be used in setting additional data for a page. response_kwargs: Result contains extra data that should be a part of final response. update_page: Set additional data for page items. items_key: Response items node field name. Returns: Callable: Decorated function. """ def decorator(f: Callable[..., Union[Query, list, Tuple[Union[Query, list], Any]]]) -> Callable: def wrapped(*args, **kwargs) -> Any: cache_value = None cache_key = None # get pagination args include_count, limit, offset = kwargs["include_count"], kwargs["limit"], kwargs["offset"] # try to get count or full data from cache if cache_key_template and (include_count or full_data): cache_value, cache_key = get_redis_value( cache_key_template=cache_key_template, func=f, args=args, kwargs=kwargs ) # do not call the function if we have full data in cache if cache_value and full_data: result = cache_value else: result = f(*args, **kwargs) response_extra = None if extra_data or response_kwargs: result, response_extra = result # check if query result or not is_query = isinstance(result, Query) # list non full result is not allowed if not is_query and not full_data: raise ValueError() # consider that previous page exists when offset > 0 has_prev = offset > 0 # if count is requested or full list is received as result if include_count or full_data: if full_data: if not cache_value: if is_query: result = result.all() set_redis_value( cache_key, cache_ttl, result if not response_extra else [result, response_extra] ) count = len(result) else: if cache_value: count = cache_value else: count = result.count() set_redis_value(cache_key, cache_ttl, count) data = _slice_data(offset, limit, count, result) has_next = offset + limit < count else: count = None limit_next = limit + 1 # execute query from offset to limit + 1 data = result[offset : offset + limit_next] # if we have limit + 1 item then the next page exists has_next = len(data) == limit_next # get page items data = data[:limit] # call a func to fill additional page data if update_page: if extra_data: data = update_page(data, response_extra, *args, **kwargs) else: data = update_page(data, *args, **kwargs) return { "count": count, "previous": _get_previous_link(offset, limit) if has_prev else None, "next": _get_next_link(offset, limit) if has_next else None, items_key: data, **(response_extra if response_kwargs else {}), } return wrapped return decorator