Moved the source files to the "src" subdirectory.

This commit is contained in:
2022-12-05 08:46:20 +08:00
parent 24c3b868e0
commit 3cef0d7009
65 changed files with 2 additions and 1 deletions

0
src/mia_core/__init__.py Normal file
View File

5
src/mia_core/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class MiaCoreConfig(AppConfig):
name = 'mia_core'

View File

@ -0,0 +1,135 @@
# Traditional Chinese PO file for the Mia core application
# Copyright (C) 2020 imacat
# This file is distributed under the same license as the Mia package.
# imacat <imacat@mail.imacat.idv.tw>, 2020.
#
msgid ""
msgstr ""
"Project-Id-Version: mia-core 3.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-01-17 00:24+0800\n"
"PO-Revision-Date: 2021-01-17 00:29+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language-Team: Traditional Chinese <imacat@mail.imacat.idv.tw>\n"
"Language: Traditional Chinese\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: mia_core/period.py:452 mia_core/period.py:487 mia_core/period.py:505
#: mia_core/period.py:518 mia_core/period.py:564
#, python-format
msgid "In %s"
msgstr "%s"
#: mia_core/period.py:462
#, python-format
msgid "Since %s"
msgstr "%s至今"
#: mia_core/period.py:475 mia_core/period.py:496 mia_core/period.py:575
#, python-format
msgid "Until %s"
msgstr "至%s前"
#: mia_core/period.py:504
msgid "All Time"
msgstr "全部"
#: mia_core/period.py:588 mia_core/period.py:621
#: mia_core/templates/mia_core/include/period-chooser.html:60
#: mia_core/templatetags/mia_core.py:219
msgid "This Month"
msgstr "這個月"
#: mia_core/period.py:629
#: mia_core/templates/mia_core/include/period-chooser.html:63
#: mia_core/templatetags/mia_core.py:226
msgid "Last Month"
msgstr "上個月"
#: mia_core/period.py:644
#: mia_core/templates/mia_core/include/period-chooser.html:76
msgid "This Year"
msgstr "今年"
#: mia_core/period.py:646
#: mia_core/templates/mia_core/include/period-chooser.html:79
msgid "Last Year"
msgstr "去年"
#: mia_core/period.py:661
#: mia_core/templates/mia_core/include/period-chooser.html:95
#: mia_core/templatetags/mia_core.py:187
msgid "Today"
msgstr "今天"
#: mia_core/period.py:663
#: mia_core/templates/mia_core/include/period-chooser.html:98
#: mia_core/templatetags/mia_core.py:190
msgid "Yesterday"
msgstr "昨天"
#: mia_core/templates/mia_core/include/period-chooser.html:37
msgid "Choosing Your Period"
msgstr "選擇時間區間"
#: mia_core/templates/mia_core/include/period-chooser.html:45
msgid "Month"
msgstr "月"
#: mia_core/templates/mia_core/include/period-chooser.html:48
msgid "Year"
msgstr "年"
#: mia_core/templates/mia_core/include/period-chooser.html:51
msgid "Day"
msgstr "日"
#: mia_core/templates/mia_core/include/period-chooser.html:54
msgid "Custom"
msgstr "自訂"
#: mia_core/templates/mia_core/include/period-chooser.html:66
msgid "Since Last Month"
msgstr "上個月至今"
#: mia_core/templates/mia_core/include/period-chooser.html:103
msgid "Date:"
msgstr "日期:"
#: mia_core/templates/mia_core/include/period-chooser.html:107
#: mia_core/templates/mia_core/include/period-chooser.html:125
msgid "Confirm"
msgstr "確定"
#: mia_core/templates/mia_core/include/period-chooser.html:113
msgid "All"
msgstr "全部"
#: mia_core/templates/mia_core/include/period-chooser.html:117
msgid "From:"
msgstr "從:"
#: mia_core/templates/mia_core/include/period-chooser.html:121
msgid "To:"
msgstr "到:"
#: mia_core/templatetags/mia_core.py:192
msgid "Tomorrow"
msgstr "明天"
#: mia_core/utils.py:342
msgctxt "Pagination|"
msgid "Previous"
msgstr "上一頁"
#: mia_core/utils.py:370 mia_core/utils.py:391
msgctxt "Pagination|"
msgid "..."
msgstr "…"
#: mia_core/utils.py:410
msgctxt "Pagination|"
msgid "Next"
msgstr "下一頁"

View File

