Compare commits

...

11 Commits

20 changed files with 164 additions and 68 deletions

View File

@ -59,7 +59,7 @@ Refer to the `change log`_.
Copyright
=========
Copyright (c) 2023 imacat.
Copyright (c) 2023-2024 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

View File

@ -2,6 +2,29 @@ Change Log
==========
Version 1.6.0
--------------
Released 2024/6/4
* Updated Python version to 3.12.
* Revised the calculation of "today" to use the client's timezone instead of
the server's timezone.
* Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test
site.
Version 1.5.11
--------------
Released 2023/12/26
Bug fix.
* Refined to enable the selection of the 3351-001 Accumulated Profit or Loss
account.
Version 1.5.10
--------------

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
# Copyright (c) 2022-2023 imacat.
# Copyright (c) 2022-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -20,7 +20,7 @@ name = "mia-accounting"
dynamic = ["version"]
description = "A Flask accounting module."
readme = "README.rst"
requires-python = ">=3.11"
requires-python = ">=3.12"
authors = [
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
]
@ -33,7 +33,7 @@ classifiers = [
"Topic :: Office/Business :: Financial :: Accounting",
]
dependencies = [
"flask",
"Flask",
"SQLAlchemy >= 2",
"Flask-SQLAlchemy",
"Flask-WTF",
@ -42,8 +42,7 @@ dependencies = [
]
[project.optional-dependencies]
test = [
"unittest",
devel = [
"httpx",
"OpenCC",
]

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -24,7 +24,7 @@ from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import UserUtilityInterface
VERSION: str = "1.5.10"
VERSION: str = "1.6.0"
"""The package version."""
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -27,7 +27,7 @@ from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk
AccountData = tuple[int, str, int, str, str, str, bool]
type AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""

View File

@ -71,7 +71,6 @@ class IsDebitAccount:
if field.data is None:
return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and field.data != "3351-001" \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)
@ -92,7 +91,6 @@ class IsCreditAccount:
if field.data is None:
return
if re.match(r"^(?:[123489]|7[1234])", field.data) \
and field.data != "3351-001" \
and not field.data.startswith("3353-"):
return
raise ValidationError(self.__message)

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -19,7 +19,7 @@
"""
import datetime as dt
from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type
from typing import Type
import sqlalchemy as sa
from flask_babel import LazyString
@ -308,11 +308,7 @@ class JournalEntryForm(FlaskForm):
return db.session.scalar(select)
T = TypeVar("T", bound=JournalEntryForm)
"""A journal entry form variant."""
class LineItemCollector(Generic[T], ABC):
class LineItemCollector[T: JournalEntryForm](ABC):
"""The line item collector."""
def __init__(self, form: T, obj: JournalEntry):

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -33,6 +33,7 @@ from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.timezone import get_tz_today
from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \
@ -67,7 +68,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
form.validate()
else:
form = journal_entry_op.form()
form.date.data = dt.date.today()
form.date.data = get_tz_today()
return journal_entry_op.render_create_template(form)

View File

@ -304,8 +304,6 @@ class Account(db.Model):
cls.base_code.startswith("78"),
cls.base_code.startswith("8"),
cls.base_code.startswith("9")),
sa.not_(sa.and_(cls.base_code == "3351",
cls.no == 1)),
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()
@ -327,8 +325,6 @@ class Account(db.Model):
cls.base_code.startswith("74"),
cls.base_code.startswith("8"),
cls.base_code.startswith("9")),
sa.not_(sa.and_(cls.base_code == "3351",
cls.no == 1)),
cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all()

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -24,6 +24,7 @@ import datetime as dt
from collections.abc import Callable
from accounting.models import JournalEntry
from accounting.utils.timezone import get_tz_today
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
@ -80,7 +81,7 @@ class PeriodChooser:
"""The available years."""
if self.has_data:
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
self.has_last_month = start < dt.date(today.year, today.month, 1)
self.has_last_year = start.year < today.year
self.has_yesterday = start < today

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -20,6 +20,7 @@
import datetime as dt
from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
from .month_end import month_end
from .period import Period
@ -27,7 +28,7 @@ from .period import Period
class ThisMonth(Period):
"""The period of this month."""
def __init__(self):
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
this_month_start: dt.date = dt.date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today))
self.is_default = True
@ -43,7 +44,7 @@ class ThisMonth(Period):
class LastMonth(Period):
"""The period of this month."""
def __init__(self):
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
year: int = today.year
month: int = today.month - 1
if month < 1:
@ -63,7 +64,7 @@ class LastMonth(Period):
class SinceLastMonth(Period):
"""The period of this month."""
def __init__(self):
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
year: int = today.year
month: int = today.month - 1
if month < 1:
@ -82,7 +83,7 @@ class SinceLastMonth(Period):
class ThisYear(Period):
"""The period of this year."""
def __init__(self):
year: int = dt.date.today().year
year: int = get_tz_today().year
start: dt.date = dt.date(year, 1, 1)
end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end)
@ -97,7 +98,7 @@ class ThisYear(Period):
class LastYear(Period):
"""The period of last year."""
def __init__(self):
year: int = dt.date.today().year
year: int = get_tz_today().year
start: dt.date = dt.date(year - 1, 1, 1)
end: dt.date = dt.date(year - 1, 12, 31)
super().__init__(start, end)
@ -112,7 +113,7 @@ class LastYear(Period):
class Today(Period):
"""The period of today."""
def __init__(self):
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
super().__init__(today, today)
self.is_today = True
@ -125,7 +126,7 @@ class Today(Period):
class Yesterday(Period):
"""The period of yesterday."""
def __init__(self):
yesterday: dt.date = dt.date.today() - dt.timedelta(days=1)
yesterday: dt.date = get_tz_today() - dt.timedelta(days=1)
super().__init__(yesterday, yesterday)
self.is_yesterday = True

View File

@ -0,0 +1,37 @@
/* The Mia! Accounting Project
* timezone.js: The JavaScript for the timezone
*/
/* Copyright (c) 2024 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: 2024/6/4
*/
"use strict";
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", () => {
setTimeZone();
});
/**
* Sets the time zone.
*
* @private
*/
function setTimeZone() {
document.cookie = `accounting-tz=${Intl.DateTimeFormat().resolvedOptions().timeZone}; SameSite=Strict`;
}

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -24,6 +24,7 @@ from typing import Any
from flask_babel import get_locale
from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
def format_amount(value: Decimal | None) -> str | None:
@ -47,7 +48,7 @@ def format_date(value: dt.date) -> str:
:param value: The date.
:return: The human-friendly date text.
"""
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
if value == today:
return gettext("Today")
if value == today - dt.timedelta(days=1):

View File

@ -2,7 +2,7 @@
The Mia! Accounting Project
base.html: The application-wide base template.
Copyright (c) 2023 imacat.
Copyright (c) 2023-2024 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -27,5 +27,6 @@ First written: 2023/1/27
{% block scripts %}
<script src="{{ url_for("accounting.babel_catalog") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/timezone.js") }}"></script>
{% block accounting_scripts %}{% endblock %}
{% endblock %}

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -19,7 +19,6 @@
This module should not import any other module from the application.
"""
from typing import TypeVar, Generic
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
ParseResult
@ -62,11 +61,8 @@ class Redirection(RequestRedirect):
DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
T = TypeVar("T")
"""The pagination item type."""
class Pagination(Generic[T]):
class Pagination[T]:
"""The pagination utility."""
def __init__(self, items: list[T], is_reversed: bool = False):
@ -92,7 +88,7 @@ class Pagination(Generic[T]):
"""The options to the number of items in a page."""
class AbstractPagination(Generic[T]):
class AbstractPagination[T]:
"""An abstract pagination."""
def __init__(self):
@ -109,12 +105,12 @@ class AbstractPagination(Generic[T]):
"""The options to the number of items in a page."""
class EmptyPagination(AbstractPagination[T]):
class EmptyPagination[T](AbstractPagination[T]):
"""The pagination from empty data."""
pass
class NonEmptyPagination(AbstractPagination[T]):
class NonEmptyPagination[T](AbstractPagination[T]):
"""The pagination with real data."""
PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
"""The page size options."""

View File

@ -0,0 +1,37 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2024/6/4
# Copyright (c) 2024 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 timezone utility.
This module should not import any other module from the application.
"""
import datetime as dt
import pytz
from flask import request
def get_tz_today() -> dt.date:
"""Returns today in the client timezone.
:return: today in the client timezone.
"""
tz_name: str | None = request.cookies.get("accounting-tz")
if tz_name is None:
return dt.date.today()
return dt.datetime.now(tz=pytz.timezone(tz_name)).date()

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -20,17 +20,14 @@ This module should not import any other module from the application.
"""
from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type
from typing import Type
import sqlalchemy as sa
from flask import g, Response
from flask_sqlalchemy.model import Model
T = TypeVar("T", bound=Model)
"""The user data model data type."""
class UserUtilityInterface(Generic[T], ABC):
class UserUtilityInterface[T: Model](ABC):
"""The interface for the user utilities."""
@abstractmethod
@ -113,7 +110,7 @@ class UserUtilityInterface(Generic[T], ABC):
__user_utils: UserUtilityInterface
"""The user utilities."""
user_cls: Type[Model] = Model
type user_cls = Model
"""The user class."""
user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class."""

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/13
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -28,6 +28,7 @@ from typing import Any
import sqlalchemy as sa
from flask import Flask
from accounting.utils.timezone import get_tz_today
from . import db
from .auth import User
@ -44,6 +45,17 @@ class Accounts:
MEAL: str = "6272-001"
def get_today() -> dt.date:
"""Returns today, based on the context.
:return: Today.
"""
try:
return get_tz_today()
except RuntimeError:
return dt.date.today()
class JournalEntryLineItemData:
"""The journal entry line item data."""
@ -183,7 +195,7 @@ class JournalEntryData:
:param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form.
"""
date: dt.date = dt.date.today() - dt.timedelta(days=self.days)
date: dt.date = get_today() - dt.timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": encoded_next_uri,
"date": date.isoformat()}
@ -260,8 +272,7 @@ class BaseTestData(ABC):
existing_j_id: set[int] = {x["id"] for x in self.__journal_entries}
existing_l_id: set[int] = {x["id"] for x in self.__line_items}
journal_entry_data.id = self.__new_id(existing_j_id)
date: dt.date \
= dt.date.today() - dt.timedelta(days=journal_entry_data.days)
date: dt.date = get_today() - dt.timedelta(days=journal_entry_data.days)
self.__journal_entries.append(
{"id": journal_entry_data.id,
"date": date,

View File

@ -1,7 +1,7 @@
# The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/12
# Copyright (c) 2023 imacat.
# Copyright (c) 2023-2024 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -23,6 +23,7 @@ from flask import Flask, Blueprint, url_for, flash, redirect, session, \
render_template, current_app
from flask_babel import lazy_gettext
from accounting.utils.timezone import get_tz_today
from . import db
from .auth import admin_required
from .lib import Accounts, JournalEntryLineItemData, JournalEntryData, \
@ -117,7 +118,7 @@ class SampleData(BaseTestData):
:return: None.
"""
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
days: int
year: int
month: int
@ -160,7 +161,7 @@ class SampleData(BaseTestData):
:return: None.
"""
today: dt.date = dt.date.today()
today: dt.date = get_tz_today()
year: int = today.year - 5
month: int = today.month

View File

@ -2,7 +2,7 @@
The Mia! Accounting Demonstration Website
base.html: The side-wide layout template
Copyright (c) 2023 imacat.
Copyright (c) 2023-2024 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -25,21 +25,21 @@ First written: 2023/1/27
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="{{ "imacat" }}" />
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css" integrity="sha384-iw3OoTErCYJJB9mCa8LNS2hbsQ7M3C0EpIsO/H5+EGAkPGc6rk+V8i04oW/K5xq0" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/css/tempus-dominus.min.css" integrity="sha384-l66rSL7gUubrdJxFRbXUo/tO7eNPAcCiZXFs/Xl147146xNqQ1qt4oPW6jlVezsS" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css" integrity="sha384-t1nt8BQoYMLFN5p42tRAtuAAFQaCQODekUVeKKZrEnEyp4H2R0RHFz0KWpmj7i8g" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.9.6/dist/css/tempus-dominus.min.css" integrity="sha384-NzVf7b26bC2au5J9EqNceWlrs7iIkBa0bA46tRpK5C3J08J7MRTPmSdpRKhWNgDL" crossorigin="anonymous">
{% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/js/tempus-dominus.min.js" integrity="sha384-MxHp+/TqTjbku1jSTIe1e/4l6CZTLhACLDbWyxYaFRgD3AM4oh99AY8bxsGhIoRc" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.9.6/dist/js/tempus-dominus.min.js" integrity="sha384-GRg4jmBEA/AnwmpV7MhpXUTim20ncyZTm9/1fbna86CRqMcdrou46etX8scQ9dPe" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
<link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}">
<title>{% block title %}{% endblock %}</title>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for("home.home") }}">
<i class="fa-solid fa-house"></i>