mia-accounting-django/mia_core/utils.py

636 lines
21 KiB
Python
Raw Normal View History

# The core application of the Mia project.
# by imacat <imacat@mail.imacat.idv.tw>, 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 datetime
import random
import urllib.parse
from typing import Dict, List, Any, Type
from django.conf import settings
from django.db.models import Model
from django.http import HttpRequest
from django.urls import reverse
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]
def parse_date(s: str):
"""Parses a string for a date. The date can be either YYYY-MM-DD,
Y/M/D, or M/D/Y.
Args:
s: The string.
Returns:
The date.
Raises:
ValueError: When the string is not in a valid format.
"""
for f in ["%Y-%m-%d", "%m/%d/%Y", "%Y/%m/%d"]:
try:
return datetime.datetime.strptime(s, f)
except ValueError:
pass
raise ValueError(F"not a recognized date {s}")
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())
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:
2020-07-02 00:50:27 +08:00
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:
2020-07-02 00:50:27 +08:00
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:
2020-07-07 21:06:11 +08:00
if len(self.params) == 0:
return self.path
return self.path + "?" + "&".join([
2020-07-07 21:06:11 +08:00
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:
2020-07-02 00:50:27 +08:00
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.
2020-07-19 21:08:10 +08:00
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(
2020-07-19 21:08:10 +08:00
(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
2020-07-19 21:08:10 +08:00
self.items = items
return
start_no = self.page_size * (self.page_no - 1)
2020-07-19 21:08:10 +08:00
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)
CDN_LIBRARIES = {
"jquery": {"css": [],
"js": ["https://code.jquery.com/jquery-3.5.1.min.js"]},
"bootstrap4": {
"css": [("https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/"
"bootstrap.min.css")],
"js": [("https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/"
"popper.min.js"),
("https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/"
"bootstrap.min.js")]},
"font-awesome-5": {
"css": ["https://use.fontawesome.com/releases/v5.14.0/css/all.css"],
"js": []},
"bootstrap4-datatables": {
"css": [("https://cdn.datatables.net/1.10.21/css/"
"jquery.dataTables.min.css"),
("https://cdn.datatables.net/1.10.21/css/"
"dataTables.bootstrap4.min.css")],
"js": [("https://cdn.datatables.net/1.10.21/js/"
"jquery.dataTables.min.js"),
("https://cdn.datatables.net/1.10.21/js/"
"dataTables.bootstrap4.min.js")]},
"jquery-ui": {"css": [("https://cdnjs.cloudflare.com/ajax/libs/jqueryui/"
"1.12.1/jquery-ui.min.css")],
"js": [("https://cdnjs.cloudflare.com/ajax/libs/jqueryui/"
"1.12.1/jquery-ui.min.js")]},
"bootstrap4-tempusdominus": {
"css": [("https://cdnjs.cloudflare.com/ajax/libs/"
"tempusdominus-bootstrap-4/5.1.2/css/"
"tempusdominus-bootstrap-4.min.css")],
"js": [("https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.27.0/"
"moment-with-locales.min.js"),
("https://cdnjs.cloudflare.com/ajax/libs/"
"tempusdominus-bootstrap-4/5.1.2/js/"
"tempusdominus-bootstrap-4.js")]},
"decimal-js": {"css": [],
"js": [("https://cdnjs.cloudflare.com/ajax/libs/decimal.js/"
"10.2.0/decimal.min.js")]},
"period-chooser": {"css": ["mia_core/css/period-chooser.css"],
"js": ["mia_core/js/period-chooser.js"]}
}
DEFAULT_LIBS = []
class CssAndJavaScriptLibraries:
"""The CSS and JavaScript library resolver."""
AVAILABLE_LIBS: List[str] = ["jquery", "bootstrap4", "font-awesome-5",
"bootstrap4-datatables", "jquery-ui",
"bootstrap4-tempusdominus", "decimal-js",
"i18n", "period-chooser"]
def __init__(self, *args):
self._use: Dict[str, bool] = {x: False for x in self.AVAILABLE_LIBS}
self._add_default_libs()
# The specified libraries
if len(args) > 0:
libs = args[0]
invalid = [x for x in libs if x not in self.AVAILABLE_LIBS]
if len(invalid) > 0:
raise NameError("library %s invalid" % ", ".join(invalid))
for lib in libs:
self._use[lib] = True
self._css = []
try:
self._css = self._css + settings.DEFAULT_CSS
except AttributeError:
pass
self._js = []
try:
self._css = self._css + settings.DEFAULT_JS
except AttributeError:
pass
def _add_default_libs(self):
"""Adds the default libraries."""
invalid = [x for x in DEFAULT_LIBS if x not in self.AVAILABLE_LIBS]
if len(invalid) > 0:
raise NameError("library %s invalid" % ", ".join(invalid))
for lib in DEFAULT_LIBS:
self._use[lib] = True
def use(self, *args) -> None:
"""Use the specific libraries.
Args:
args: The libraries.
"""
if len(args) == 0:
return
libs = args[0]
invalid = [x for x in libs if x not in self.AVAILABLE_LIBS]
if len(invalid) > 0:
raise NameError("library %s invalid" % ", ".join(invalid))
for lib in libs:
self._use[lib] = True
def add_css(self, css) -> None:
"""Adds a custom CSS file."""
self._css.append(css)
def add_js(self, js) -> None:
"""Adds a custom JavaScript file."""
self._js.append(js)
def css(self) -> List[str]:
"""Returns the stylesheet files to use."""
use: Dict[str, bool] = self._solve_use_dependencies()
css = []
for lib in [x for x in self.AVAILABLE_LIBS if use[x]]:
if lib == "i18n":
continue
try:
css = css + settings.STATIC_LIBS[lib]["css"]
except AttributeError:
css = css + CDN_LIBRARIES[lib]["css"]
except TypeError:
css = css + CDN_LIBRARIES[lib]["css"]
except KeyError:
css = css + CDN_LIBRARIES[lib]["css"]
return css + self._css
def js(self) -> List[str]:
"""Returns the JavaScript files to use."""
use: Dict[str, bool] = self._solve_use_dependencies()
js = []
for lib in [x for x in self.AVAILABLE_LIBS if use[x]]:
if lib == "i18n":
js.append(reverse("javascript-catalog"))
continue
try:
js = js + settings.STATIC_LIBS[lib]["js"]
except AttributeError:
js = js + CDN_LIBRARIES[lib]["js"]
except TypeError:
js = js + CDN_LIBRARIES[lib]["js"]
except KeyError:
js = js + CDN_LIBRARIES[lib]["js"]
return js + self._js
def _solve_use_dependencies(self) -> Dict[str, bool]:
"""Solves and returns the library dependencies."""
use: Dict[str, bool] = {x: self._use[x] for x in self._use}
if use["period-chooser"]:
use["bootstrap4-tempusdominus"] = True
if use["bootstrap4-tempusdominus"]:
use["bootstrap4"] = True
if use["bootstrap4-datatables"]:
use["bootstrap4"] = True
if use["jquery-ui"]:
use["jquery"] = True
if use["bootstrap4"]:
use["jquery"] = True
return use
def add_default_libs(*args) -> None:
"""Adds the specified libraries to the default CSS and JavaScript
libraries.
Args:
args: The libraries to be added to the default libraries
"""
libs = args
invalid = [x for x in libs
if x not in CssAndJavaScriptLibraries.AVAILABLE_LIBS]
if len(invalid) > 0:
raise NameError("library %s invalid" % ", ".join(invalid))
for lib in libs:
if lib not in DEFAULT_LIBS:
DEFAULT_LIBS.append(lib)