# The core application of the Mia project. # by imacat , 2020/7/1 # Copyright (c) 2020 imacat. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """The utilities of the Mia core application. """ import random import urllib.parse from typing import Dict, List, Any, Type from django.conf import settings from django.db.models import Model, Q from django.http import HttpRequest from django.utils.translation import pgettext, get_language def new_pk(cls: Type[Model]) -> int: """Finds a random ID that does not conflict with the existing data records. Args: cls: The Django model class. Returns: The new random ID. """ while True: pk = random.randint(100000000, 999999999) try: cls.objects.get(pk=pk) except cls.DoesNotExist: return pk def strip_post(post: Dict[str, str]) -> None: """Strips the values of the POSTed data. Empty strings are removed. Args: post (dict[str]): The POSTed data. """ for key in list(post.keys()): post[key] = post[key].strip() if post[key] == "": del post[key] class Language: """A language. Args: language: The Django language code. Attributes: id (str): The language ID db (str): The database column suffix of this language. locale (str); The locale name of this language. is_default (bool): Whether this is the default language. """ def __init__(self, language: str): self.id = language self.db = "_" + language.lower().replace("-", "_") if language == "zh-hant": self.locale = "zh-TW" elif language == "zh-hans": self.locale = "zh-CN" else: self.locale = language self.is_default = (language == settings.LANGUAGE_CODE) @staticmethod def default(): return Language(settings.LANGUAGE_CODE) @staticmethod def current(): return Language(get_language()) def get_multi_lingual_attr(model: Model, name: str, default: str = None) -> str: """Returns a multi-lingual attribute of a data model. Args: model: The data model. name: The attribute name. default: The default language. Returns: The attribute in this language, or in the default language if there is no content in the current language. """ language = Language.current() title = getattr(model, name + language.db) if default is None: default = Language.default().id if language.id == default: return title if title is not None: return title return getattr(model, name + Language.default().db) def set_multi_lingual_attr(model: Model, name: str, value: str) -> None: """Sets a multi-lingual attribute of a data model. Args: model: The data model. name: The attribute name. value: The new value """ language = Language.current() setattr(model, name + language.db, value) def get_multi_lingual_search(attr: str, query: str) -> Q: """Returns the query condition on a multi-lingual attribute. Args: attr: The base name of the multi-lingual attribute. query: The query. Returns: The query condition """ language = Language.current() if language.is_default: return Q(**{attr + language.db + "__icontains": query}) default = Language.default() q = (Q(**{attr + language.db + "__isnull": False}) & Q(**{attr + language.db + "__icontains": query}))\ | (Q(**{attr + language.db + "__isnull": True}) & Q(**{attr + default.db + "__icontains": query})) return q class UrlBuilder: """The URL builder. Attributes: path (str): the base path params (list[Param]): The query parameters """ def __init__(self, start_url: str): """Constructs a new URL builder. Args: start_url (str): The URL to start with """ pos = start_url.find("?") if pos == -1: self.path = start_url self.params = [] return self.path = start_url[:pos] self.params = [] for piece in start_url[pos + 1:].split("&"): pos = piece.find("=") name = urllib.parse.unquote(piece[:pos]) value = urllib.parse.unquote(piece[pos + 1:]) self.params.append(self.Param(name, value)) def add(self, name, value): """Adds a query parameter. Args: name (str): The parameter name value (str): The parameter value Returns: UrlBuilder: The URL builder itself, with the parameter modified. """ if value is not None: self.params.append(self.Param(name, value)) return self def remove(self, name): """Removes a query parameter. Args: name (str): The parameter name Returns: UrlBuilder: The URL builder itself, with the parameter modified. """ self.params = [x for x in self.params if x.name != name] return self def query(self, **kwargs): """A keyword-styled query parameter setter. The existing values are always replaced. Multiple-values are added when the value is a list or tuple. The existing values are dropped when the value is None. """ for key in kwargs: self.remove(key) if isinstance(kwargs[key], list) or isinstance(kwargs[key], tuple): for value in kwargs[key]: self.add(key, value) elif kwargs[key] is None: pass else: self.add(key, kwargs[key]) return self def clone(self): """Returns a copy of this URL builder. Returns: UrlBuilder: A copy of this URL builder. """ another = UrlBuilder(self.path) another.params = [ self.Param(x.name, x.value) for x in self.params] return another def __str__(self) -> str: if len(self.params) == 0: return self.path return self.path + "?" + "&".join([ str(x) for x in self.params]) class Param: """A query parameter. Attributes: name: The parameter name value: The parameter value """ def __init__(self, name: str, value: str): """Constructs a new query parameter Args: name (str): The parameter name value (str): The parameter value """ self.name = name self.value = value def __str__(self) -> str: """Returns the string representation of this query parameter. Returns: str: The string representation of this query parameter """ return "%s=%s" % ( urllib.parse.quote(self.name), urllib.parse.quote(self.value)) class Pagination: """The pagination. Args: request: The request. items: All the items. is_reversed: Whether we should display the last page first. Raises: PaginationException: With invalid pagination parameters Attributes: current_url (UrlBuilder): The current request URL. is_reversed (bool): Whether we should display the last page first. page_size (int): The page size. total_pages (int): The total number of pages available. is_paged (bool): Whether there are more than one page. page_no (int): The current page number. items (list[Model]): The items in the current page. """ DEFAULT_PAGE_SIZE = 10 def __init__(self, request: HttpRequest, items: List[Any], is_reversed: bool = False): self.current_url = UrlBuilder(request.get_full_path()) self.is_reversed = is_reversed self.page_size = self.DEFAULT_PAGE_SIZE self.total_pages = None self.is_paged = None self.page_no = 1 self.items = [] # The page size try: self.page_size = int(request.GET["page-size"]) if self.page_size == self.DEFAULT_PAGE_SIZE: raise PaginationException(self.current_url.remove("page-size")) if self.page_size < 1: raise PaginationException(self.current_url.remove("page-size")) except KeyError: self.page_size = self.DEFAULT_PAGE_SIZE except ValueError: raise PaginationException(self.current_url.remove("page-size")) self.total_pages = int( (len(items) - 1) / self.page_size) + 1 default_page_no = 1 if not is_reversed else self.total_pages self.is_paged = self.total_pages > 1 # The page number try: self.page_no = int(request.GET["page"]) if not self.is_paged: raise PaginationException(self.current_url.remove("page")) if self.page_no == default_page_no: raise PaginationException(self.current_url.remove("page")) if self.page_no < 1: raise PaginationException(self.current_url.remove("page")) if self.page_no > self.total_pages: raise PaginationException(self.current_url.remove("page")) except KeyError: self.page_no = default_page_no except ValueError: raise PaginationException(self.current_url.remove("page")) if not self.is_paged: self.page_no = 1 self.items = items return start_no = self.page_size * (self.page_no - 1) self.items = items[start_no:start_no + self.page_size] def links(self): """Returns the navigation links of the pagination bar. Returns: List[Link]: The navigation links of the pagination bar. """ base_url = self.current_url.clone().remove("page").remove("s") links = [] # The previous page link = self.Link() link.title = pgettext("Pagination|", "Previous") if self.page_no > 1: if self.page_no - 1 == 1: if not self.is_reversed: link.url = str(base_url) else: link.url = str(base_url.clone().add( "page", "1")) else: link.url = str(base_url.clone().add( "page", str(self.page_no - 1))) link.is_small_screen = True links.append(link) # The first page link = self.Link() link.title = "1" if not self.is_reversed: link.url = str(base_url) else: link.url = str(base_url.clone().add( "page", "1")) if self.page_no == 1: link.is_active = True links.append(link) # The previous ellipsis if self.page_no > 4: link = self.Link() if self.page_no > 5: link.title = pgettext("Pagination|", "...") else: link.title = "2" link.url = str(base_url.clone().add( "page", "2")) links.append(link) # The nearby pages for no in range(self.page_no - 2, self.page_no + 3): if no <= 1 or no >= self.total_pages: continue link = self.Link() link.title = str(no) link.url = str(base_url.clone().add( "page", str(no))) if no == self.page_no: link.is_active = True links.append(link) # The next ellipsis if self.page_no + 3 < self.total_pages: link = self.Link() if self.page_no + 4 < self.total_pages: link.title = pgettext("Pagination|", "...") else: link.title = str(self.total_pages - 1) link.url = str(base_url.clone().add( "page", str(self.total_pages - 1))) links.append(link) # The last page link = self.Link() link.title = str(self.total_pages) if self.is_reversed: link.url = str(base_url) else: link.url = str(base_url.clone().add( "page", str(self.total_pages))) if self.page_no == self.total_pages: link.is_active = True links.append(link) # The next page link = self.Link() link.title = pgettext("Pagination|", "Next") if self.page_no < self.total_pages: if self.page_no + 1 == self.total_pages: if self.is_reversed: link.url = str(base_url) else: link.url = str(base_url.clone().add( "page", str(self.total_pages))) else: link.url = str(base_url.clone().add( "page", str(self.page_no + 1))) link.is_small_screen = True links.append(link) return links class Link: """A navigation link in the pagination bar. Attributes: url (str): The link URL, or for a non-link slot. title (str): The title of the link. is_active (bool): Whether this link is currently active. is_small_screen (bool): Whether this link is for small screens """ def __int__(self): self.url = None self.title = None self.is_active = False self.is_small_screen = False def page_size_options(self): """Returns the page size options. Returns: List[PageSizeOption]: The page size options. """ base_url = self.current_url.remove("page").remove("page-size") return [self.PageSizeOption(x, self._page_size_url(base_url, x)) for x in [10, 100, 200]] @staticmethod def _page_size_url(base_url: UrlBuilder, size: int) -> str: """Returns the URL for a new page size. Args: base_url (UrlBuilder): The base URL builder. size (int): The new page size. Returns: str: The URL for the new page size. """ if size == Pagination.DEFAULT_PAGE_SIZE: return str(base_url) return str(base_url.clone().add("page-size", str(size))) class PageSizeOption: """A page size option. Args: size: The page size. url: The URL of this page size. Attributes: size (int): The page size. url (str): The URL for this page size. """ def __init__(self, size: int, url: str): self.size = size self.url = url class PaginationException(Exception): """The exception thrown with invalid pagination parameters. Args: url_builder: The canonical URL to redirect to. Attributes: url (str): The canonical URL to redirect to. """ def __init__(self, url_builder: UrlBuilder): self.url = str(url_builder)