diff --git a/accounting/views.py b/accounting/views.py index fd39d8b..837d80d 100644 --- a/accounting/views.py +++ b/accounting/views.py @@ -18,6 +18,7 @@ """The view controllers of the accounting application. """ +from datetime import date from django.http import HttpResponseRedirect, HttpResponse @@ -25,11 +26,12 @@ from django.urls import reverse from django.utils import dateformat from django.utils.decorators import method_decorator from django.utils.timezone import localdate +from django.utils.translation import get_language from django.views import generic from django.views.decorators.http import require_GET from accounting.models import Record -from mia_core.period import PeriodParser +from mia_core.period import Period from mia import settings from mia_core.digest_auth import digest_login_required from mia_core.utils import UrlBuilder, Pagination, \ @@ -70,12 +72,12 @@ class BaseReportView(generic.ListView): Attributes: page_no (int): The specified page number page_size (int): The specified page size - period_parser (PeriodParser): The period parser + period (Period): The template period helper """ page_no = None page_size = None pagination = None - period_parser = None + period = None def get(self, request, *args, **kwargs): """Adds object_list to the context. @@ -125,7 +127,7 @@ class BaseReportView(generic.ListView): def get_context_data(self, **kwargs): data = super(BaseReportView, self).get_context_data(**kwargs) - data["period"] = self.period_parser + data["period"] = self.period data["pagination_links"] = self.pagination.links return data @@ -142,7 +144,9 @@ class CashReportView(BaseReportView): Returns: List[Record]: The accounting records for the cash report """ - self.period_parser = PeriodParser(self.kwargs["period_spec"]) + self.period = Period( + get_language(), date(2000, 1, 1), date(2030, 12, 31), + self.kwargs["period_spec"]) if self.kwargs["subject_code"] == "0": records = Record.objects.raw( """SELECT r.* @@ -174,7 +178,7 @@ ORDER BY t.ord, CASE WHEN is_credit THEN 1 ELSE 2 END, r.ord""", - [self.period_parser.start, self.period_parser.end]) + [self.period.start, self.period.end]) else: records = Record.objects.raw( """SELECT r.* @@ -200,8 +204,8 @@ ORDER BY t.ord, CASE WHEN is_credit THEN 1 ELSE 2 END, r.ord""", - [self.period_parser.start, - self.period_parser.end, + [self.period.start, + self.period.end, self.kwargs["subject_code"] + "%", self.kwargs["subject_code"] + "%"]) self.pagination = Pagination( diff --git a/mia_core/period.py b/mia_core/period.py index d34b3d1..8e0d4bd 100644 --- a/mia_core/period.py +++ b/mia_core/period.py @@ -22,212 +22,509 @@ import re from datetime import date, timedelta +from django.core.serializers.json import DjangoJSONEncoder from django.template import defaultfilters from django.utils import dateformat from django.utils.timezone import localdate -from django.utils.translation import gettext, pgettext +from django.utils.translation import gettext -class PeriodParser: - """The period parser. +class Period: + """The template helper for the period chooser. Args: - spec (str): The period specification. + language (str): The current language. + data_start (date): The available first day of the data. + data_end (date): The available last day of the data. + spec (str): The current period specification Attributes: - spec (str): The currently-using period specification. - start (date): The start of the period. - end (date): The end of the period. - description (str): The text description of the period. - error (str): The period specification format error, or - None on success. + spec (date): The currently-working period specification. + start (date): The start day of the currently-specified period. + end (date): The end day of the currently-specified period. + description (str): The description of the currently-specified + period. + this_month (str): The specification of this month. + last_month (str): The specification of last month. + since_last_month (str): The specification since last month. + has_months_to_choose (bool): Whether there are months to + choose besides this month and + last month. + chosen_month (bool): The specification of the chosen month, + or None if the current period is not a + month or is out of available data range. + this_year (str): The specification of this year. + last_year (str): The specification of last year. + has_years_to_choose (bool): Whether there are years to + choose besides this year and + last year. + years_to_choose (list[str]): This specification of the + available years to choose, + besides this year and last year. + today (str): The specification of today. + yesterday (str): The specification of yesterday. + chosen_start (str): The specification of the first day of the + specified period, as the default date for + the single-day chooser. + has_days_to_choose (bool): Whether there is a day range to + choose. + data_start (str): The specification of the available first day. + data_end (str): The specification of the available last day. + chosen_start (str): The specification of the first day of the + specified period + chosen_end (str): The specification of the last day of the + specified period + month_picker_params (str): The month-picker parameters, as a + JSON text string """ - spec = None - start = None - end = None - description = None - error = None + _language = None + _data_start = None + _data_end = None + _period = None - def __init__(self, spec): - 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)) - try: - self.start = date(year, month, 1) - except ValueError: - self.invalid_period() - return - self.end = self.get_month_last_day(self.start) - self.description = self.get_month_text(year, month) - 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)) - try: - self.start = date(year, month, 1) - except ValueError: - self.invalid_period() - return - self.end = self.get_month_last_day(localdate()) - self.description = gettext( - "Since %s") % self.get_month_text(year, month) - return - # A specific year - m = re.match("^([0-9]{4})$", spec) - if m is not None: - year = int(m.group(1)) - try: - self.start = date(year, 1, 1) - except ValueError: - self.invalid_period() - return - self.end = date(year, 12, 31) - today = localdate() - if year == today.year: - self.description = gettext("This Year") - elif year == today.year - 1: - self.description = gettext("Last Year") - else: - self.description = str(year) - return - # All time - if spec == "-": - self.start = date(2000, 1, 1) - self.end = self.get_month_last_day(localdate()) - self.description = gettext("All") - return - # A specific date - m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$", - spec) - if m is not None: - try: - self.start = date( - int(m.group(1)), int(m.group(2)), int(m.group(3))) - except ValueError: - self.invalid_period() - return - self.end = self.start - self.description = self.get_date_text(self.start) - 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: - try: - self.start = date( - int(m.group(1)), int(m.group(2)), int(m.group(3))) - self.end = date( - int(m.group(4)), int(m.group(5)), int(m.group(6))) - except ValueError: - self.invalid_period() - return - today = 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.get_date_text(self.start) - return - # Wrong period format - self.invalid_period() + def __init__(self, language, data_start, data_end, spec): + self._language = language + self._data_start = data_start + self._data_end = data_end + self._period = self.Parser(spec) - def invalid_period(self): - """Sets the period when the period specification is invalid. - """ - self.error = gettext("Invalid period.") - today = localdate() - self.spec = dateformat.format(localdate(), "Y-m") - self.start = date(today.year, today.month, 1) - self.end = self.get_month_last_day(self.start) - self.description = gettext("This Month") + @property + def spec(self): + return self._period.spec + + @property + def start(self): + return self._period.start + + @property + def end(self): + return self._period.end + + @property + def description(self): + return self._period.description @staticmethod - def get_month_last_day(day): - """Calculates and returns the last day of a month. - - Args: - day (date): A day in the month. + def _get_last_month_start(): + """Returns the first day of the last month. Returns: - date: 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 date(next_year, next_month, 1) - timedelta(days=1) - - @staticmethod - def get_month_text(year, month): - """Returns the text description of a month. - - Args: - year (int): The year. - month (int): The month. - - Returns: - str: The description of the month. + date: The first day of the last month. """ today = 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 = date(prev_year, prev_month, 1) - if year == prev.year and month == prev.month: - return gettext("Last Month") - return "%d/%d" % (year, month) + month = today.month - 1 + year = today.year + if month < 1: + month = 12 + year = year - 1 + return date(year, month, 1) @staticmethod - def get_date_text(day): - """Returns the text description of a day. - - Args: - day (date): The date. + def _get_next_month_start(): + """Returns the first day of the next month. Returns: - str: The description of the day. + date: The first day of the next month. """ today = localdate() - if day == today: - return gettext("Today") - elif day == today - timedelta(days=1): - return gettext("Yesterday") - elif day.year != today.year: - return defaultfilters.date(day, "Y/n/j") + month = today.month + 1 + year = today.year + if month > 12: + month = 1 + year = year + 1 + return date(year, month, 1) + + @property + def this_month(self): + if self._data_start is None: + return None + today = localdate() + first_month_start = date( + self._data_start.year, self._data_start.month, 1) + if today < first_month_start: + return None + return dateformat.format(today, "Y-m") + + @property + def last_month(self): + if self._data_start is None: + return None + last_month_start = self._get_last_month_start() + first_month_start = 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") + + @property + def since_last_month(self): + last_month = self.last_month + if last_month is None: + return None + return self.last_month + "-" + + @property + def has_months_to_choose(self): + if self._data_start is None: + return None + if self._data_start < self._get_last_month_start(): + return True + if self._data_end >= self._get_next_month_start(): + return True + return False + + @property + def chosen_month(self): + 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 + + @property + def this_year(self): + if self._data_start is None: + return None + this_year = localdate().year + if this_year < self._data_start.year: + return None + return str(this_year) + + @property + def last_year(self): + if self._data_start is None: + return None + last_year = localdate().year - 1 + if last_year < self._data_start.year: + return None + return str(last_year) + + @property + def has_years_to_choose(self): + if self._data_start is None: + return None + this_year = localdate().year + if self._data_start.year < this_year - 1: + return True + if self._data_end.year > this_year: + return True + return False + + @property + def years_to_choose(self): + if self._data_start is None: + return None + this_year = 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 is_chosen_year(self, year): + """Returne whether the specified year is the currently-chosen + year. + + Args: + year (str): the year. + + Returns: + bool: True if the year is the currently-chosen year, or + False otherwise + """ + if self._period.spec == str(year): + return True + + @property + def today(self): + if self._data_start is None: + return None + today = localdate() + if today < self._data_start or today > self._data_end: + return None + return dateformat.format(today, "Y-m-d") + + @property + def yesterday(self): + if self._data_start is None: + return None + yesterday = localdate() - timedelta(days=1) + if yesterday < self._data_start or yesterday > self._data_end: + return None + return dateformat.format(yesterday, "Y-m-d") + + @property + def chosen_day(self): + return dateformat.format(self._period.start, "Y-m-d") + + @property + def has_days_to_choose(self): + if self._data_start is None: + return False + if self._data_start == self._data_end: + return False + return True + + @property + def first_day(self): + if self._data_start is None: + return None + return dateformat.format(self._data_start, "Y-m-d") + + @property + def last_day(self): + if self._data_end is None: + return None + return dateformat.format(self._data_end, "Y-m-d") + + @property + def chosen_start(self): + 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") + + @property + def chosen_end(self): + 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") + + @property + def month_picker_params(self): + if self._data_start is None: + return None + if self._language == "zh-Hant": + locale = "zh_TW" + elif self._language == "zh-Hans": + locale = "zh_CN" else: - return defaultfilters.date(day, "n/j") + locale = self._language.replace("-", "_") + start = date(self._data_start.year, self._data_start.month, 1) + return DjangoJSONEncoder().encode({ + "locale": locale, + "minDate": start, + "maxDate": self._data_end, + "defaultDate": self.chosen_month, + }) + + class Parser: + """The period parser. + + Args: + spec (str): The period specification. + + Attributes: + spec (str): The currently-using period specification. + start (date): The start of the period. + end (date): The end of the period. + description (str): The text description of the period. + error (str): The period specification format error, or + None on success. + """ + spec = None + start = None + end = None + description = None + error = None + + def __init__(self, spec): + 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)) + try: + self.start = date(year, month, 1) + except ValueError: + self.invalid_period() + return + self.end = self.get_month_last_day(self.start) + self.description = self.get_month_text(year, month) + 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)) + try: + self.start = date(year, month, 1) + except ValueError: + self.invalid_period() + return + self.end = self.get_month_last_day(localdate()) + self.description = gettext( + "Since %s") % self.get_month_text(year, month) + return + # A specific year + m = re.match("^([0-9]{4})$", spec) + if m is not None: + year = int(m.group(1)) + try: + self.start = date(year, 1, 1) + except ValueError: + self.invalid_period() + return + self.end = date(year, 12, 31) + today = localdate() + if year == today.year: + self.description = gettext("This Year") + elif year == today.year - 1: + self.description = gettext("Last Year") + else: + self.description = str(year) + return + # All time + if spec == "-": + self.start = date(2000, 1, 1) + self.end = self.get_month_last_day(localdate()) + self.description = gettext("All") + return + # A specific date + m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$", + spec) + if m is not None: + try: + self.start = date( + int(m.group(1)), + int(m.group(2)), + int(m.group(3))) + except ValueError: + self.invalid_period() + return + self.end = self.start + self.description = self.get_date_text(self.start) + 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: + try: + self.start = date( + int(m.group(1)), + int(m.group(2)), + int(m.group(3))) + self.end = date( + int(m.group(4)), + int(m.group(5)), + int(m.group(6))) + except ValueError: + self.invalid_period() + return + today = 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.get_date_text(self.start) + return + # Wrong period format + self.invalid_period() + + def invalid_period(self): + """Sets the period when the period specification is + invalid. + """ + self.error = gettext("Invalid period.") + today = localdate() + self.spec = dateformat.format(localdate(), "Y-m") + self.start = date(today.year, today.month, 1) + self.end = self.get_month_last_day(self.start) + self.description = gettext("This Month") + + @staticmethod + def get_month_last_day(day): + """Calculates and returns the last day of a month. + + Args: + day (date): A day in the month. + + Returns: + date: 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 date(next_year, next_month, 1) - timedelta(days=1) + + @staticmethod + def get_month_text(year, month): + """Returns the text description of a month. + + Args: + year (int): The year. + month (int): The month. + + Returns: + str: The description of the month. + """ + today = 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 = 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 get_date_text(day): + """Returns the text description of a day. + + Args: + day (date): The date. + + Returns: + str: The description of the day. + """ + today = localdate() + if day == today: + return gettext("Today") + elif day == today - 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")