@ -0,0 +1,130 @@
# The accounting application of the Mia project.
# by imacat <imacat@mail.imacat.idv.tw>, 2020/9/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 command to populate the database with the accounts.
"""
import os
import re
from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand, CommandParser, CommandError, \
call_command
from django.utils import timezone
from opencc import OpenCC
class Command(BaseCommand):
"""Updates the revision date, converts the Traditional Chinese
translation into Simplified Chinese, and then calls the
compilemessages command.
"""
help = ("Updates the revision date, converts the Traditional Chinese"
" translation into Simplified Chinese, and then calls the"
" compilemessages command.")
def __init__(self):
super().__init__()
self._cc: OpenCC = OpenCC("tw2sp")
self._now: str = timezone.localtime().strftime("%Y-%m-%d %H:%M%z")
def add_arguments(self, parser):
"""Adds command line arguments to the parser.
Args:
parser (CommandParser): The command line argument parser.
"""
parser.add_argument("app_dir", nargs="+",
help=("One or more application directories that"
" contains their locale subdirectories"))
parser.add_argument("--domain", "-d", action="append",
choices=["django", "djangojs"], required=True,
help="The domain, either django or djangojs")
def handle(self, *args, **options):
"""Runs the command.
Args:
*args (list[str]): The command line arguments.
**options (dict[str,str]): The command line switches.
"""
locale_dirs = [os.path.join(settings.BASE_DIR, x, "locale")
for x in options["app_dir"]]
missing = [x for x in locale_dirs if not os.path.isdir(x)]
if len(missing) > 0:
error = "Directories not exist: " + ", ".join(missing)
raise CommandError(error, returncode=1)
domains = [x for x in ["django", "djangojs"] if x in options["domain"]]
for locale_dir in locale_dirs:
for domain in domains:
self._handle_po(locale_dir, domain)
call_command("compilemessages")
def _handle_po(self, locale_dir: str, domain: str) -> None:
"""Updates a PO file in a specific directory
Args:
locale_dir: the locale directory that contains the PO file
domain: The domain, either django or djangojs.
"""
zh_hant = os.path.join(
locale_dir, "zh_Hant", "LC_MESSAGES", F"{domain}.po")
zh_hans = os.path.join(
locale_dir, "zh_Hans", "LC_MESSAGES", F"{domain}.po")
self._update_rev_date(zh_hant)
self._convert_chinese(zh_hant, zh_hans)
def _update_rev_date(self, file: str) -> None:
"""Updates the revision date of the PO file.
Args:
file: the PO file as its full path.
"""
size = Path(file).stat().st_size
with open(file, "r+") as f:
content = f.read(size)
content = re.sub("\n\"PO-Revision-Date: [^\n]*\"\n",
F"\n\"PO-Revision-Date: {self._now}\\\\n\"\n",
content)
f.seek(0)
f.write(content)
def _convert_chinese(self, zh_hant: str, zh_hans: str) -> None:
"""Creates the Simplified Chinese PO file from the Traditional
Chinese PO file.
Args:
zh_hant: the Traditional Chinese PO file as its full path.
zh_hans: the Simplified Chinese PO file as its full path.
"""
size = Path(zh_hant).stat().st_size
with open(zh_hant, "r") as f:
content = f.read(size)
content = self._cc.convert(content)
content = re.sub("^# Traditional Chinese PO file for the ",
"# Simplified Chinese PO file for the ", content)
content = re.sub("\n\"PO-Revision-Date: [^\n]*\"\n",
F"\n\"PO-Revision-Date: {self._now}\\\\n\"\n",
content)
content = re.sub("\n\"Language-Team: Traditional Chinese",
"\n\"Language-Team: Simplified Chinese", content)
content = re.sub("\n\"Language: [^\n]*\"\n",
"\n\"Language: Simplified Chinese\\\\n\"\n",
content)
with open(zh_hans, "w") as f:
f.write(content)

236
src/mia_core/models.py Normal file
View File

@ -0,0 +1,236 @@
# The core application of the Mia project.
# by imacat <imacat@mail.imacat.idv.tw>, 2020/6/29
# 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 data models of the Mia core application.
"""
from typing import Any, Dict, List
from dirtyfields import DirtyFieldsMixin
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from mia_core.utils import new_pk, Language
class RandomPkModel(models.Model):
"""The abstract data model that uses 9-digit random primary keys."""
id = models.PositiveIntegerField(primary_key=True)
def save(self, force_insert=False, force_update=False, using=None,
update_fields=None):
if self.pk is None:
self.pk = new_pk(self.__class__)
super().save(force_insert=force_insert, force_update=force_update,
using=using, update_fields=update_fields)
class Meta:
abstract = True
class StampedModel(models.Model):
"""The abstract base model that has created_at, created_by, updated_at, and
updated_by."""
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
related_name="created_%(app_label)s_%(class)s")
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT,
related_name="updated_%(app_label)s_%(class)s")
def __init__(self, *args, **kwargs):
self.current_user = None
if "current_user" in kwargs:
self.current_user = kwargs["current_user"]
del kwargs["current_user"]
super().__init__(*args, **kwargs)
def save(self, force_insert=False, force_update=False, using=None,
update_fields=None):
if self.current_user is None:
raise AttributeError(
F"Missing current_user in {self.__class__.__name__}")
try:
self.created_by
except ObjectDoesNotExist as e:
self.created_by = self.current_user
self.updated_by = self.current_user
super().save(force_insert=force_insert, force_update=force_update,
using=using, update_fields=update_fields)
class Meta:
abstract = True
class LocalizedModel(models.Model):
"""An abstract localized model."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if getattr(self, "_l10n", None) is None:
self._l10n: Dict[str, Dict[str, Any]] = {}
# Amends the is_dirty() method in DirtyFieldsMixin
if isinstance(self, DirtyFieldsMixin):
old_is_dirty = getattr(self, "is_dirty", None)
def new_is_dirty(check_relationship=False, check_m2m=None) -> bool:
"""Returns whether the current data model is changed."""
if old_is_dirty(check_relationship=check_relationship,
check_m2m=check_m2m):
return True
default_language = self._get_default_language()
for name in self._l10n:
for language in self._l10n[name]:
new_value = self._l10n[name][language]
if language == default_language:
if getattr(self, name + "_l10n") != new_value:
return True
else:
l10n_rec = self._get_l10n_set() \
.filter(name=name, language=language) \
.first()
if l10n_rec is None or l10n_rec.value != new_value:
return True
return False
self.is_dirty = new_is_dirty
def save(self, force_insert=False, force_update=False, using=None,
update_fields=None):
"""Saves the data model, along with the localized contents."""
default_language = self._get_default_language()
l10n_to_save: List[models.Model] = []
if getattr(self, "_l10n", None) is not None:
for name in self._l10n:
for language in self._l10n[name]:
new_value = self._l10n[name][language]
if language == default_language:
setattr(self, name + "_l10n", new_value)
else:
current_value = getattr(self, name + "_l10n")
if current_value is None or current_value == "":
setattr(self, name + "_l10n", new_value)
if self.pk is None:
l10n_rec = None
else:
l10n_rec = self._get_l10n_set()\
.filter(name=name, language=language)\
.first()
if l10n_rec is None:
l10n_to_save.append(self._get_l10n_set().model(
master=self, name=name,
language=language,
value=self._l10n[name][language]))
elif l10n_rec.value != new_value:
if getattr(self, name + "_l10n") == l10n_rec.value:
setattr(self, name + "_l10n", new_value)
l10n_rec.value = new_value
l10n_to_save.append(l10n_rec)
super().save(force_insert=force_insert, force_update=force_update,
using=using, update_fields=update_fields)
for l10n_rec in l10n_to_save:
if isinstance(self, StampedModel)\
and isinstance(l10n_rec, StampedModel):
l10n_rec.current_user = self.current_user
l10n_rec.save(force_insert=force_insert, force_update=force_update,
using=using, update_fields=update_fields)
def _get_l10n_set(self):
"""Returns the related localization data model."""
l10n_set = getattr(self, "l10n_set", None)
if l10n_set is None:
raise AttributeError("Please define the localization data model.")
return l10n_set
def _get_default_language(self) -> str:
"""Returns the default language."""
default = getattr(self.__class__, "DEFAULT_LANGUAGE", None)
return Language.default().id if default is None else default
def get_l10n(self, name: str) -> Any:
"""Returns the value of a localized field in the current language.
Args:
name: The field name.
Returns:
The value of this field in the current language.
"""
return self.get_l10n_in(name, Language.current().id)
def get_l10n_in(self, name: str, language: str) -> Any:
"""Returns the value of a localized field in a specific language.
Args:
name: The field name.
language: The language ID.
Returns:
The value of this field in this language.
"""
if getattr(self, "_l10n", None) is None:
self._l10n: Dict[str, Dict[str, Any]] = {}
if name not in self._l10n:
self._l10n[name]: Dict[str, Any] = {}
if language not in self._l10n[name]:
if language != self._get_default_language():
l10n_rec = self._get_l10n_set() \
.filter(name=name, language=language) \
.first()
self._l10n[name][language] = getattr(self, name + "_l10n") \
if l10n_rec is None else l10n_rec.value
else:
self._l10n[name][language] = getattr(self, name + "_l10n")
return self._l10n[name][language]
def set_l10n(self, name: str, value: Any) -> None:
"""Sets the value of a localized field in the current language.
Args:
name: The field name.
value: The value.
"""
self.set_l10n_in(name, Language.current().id, value)
def set_l10n_in(self, name: str, language: str, value: Any) -> None:
"""Sets the value of a localized field in a specific language.
Args:
name: The field name.
language: The language ID.
value: The value.
"""
if getattr(self, "_l10n", None) is None:
self._l10n: Dict[str, Dict[str, Any]] = {}
if name not in self._l10n:
self._l10n[name]: Dict[str, Any] = {}
self._l10n[name][language] = value
class Meta:
abstract = True
class L10nModel(models.Model):
"""The abstract base localization model."""
name = models.CharField(max_length=128)
language = models.CharField(max_length=7)
value = models.CharField(max_length=65535)
class Meta:
abstract = True

667
src/mia_core/period.py Normal file
View File

@ -0,0 +1,667 @@
# The accounting application of the Mia project.
# by imacat <imacat@mail.imacat.idv.tw>, 2020/6/30
# 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 period chooser utilities of the Mia core application.
"""
import datetime
import re
from typing import Optional, List
from django.core.serializers.json import DjangoJSONEncoder
from django.template import defaultfilters
from django.utils import dateformat, timezone
from django.utils.translation import gettext
from mia_core.utils import Language
class Period:
"""The template helper for the period chooser.
Args:
spec: The current period specification
data_start: The available first day of the data.
data_end: The available last day of the data.
Raises:
ValueError: When the period specification is invalid.
"""
def __init__(self, spec: str = None, data_start: datetime.date = None,
data_end: datetime.date = None):
# Raises ValueError
self._period = self.Parser(spec)
self._data_start = data_start
self._data_end = data_end
@property
def spec(self) -> str:
"""Returns the period specification.
Returns:
The period specification.
"""
return self._period.spec
@property
def start(self) -> datetime.date:
"""Returns the start day of the currently-specified period.
Returns:
The start day of the currently-specified period.
"""
return self._period.start
@property
def end(self) -> datetime.date:
"""Returns the end day of the currently-specified period.
Returns:
The end day of the currently-specified period.
"""
return self._period.end
@property
def description(self) -> str:
"""Returns the text description of the currently-specified period.
Returns:
The text description of the currently-specified period
"""
return self._period.description
@property
def prep_desc(self) -> str:
"""Returns the text description with preposition of the
currently-specified period.
Returns:
The text description with preposition of the currently-specified
period.
"""
return self._period.prep_desc
@staticmethod
def _get_last_month_start() -> datetime.date:
"""Returns the first day of the last month.
Returns:
The first day of the last month.
"""
today = timezone.localdate()
month = today.month - 1
year = today.year
if month < 1:
month = 12
year = year - 1
return datetime.date(year, month, 1)
@staticmethod
def _get_next_month_start() -> datetime.date:
"""Returns the first day of the next month.
Returns:
The first day of the next month.
"""
today = timezone.localdate()
month = today.month + 1
year = today.year
if month > 12:
month = 1
year = year + 1
return datetime.date(year, month, 1)
def this_month(self) -> Optional[str]:
"""Returns the specification of this month.
Returns:
The specification of this month, or None if there is no data in or
before this month.
"""
if self._data_start is None:
return None
today = timezone.localdate()
first_month_start = datetime.date(
self._data_start.year, self._data_start.month, 1)
if today < first_month_start:
return None
return dateformat.format(today, "Y-m")
def last_month(self) -> Optional[str]:
"""Returns the specification of last month.
Returns:
The specification of last month, or None if there is no data in or
before last month.
"""
if self._data_start is None:
return None
last_month_start = self._get_last_month_start()
first_month_start = datetime.date(
self._data_start.year, self._data_start.month, 1)
if last_month_start < first_month_start:
return None
return dateformat.format(last_month_start, "Y-m")
def since_last_month(self) -> Optional[str]:
"""Returns the specification since last month.
Returns:
The specification since last month, or None if there is no data in
or before last month.
"""
last_month = self.last_month()
if last_month is None:
return None
return last_month + "-"
def has_months_to_choose(self) -> bool:
"""Returns whether there are months to choose besides this month and
last month.
Returns:
True if there are months to choose besides this month and last
month, or False otherwise.
"""
if self._data_start is None:
return False
if self._data_start < self._get_last_month_start():
return True
if self._data_end >= self._get_next_month_start():
return True
return False
def chosen_month(self) -> Optional[str]:
"""Returns the specification of the chosen month, or None if the
current period is not a month or is out of available data range.
Returns:
The specification of the chosen month, or None if the current
period is not a month or is out of available data range.
"""
if self._data_start is None:
return None
m = re.match("^[0-9]{4}-[0-2]{2}", self._period.spec)
if m is None:
return None
if self._period.end < self._data_start:
return None
if self._period.start > self._data_end:
return None
return self._period.spec
def this_year(self) -> Optional[str]:
"""Returns the specification of this year.
Returns:
The specification of this year, or None if there is no data in or
before this year.
"""
if self._data_start is None:
return None
this_year = timezone.localdate().year
if this_year < self._data_start.year:
return None
return str(this_year)
def last_year(self) -> Optional[str]:
"""Returns the specification of last year.
Returns:
The specification of last year, or None if there is no data in or
before last year.
"""
if self._data_start is None:
return None
last_year = timezone.localdate().year - 1
if last_year < self._data_start.year:
return None
return str(last_year)
def has_years_to_choose(self) -> bool:
"""Returns whether there are years to choose besides this year and
last year.
Returns:
True if there are years to choose besides this year and last year,
or False otherwise.
"""
if self._data_start is None:
return False
this_year = timezone.localdate().year
if self._data_start.year < this_year - 1:
return True
if self._data_end.year > this_year:
return True
return False
def years_to_choose(self) -> Optional[List[str]]:
"""Returns the years to choose besides this year and last year.
Returns:
The years to choose besides this year and last year, or None if
there is no data.
"""
if self._data_start is None:
return None
this_year = timezone.localdate().year
before = [str(x) for x in range(
self._data_start.year, this_year - 1)]
after = [str(x) for x in range(
self._data_end.year, this_year, -1)]
return after + before[::-1]
def today(self) -> Optional[None]:
"""Returns the specification of today.
Returns:
The specification of today, or None if there is no data in or
before today.
"""
if self._data_start is None:
return None
today = timezone.localdate()
if today < self._data_start or today > self._data_end:
return None
return dateformat.format(today, "Y-m-d")
def yesterday(self) -> Optional[str]:
"""Returns the specification of yesterday.
Returns:
The specification of yesterday, or None if there is no data in or
before yesterday.
"""
if self._data_start is None:
return None
yesterday = timezone.localdate() - datetime.timedelta(days=1)
if yesterday < self._data_start or yesterday > self._data_end:
return None
return dateformat.format(yesterday, "Y-m-d")
def chosen_day(self) -> str:
"""Returns the specification of the chosen day.
Returns:
The specification of the chosen day, or the start day of the period
if the current period is not a day.
"""
return dateformat.format(self._period.start, "Y-m-d")
def has_days_to_choose(self) -> bool:
"""Returns whether there are more than one day to choose from.
Returns:
True if there are more than one day to choose from, or False
otherwise.
"""
if self._data_start is None:
return False
if self._data_start == self._data_end:
return False
return True
def first_day(self) -> Optional[str]:
"""Returns the specification of the available first day.
Returns:
The specification of the available first day, or None if there is
no data.
"""
if self._data_start is None:
return None
return dateformat.format(self._data_start, "Y-m-d")
def last_day(self) -> Optional[str]:
"""Returns the specification of the available last day.
Returns:
The specification of the available last day, or None if there is no
data.
"""
if self._data_end is None:
return None
return dateformat.format(self._data_end, "Y-m-d")
def chosen_start(self) -> Optional[str]:
"""Returns the specification of of the first day of the
specified period.
Returns:
The specification of of the first day of the specified period, or
None if there is no data.
"""
if self._data_start is None:
return None
day = self._period.start \
if self._period.start >= self._data_start \
else self._data_start
return dateformat.format(day, "Y-m-d")
def chosen_end(self) -> Optional[str]:
"""Returns the specification of of the last day of the
specified period.
Returns:
The specification of of the last day of the specified period, or
None if there is data.
"""
if self._data_end is None:
return None
day = self._period.end \
if self._period.end <= self._data_end \
else self._data_end
return dateformat.format(day, "Y-m-d")
def period_before(self) -> Optional[str]:
"""Returns the specification of the period before the current period.
Returns:
The specification of the period before the current period, or None
if there is no data before the current period.
"""
if self._data_start is None:
return None
if self.start <= self._data_start:
return None
previous_day = self.start - datetime.timedelta(days=1)
if re.match("^[0-9]{4}$", self.spec):
return "-" + str(previous_day.year)
if re.match("^[0-9]{4}-[0-9]{2}$", self.spec):
return dateformat.format(previous_day, "-Y-m")
return dateformat.format(previous_day, "-Y-m-d")
def month_picker_params(self) -> Optional[str]:
"""Returns the parameters for the month-picker, as a JSON text string.
Returns:
The parameters for the month-picker, as a JSON text string, or None
if there is no data.
"""
if self._data_start is None:
return None
start = datetime.date(self._data_start.year, self._data_start.month, 1)
return DjangoJSONEncoder().encode({
"locale": Language.current().locale,
"minDate": start,
"maxDate": self._data_end,
"defaultDate": self.chosen_month(),
})
@staticmethod
def default_spec() -> str:
"""Returns the specification for the default period.
Returns:
str: The specification for the default period
"""
return dateformat.format(timezone.localdate(), "Y-m")
class Parser:
"""The period parser.
Args:
spec (str|None): The period specification.
Raises:
ValueError: When the period specification is invalid.
Attributes:
spec (str): The currently-using period specification.
start (datetime.date): The start of the period.
end (datetime.date): The end of the period.
description (str): The text description of the period.
prep_desc (str): The text description with preposition.
"""
VERY_START: datetime.date = datetime.date(1990, 1, 1)
def __init__(self, spec: str):
self.spec = None
self.start = None
self.end = None
self.description = None
self.prep_desc = None
if spec is None:
self._set_this_month()
return
self.spec = spec
# A specific month
m = re.match("^([0-9]{4})-([0-9]{2})$", spec)
if m is not None:
year = int(m.group(1))
month = int(m.group(2))
# Raises ValueError
self.start = datetime.date(year, month, 1)
self.end = self._month_last_day(self.start)
self.description = self._month_text(year, month)
self.prep_desc = gettext("In %s") % self.description
return
# From a specific month
m = re.match("^([0-9]{4})-([0-9]{2})-$", spec)
if m is not None:
year = int(m.group(1))
month = int(m.group(2))
# Raises ValueError
self.start = datetime.date(year, month, 1)
self.end = self._month_last_day(timezone.localdate())
self.description = gettext("Since %s")\
% self._month_text(year, month)
self.prep_desc = self.description
return
# Until a specific month
m = re.match("^-([0-9]{4})-([0-9]{2})$", spec)
if m is not None:
year = int(m.group(1))
month = int(m.group(2))
# Raises ValueError
until_month = datetime.date(year, month, 1)
self.start = Period.Parser.VERY_START
self.end = self._month_last_day(until_month)
self.description = gettext("Until %s")\
% self._month_text(year, month)
self.prep_desc = self.description
return
# A specific year
m = re.match("^([0-9]{4})$", spec)
if m is not None:
year = int(m.group(1))
# Raises ValueError
self.start = datetime.date(year, 1, 1)
self.end = datetime.date(year, 12, 31)
self.description = self._year_text(year)
self.prep_desc = gettext("In %s") % self.description
return
# Until a specific year
m = re.match("^-([0-9]{4})$", spec)
if m is not None:
year = int(m.group(1))
# Raises ValueError
self.end = datetime.date(year, 12, 31)
self.start = Period.Parser.VERY_START
self.description = gettext("Until %s")\
% self._year_text(year)
self.prep_desc = self.description
return
# All time
if spec == "-":
self.start = Period.Parser.VERY_START
self.end = self._month_last_day(timezone.localdate())
self.description = gettext("All Time")
self.prep_desc = gettext("In %s") % self.description
return
# A specific date
m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$",
spec)
if m is not None:
# Raises ValueError
self.start = datetime.date(
int(m.group(1)),
int(m.group(2)),
int(m.group(3)))
self.end = self.start
self.description = self._date_text(self.start)
self.prep_desc = gettext("In %s") % self.description
return
# A specific date period
m = re.match(("^([0-9]{4})-([0-9]{2})-([0-9]{2})"
"-([0-9]{4})-([0-9]{2})-([0-9]{2})$"),
spec)
if m is not None:
# Raises ValueError
self.start = datetime.date(
int(m.group(1)),
int(m.group(2)),
int(m.group(3)))
self.end = datetime.date(
int(m.group(4)),
int(m.group(5)),
int(m.group(6)))
today = timezone.localdate()
# Spans several years
if self.start.year != self.end.year:
self.description = "%s-%s" % (
defaultfilters.date(self.start, "Y/n/j"),
defaultfilters.date(self.end, "Y/n/j"))
# Spans several months
elif self.start.month != self.end.month:
if self.start.year != today.year:
self.description = "%s-%s" % (
defaultfilters.date(self.start, "Y/n/j"),
defaultfilters.date(self.end, "n/j"))
else:
self.description = "%s-%s" % (
defaultfilters.date(self.start, "n/j"),
defaultfilters.date(self.end, "n/j"))
# Spans several days
elif self.start.day != self.end.day:
if self.start.year != today.year:
self.description = "%s-%s" % (
defaultfilters.date(self.start, "Y/n/j"),
defaultfilters.date(self.end, "j"))
else:
self.description = "%s-%s" % (
defaultfilters.date(self.start, "n/j"),
defaultfilters.date(self.end, "j"))
# At the same day
else:
self.spec = dateformat.format(self.start, "Y-m-d")
self.description = self._date_text(self.start)
self.prep_desc = gettext("In %s") % self.description
return
# Until a specific day
m = re.match("^-([0-9]{4})-([0-9]{2})-([0-9]{2})$", spec)
if m is not None:
# Raises ValueError
self.end = datetime.date(
int(m.group(1)),
int(m.group(2)),
int(m.group(3)))
self.start = Period.Parser.VERY_START
self.description = gettext("Until %s")\
% self._date_text(self.end)
self.prep_desc = self.description
return
# Wrong period format
raise ValueError
def _set_this_month(self) -> None:
"""Sets the period to this month."""
today = timezone.localdate()
self.spec = dateformat.format(today, "Y-m")
self.start = datetime.date(today.year, today.month, 1)
self.end = self._month_last_day(self.start)
self.description = gettext("This Month")
@staticmethod
def _month_last_day(day: datetime.date) -> datetime.date:
"""Calculates and returns the last day of a month.
Args:
day: A day in the month.
Returns:
The last day in the month
"""
next_month = day.month + 1
next_year = day.year
if next_month > 12:
next_month = 1
next_year = next_year + 1
return datetime.date(
next_year, next_month, 1) - datetime.timedelta(days=1)
@staticmethod
def _month_text(year: int, month: int) -> str:
"""Returns the text description of a month.
Args:
year: The year.
month: The month.
Returns:
The description of the month.
"""
today = timezone.localdate()
if year == today.year and month == today.month:
return gettext("This Month")
prev_month = today.month - 1
prev_year = today.year
if prev_month < 1:
prev_month = 12
prev_year = prev_year - 1
prev = datetime.date(prev_year, prev_month, 1)
if year == prev.year and month == prev.month:
return gettext("Last Month")
return "%d/%d" % (year, month)
@staticmethod
def _year_text(year: int) -> str:
"""Returns the text description of a year.
Args:
year: The year.
Returns:
The description of the year.
"""
this_year = timezone.localdate().year
if year == this_year:
return gettext("This Year")
if year == this_year - 1:
return gettext("Last Year")
return str(year)
@staticmethod
def _date_text(day: datetime.date) -> str:
"""Returns the text description of a day.
Args:
day: The date.
Returns:
The description of the day.
"""
today = timezone.localdate()
if day == today:
return gettext("Today")
elif day == today - datetime.timedelta(days=1):
return gettext("Yesterday")
elif day.year != today.year:
return defaultfilters.date(day, "Y/n/j")
else:
return defaultfilters.date(day, "n/j")

