Moved the source files to the "src" subdirectory.
This commit is contained in:
0
src/mia_core/__init__.py
Normal file
0
src/mia_core/__init__.py
Normal file
5
src/mia_core/apps.py
Normal file
5
src/mia_core/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MiaCoreConfig(AppConfig):
|
||||
name = 'mia_core'
|
135
src/mia_core/locale/zh_Hant/LC_MESSAGES/django.po
Normal file
135
src/mia_core/locale/zh_Hant/LC_MESSAGES/django.po
Normal 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 "下一頁"
|
130
src/mia_core/management/commands/make_trans.py
Normal file
130
src/mia_core/management/commands/make_trans.py
Normal 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
236
src/mia_core/models.py
Normal 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
667
src/mia_core/period.py
Normal 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")
|
26
src/mia_core/static/mia_core/css/period-chooser.css
Normal file
26
src/mia_core/static/mia_core/css/period-chooser.css
Normal 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;
|
||||
}
|
98
src/mia_core/static/mia_core/js/period-chooser.js
Normal file
98
src/mia_core/static/mia_core/js/period-chooser.js
Normal 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");
|
||||
}
|
48
src/mia_core/templates/mia_core/include/pagination.html
Normal file
48
src/mia_core/templates/mia_core/include/pagination.html
Normal 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 %}
|
131
src/mia_core/templates/mia_core/include/period-chooser.html
Normal file
131
src/mia_core/templates/mia_core/include/period-chooser.html
Normal 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">×</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>
|
0
src/mia_core/templatetags/__init__.py
Normal file
0
src/mia_core/templatetags/__init__.py
Normal file
278
src/mia_core/templatetags/mia_core.py
Normal file
278
src/mia_core/templatetags/mia_core.py
Normal 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
3
src/mia_core/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
664
src/mia_core/utils.py
Normal file
664
src/mia_core/utils.py
Normal 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
225
src/mia_core/views.py
Normal 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
|
Reference in New Issue
Block a user