View File

@ -0,0 +1,26 @@
/* The Mia Website
* period-chooser.css: The style sheet for the period chooser
*/
/* Copyright (c) 2019-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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2019/9/12
*/
.period-shortcuts {
margin-bottom: 0.2em;
}

View File

@ -0,0 +1,98 @@
/* The Mia Website
* period-chooser.js: The JavaScript for the period chooser
*/
/* Copyright (c) 2019-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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2019/9/14
*/
// Initializes the period chooser JavaScript.
$(function () {
$(".period-tab")
.on("click", function () {
switchPeriodTab($(this));
});
$("#button-period-day")
.on("click", function () {
window.location = $("#period-url").val()
.replace("0000-00-00", $("#day-picker").val());
});
$("#period-start")
.on("change", function () {
$("#period-end")[0].min = this.value;
});
$("#period-end")
.on("change", function () {
$("#period-start")[0].max = this.value;
});
$("#button-period-custom")
.on("click", function () {
window.location = $("#period-url").val().replace(
"0000-00-00",
$("#period-start").val() + "-" + $("#period-end").val());
});
const monthPickerParams = JSON.parse($("#period-month-picker-params").val());
const monthPicker = $("#month-picker");
monthPicker.datetimepicker({
locale: monthPickerParams.locale,
inline: true,
format: "YYYY-MM",
minDate: monthPickerParams.minDate,
maxDate: monthPickerParams.maxDate,
useCurrent: false,
defaultDate: monthPickerParams.defaultDate,
});
monthPicker.on("change.datetimepicker", function (e) {
monthPickerChanged(e.date);
});
});
/**
* Turns to the page to view the records of a month when the month is
* selected.
*
* @param {moment} newDate the date with the selected new month
* @private
*/
function monthPickerChanged(newDate) {
const year = newDate.year();
const month = newDate.month() + 1;
let periodSpec;
if (month < 10) {
periodSpec = year + "-0" + month;
} else {
periodSpec = year + "-" + month;
}
window.location = $("#period-url").val()
.replace("0000-00-00", periodSpec);
}
/**
* Switch the period chooser to tab.
*
* @param {jQuery} tab the navigation tab corresponding to a type
* of period
* @private
*/
function switchPeriodTab(tab) {
$(".period-content").addClass("d-none");
$("#period-content-" + tab.data("tab")).removeClass("d-none");
$(".period-tab").removeClass("active");
tab.addClass("active");
}

View File

@ -0,0 +1,48 @@
{% comment %}
The core application of the Mia project
pagination.html: The side-wide layout template
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2020/7/1
{% endcomment %}
{# The pagination, if any #}
{% if pagination.is_paged %}
<ul class="pagination">
{% for link in pagination.links %}
{% if link.url is not None %}
<li class="page-item {% if link.is_active %} active {% endif %}{% if not link.is_small_screen %} d-none d-md-inline {% endif %}">
<a class="page-link" href="{{ link.url }}">{{ link.title }}</a>
</li>
{% else %}
<li class="page-item disabled {% if link.is_active %} active {% endif %}{% if not link.is_small_screen %} d-none d-md-inline {% endif %}">
<span class="page-link">{{ link.title }}</span>
</li>
{% endif %}
{% endfor %}
<li class="page-item active d-none d-md-inline">
<div class="page-link dropdown-toggle" data-toggle="dropdown">
{{ pagination.page_size }}
</div>
<div class="dropdown-menu">
{% for option in pagination.page_size_options %}
<a class="dropdown-item {% if pagination.page_size == option.size %} active {% endif %}" href="{{ option.url }}">{{ option.size }}</a>
{% endfor %}
</div>
</li>
</ul>
{% endif %}

View File

@ -0,0 +1,131 @@
{% comment %}
The core application of the Mia project
period-chooser.html: The side-wide layout template
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2020/7/10
{% endcomment %}
{% load i18n %}
{% load mia_core %}
<!-- the period chooser dialog -->
<!-- The Modal -->
<input id="period-url" type="hidden" value="{% url_period "0000-00-00" %}" />
<input id="period-month-picker-params" type="hidden" value="{{ period.month_picker_params }}" />
<div class="modal fade" id="period-modal">
<div class="modal-dialog">
<div class="modal-content">
<!-- Modal Header -->
<div class="modal-header">
<h4 class="modal-title">
<i class="far fa-calendar-alt"></i>
{{ _("Choosing Your Period")|force_escape }}
</h4>
<button type="button" class="close" data-dismiss="modal">&times;</button>
</div>
<!-- Modal body -->
<ul class="nav nav-tabs">
<li class="nav-item">
<span class="period-tab nav-link active" data-tab="month">{{ _("Month")|force_escape }}</span>
</li>
<li class="nav-item">
<span class="period-tab nav-link" data-tab="year">{{ _("Year")|force_escape }}</span>
</li>
<li class="nav-item">
<span class="period-tab nav-link" data-tab="day">{{ _("Day")|force_escape }}</span>
</li>
<li class="nav-item">
<span class="period-tab nav-link" data-tab="custom">{{ _("Custom")|force_escape }}</span>
</li>
</ul>
<div id="period-content-month" class="period-content modal-body">
<div class="period-shortcuts">
{% if period.this_month is not None %}
<a class="btn btn-primary" role="button" href="{% url_period period.this_month %}">{{ _("This Month")|force_escape }}</a>
{% endif %}
{% if period.last_month is not None %}
<a class="btn btn-primary" role="button" href="{% url_period period.last_month %}">{{ _("Last Month")|force_escape }}</a>
{% endif %}
{% if period.since_last_month is not None %}
<a class="btn btn-primary" role="button" href="{% url_period period.since_last_month %}">{{ _("Since Last Month")|force_escape }}</a>
{% endif %}
</div>
{% if period.has_months_to_choose %}
<div id="month-picker" class="col-sm-7"></div>
{% endif %}
</div>
<div id="period-content-year" class="period-content modal-body d-none">
<div class="period-shortcuts">
{% if period.this_year is not None %}
<a class="btn btn-primary" role="button" href="{% url_period period.this_year %}">{{ _("This Year")|force_escape }}</a>
{% endif %}
{% if period.last_year is not None %}
<a class="btn btn-primary" role="button" href="{% url_period period.last_year %}">{{ _("Last Year")|force_escape }}</a>
{% endif %}
</div>
{% if period.has_years_to_choose %}
<ul class="nav nav-pills">
{% for year in period.years_to_choose %}
<li class="nav-item">
<a class="nav-link {% if period.spec == year %} active {% endif %}" href="{% url_period year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<div id="period-content-day" class="period-content modal-body d-none">
<div class="period-shortcuts">
{% if period.today is not None %}
<a class="btn btn-primary" role="button" href="{% url_period period.today %}">{{ _("Today")|force_escape }}</a>
{% endif %}
{% if period.yesterday is not None %}
<a class="btn btn-primary" role="button" href="{% url_period period.yesterday %}">{{ _("Yesterday")|force_escape }}</a>
{% endif %}
</div>
{% if period.has_days_to_choose %}
<div>
<label for="day-picker">{{ _("Date:")|force_escape }}</label>
<input id="day-picker" type="date" value="{{ period.chosen_day }}" min="{{ period.data_start }}" max="{{ period.data_end }}" required="required" />
</div>
<div>
<button id="button-period-day" class="btn btn-primary" type="submit">{{ _("Confirm")|force_escape }}</button>
</div>
{% endif %}
</div>
<div id="period-content-custom" class="period-content modal-body d-none">
<div class="period-shortcuts">
<a class="btn btn-primary" role="button" href="{% url_period "-" %}">{{ _("All")|force_escape }}</a>
</div>
{% if period.has_days_to_choose %}
<div>
<label for="period-start">{{ _("From:")|force_escape }}</label>
<input id="period-start" type="date" value="{{ period.chosen_start }}" min="{{ period.data_start }}" max="{{ period.chosen_end }}" required="required" />
</div>
<div>
<label for="period-end">{{ _("To:")|force_escape }}</label>
<input id="period-end" type="date" value="{{ period.chosen_end }}" min="{{ period.chosen_start }}" max="{{ period.data_end }}" required="required" />
</div>
<div>
<button id="button-period-custom" class="btn btn-primary" type="submit">{{ _("Confirm")|force_escape }}</button>
</div>
{% endif %}
</div>
</div>
</div>
</div>

View File

View File

@ -0,0 +1,278 @@
# 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 template tags and filters of the Mia core application.
"""
import datetime
import re
from datetime import date
from typing import Any
import titlecase
from django import template
from django.http import HttpRequest
from django.template import defaultfilters, RequestContext
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeString
from django.utils.translation import gettext, get_language
from mia_core.utils import UrlBuilder, CssAndJavaScriptLibraries
register = template.Library()
@register.simple_tag(takes_context=True)
def setvar(context: RequestContext, key: str, value: Any) -> str:
"""Sets a variable in the template.
Args:
context: the context
key: The variable name
value: The variable value
Returns:
An empty string.
"""
context.dicts[0][key] = value
return ""
@register.simple_tag(takes_context=True)
def url_period(context: RequestContext, period_spec: str) -> str:
"""Returns the current URL with a new period.
Args:
context: The request context.
period_spec: The period specification.
Returns:
The current URL with the new period.
"""
view_name = "%s:%s" % (
context.request.resolver_match.app_name,
context.request.resolver_match.url_name)
kwargs = context.request.resolver_match.kwargs.copy()
kwargs["period"] = period_spec
namespace = context.request.resolver_match.namespace
return reverse(view_name, kwargs=kwargs, current_app=namespace)
@register.simple_tag(takes_context=True)
def url_with_return(context: RequestContext, url: str) -> str:
"""Returns the URL with the current page added as the "r" query parameter,
so that returning to this page is possible.
Args:
context: The request context.
url: The URL.
Returns:
The URL with the current page added as the "r" query parameter.
"""
return str(UrlBuilder(url).query(
r=str(UrlBuilder(context.request.get_full_path()).remove("s"))))
@register.simple_tag(takes_context=True)
def url_keep_return(context: RequestContext, url: str) -> str:
"""Returns the URL with the current "r" query parameter set, so that the
next processor can still return to the same page.
Args:
context: The request context.
url: The URL.
Returns:
The URL with the current "r" query parameter set.
"""
return str(UrlBuilder(url).query(r=context.request.GET.get("r")))
@register.simple_tag(takes_context=True)
def init_libs(context: RequestContext) -> str:
"""Initializes the static libraries.
Args:
context: The request context.
Returns:
An empty string.
"""
if "libs" not in context.dicts[0]:
context.dicts[0]["libs"] = CssAndJavaScriptLibraries()
return ""
@register.simple_tag(takes_context=True)
def add_lib(context: RequestContext, *args) -> str:
"""Adds CSS and JavaScript libraries.
Args:
context: The request context.
args: The names of the CSS and JavaScript libraries.
Returns:
An empty string.
"""
if "libs" not in context.dicts[0]:
context.dicts[0]["libs"] = CssAndJavaScriptLibraries(args)
else:
context.dicts[0]["libs"].use(args)
return ""
@register.simple_tag(takes_context=True)
def add_css(context: RequestContext, url: str) -> str:
"""Adds a local CSS file. The file is added to the "css" template
list variable.
Args:
context: The request context.
url: The URL or path of the CSS file.
Returns:
An empty string.
"""
if "libs" not in context.dicts[0]:
context.dicts[0]["libs"] = CssAndJavaScriptLibraries()
context.dicts[0]["libs"].add_css(url)
return ""
@register.simple_tag(takes_context=True)
def add_js(context: RequestContext, url: str) -> str:
"""Adds a local JavaScript file. The file is added to the "js" template
list variable.
Args:
context: The request context.
url: The URL or path of the JavaScript file.
Returns:
An empty string.
"""
if "libs" not in context.dicts[0]:
context.dicts[0]["libs"] = CssAndJavaScriptLibraries()
context.dicts[0]["libs"].add_js(url)
return ""
@register.filter
def smart_date(value: datetime.date) -> str:
"""Formats the date for human friendliness.
Args:
value: The date.
Returns:
The human-friendly format of the date.
"""
if value == date.today():
return gettext("Today")
prev_days = (value - date.today()).days
if prev_days == -1:
return gettext("Yesterday")
if prev_days == 1:
return gettext("Tomorrow")
if get_language() == "zh-hant":
if prev_days == -2:
return "前天"
if prev_days == -3:
return "大前天"
if prev_days == 2:
return "後天"
if prev_days == 3:
return "大後天"
if date.today().year == value.year:
return defaultfilters.date(value, "n/j(D)").replace("星期", "")
return defaultfilters.date(value, "Y/n/j(D)").replace("星期", "")
@register.filter
def smart_month(value: datetime.date) -> str:
"""Formats the month for human friendliness.
Args:
value: The month.
Returns:
The human-friendly format of the month.
"""
today = timezone.localdate()
if value.year == today.year and value.month == today.month:
return gettext("This Month")
month = today.month - 1
year = today.year
if month < 1:
month = 12
year = year - 1
if value.year == year and value.month == month:
return gettext("Last Month")
return defaultfilters.date(value, "Y/n")
@register.filter
def title_case(value: str) -> str:
"""Formats the title in a proper American-English case.
Args:
value: The title.
Returns:
The title in a proper American-English case.
"""
value = str(value)
if isinstance(value, SafeString):
value = value + ""
return titlecase.titlecase(value)
@register.filter
def is_in_section(request: HttpRequest, section_name: str) -> bool:
"""Returns whether the request is currently in a section.
Args:
request: The request.
section_name: The view name of this section.
Returns:
True if the request is currently in this section, or False otherwise.
"""
if request is None:
return False
if request.resolver_match is None:
return False
view_name = request.resolver_match.view_name
return view_name == section_name\
or view_name.startswith(section_name + ".")
@register.filter
def is_static_url(target: str) -> bool:
"""Returns whether the target URL is a static path
Args:
target: The target, either a static path that need to be passed to
the static template tag, or an HTTP, HTTPS URL or absolute path
that should be displayed directly.
Returns:
True if the target URL is a static path, or False otherwise.
"""
return not (re.match("^https?://", target) or target.startswith("/"))

3
src/mia_core/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

664
src/mia_core/utils.py Normal file
View File

@ -0,0 +1,664 @@
# 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, Optional
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]
STORAGE_KEY: str = "stored_post"
def store_post(request: HttpRequest, post: Dict[str, str]):
"""Stores the POST data into the session.
Args:
request: The request.
post: The POST data.
"""
request.session[STORAGE_KEY] = post
def retrieve_store(request: HttpRequest) -> Optional[Dict[str, str]]:
"""Retrieves the POST data from the storage.
Args:
request: The request.
Returns:
The POST data, or None if the previously-stored data does not exist.
"""
if STORAGE_KEY not in request.session:
return None
post = request.session[STORAGE_KEY]
del request.session[STORAGE_KEY]
return post
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_plus(piece[:pos])
value = urllib.parse.unquote_plus(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)
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)

225
src/mia_core/views.py Normal file
View File

@ -0,0 +1,225 @@
# The core application of the Mia project.
# by imacat <imacat@mail.imacat.idv.tw>, 2020/7/4
# 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 views of the Mia core application.
"""
from typing import Dict, Type, Optional, Any
from dirtyfields import DirtyFieldsMixin
from django import forms
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.db import transaction
from django.db.models import Model
from django.http import HttpResponse, HttpRequest, \
HttpResponseRedirect, Http404
from django.shortcuts import redirect, render
from django.urls import reverse
from django.views.generic import DeleteView as CoreDeleteView, \
RedirectView as CoreRedirectView
from django.views.generic.base import View
from . import utils
from .models import StampedModel
from .utils import UrlBuilder
class RedirectView(CoreRedirectView):
"""The redirect view, with current_app at the current namespace."""
def get_redirect_url(self, *args, **kwargs):
url = reverse(self.pattern_name, kwargs=kwargs,
current_app=self.request.resolver_match.namespace)
if self.query_string and self.request.META["QUERY_STRING"] != "":
url = url + "?" + self.request.META["QUERY_STRING"]
return url
class FormView(View):
"""The base form view."""
model: Type[Model] = None
form_class: Type[forms.Form] = None
template_name: str = None
context_object_name: str = "form"
success_url: str = None
error_url: str = None
not_modified_message: str = None
success_message: str = None
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.object: Optional[Model] = None
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""The view to store an accounting transaction."""
self.object = self.get_object()
if self.request.method == "POST":
return self.post(request, *args, **kwargs)
else:
return self.get(request, *args, **kwargs)
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Handles the GET requests."""
return render(self.request, self.get_template_name(),
self.get_context_data(**kwargs))
def post(self, request: HttpRequest, *args,
**kwargs) -> HttpResponseRedirect:
"""Handles the POST requests."""
form = self.get_form(**kwargs)
if not form.is_valid():
return self.form_invalid(form)
return self.form_valid(form)
def get_form_class(self) -> Type[forms.Form]:
"""Returns the form class."""
if self.form_class is None:
raise AttributeError("Please defined the form_class property.")
return self.form_class
@property
def _model(self):
if self.model is None:
raise AttributeError("Please defined the model property.")
return self.model
def get_context_data(self, **kwargs) -> Dict[str, Any]:
"""Returns the context data for the template."""
return {self.context_object_name: self.get_form()}
def get_form(self, **kwargs) -> forms.Form:
"""Returns the form for the template."""
if self.request.method == "POST":
post = self.request.POST.dict()
utils.strip_post(post)
return self.make_form_from_post(post)
else:
previous_post = utils.retrieve_store(self.request)
if previous_post is not None:
return self.make_form_from_post(previous_post)
if self.object is not None:
return self.make_form_from_model(self.object)
return self.get_form_class()()
def get_template_name(self) -> str:
"""Returns the name of the template."""
if self.template_name is not None:
return self.template_name
if self.model is not None:
app_name = self.request.resolver_match.app_name
model_name = self.model.__name__.lower()
return F"{app_name}/{model_name}_form.html"
raise AttributeError(
"Please either define the template_name or the model property.")
def make_form_from_post(self, post: Dict[str, str]) -> forms.Form:
"""Creates and returns the form from the POST data."""
return self.get_form_class()(post)
def make_form_from_model(self, obj: Model) -> forms.Form:
"""Creates and returns the form from a data model."""
form_class = self.get_form_class()
return form_class({x: getattr(obj, x, None)
for x in form_class.base_fields})
def fill_model_from_form(self, obj: Model, form: forms.Form) -> None:
"""Fills in the data model from the form."""
for name in form.fields:
setattr(obj, name, form[name].value())
if isinstance(obj, StampedModel):
obj.current_user = self.request.user
def form_invalid(self, form: forms.Form) -> HttpResponseRedirect:
"""Handles the action when the POST form is invalid."""
utils.store_post(self.request, form.data)
return redirect(self.get_error_url())
def form_valid(self, form: forms.Form) -> HttpResponseRedirect:
"""Handles the action when the POST form is valid."""
if self.object is None:
self.object = self._model()
self.fill_model_from_form(self.object, form)
if isinstance(self.object, DirtyFieldsMixin)\
and not self.object.is_dirty(check_relationship=True):
message = self.get_not_modified_message(form.cleaned_data)
else:
with transaction.atomic():
self.object.save()
message = self.get_success_message(form.cleaned_data)
messages.success(self.request, message)
return redirect(str(UrlBuilder(self.get_success_url())
.query(r=self.request.GET.get("r"))))
def get_success_url(self) -> str:
"""Returns the URL on success."""
if self.success_url is not None:
return self.success_url
get_absolute_url = getattr(self.object, "get_absolute_url", None)
if get_absolute_url is not None:
return get_absolute_url()
raise AttributeError(
"Please define either the success_url property,"
" the get_absolute_url method on the data model,"
" or the get_success_url method.")
def get_error_url(self) -> str:
"""Returns the URL on error"""
if self.error_url is not None:
return self.error_url
return self.request.get_full_path()
def get_not_modified_message(self, cleaned_data: Dict[str, str]) -> str:
"""Returns the message when the data was not modified.
Args:
cleaned_data: The cleaned data of the form.
Returns:
The message when the data was not modified.
"""
return self.not_modified_message % cleaned_data
def get_success_message(self, cleaned_data: Dict[str, str]) -> str:
"""Returns the success message.
Args:
cleaned_data: The cleaned data of the form.
Returns:
The message when the data was not modified.
"""
return self.success_message % cleaned_data
def get_object(self) -> Optional[Model]:
"""Finds and returns the current object, or None on a create form."""
if "pk" in self.kwargs:
pk = self.kwargs["pk"]
try:
return self._model.objects.get(pk=pk)
except self._model.DoesNotExist:
raise Http404
return None
class DeleteView(SuccessMessageMixin, CoreDeleteView):
"""The delete form view, with SuccessMessageMixin."""
def delete(self, request, *args, **kwargs):
response = super(DeleteView, self).delete(request, *args, **kwargs)
messages.success(request, self.get_success_message({}))
return response