16 Commits

Author SHA1 Message Date
2a5eac011a Fix type hints in the console command test case. 2026-01-11 11:58:27 +08:00
9efbbc38ab Add init options to skip data initialization and remove manual cleanup in test cases. 2026-01-11 11:56:43 +08:00
693c5890ca Add db.engine.dispose() in test tearDown to fix ResourceWarning from Python 3.13. 2026-01-11 11:56:20 +08:00
3adcaa61d3 Fix httpx dependency version in pyproject.toml. 2026-01-08 21:52:11 +08:00
aea9dcae79 Advanced to version 1.6.1. 2024-12-03 08:18:40 +08:00
40278eaf06 Fix test cases for compatibility with httpx 0.28.0. 2024-12-03 08:18:30 +08:00
e00c14f277 Fixed the SQLite database URL for the in-memory database. 2024-07-10 05:49:29 +08:00
f20c462685 Advanced to version 1.6.0. 2024-06-04 08:29:26 +08:00
80ae4bd91c Revised the calculation of "today" to use the client's timezone instead of the server's timezone. 2024-06-04 08:28:59 +08:00
6ee3ee76ea Updated optional dependencies in pyproject.toml. 2024-06-04 08:28:58 +08:00
2bfcc8b889 Updated the dependencies in pyproject.toml. 2024-06-04 08:28:15 +08:00
99564c02d0 Updated the Bootstrap, FontAwesome, and Tempus-Dominus versions in the test site. 2024-04-21 22:41:46 +02:00
25d9904180 Applied the new type parameter syntax to the generic classes for Python 3.12. 2024-03-03 07:39:37 +08:00
1cf83adf87 Applied the "type" statement to type aliases for Python 3.12. 2024-03-03 07:39:20 +08:00
8e3d1f11b5 Updated Python version to 3.12. 2024-03-03 07:38:59 +08:00
0ab14aa34d Updated the copyright year in README.rst. 2024-03-03 07:38:32 +08:00
32 changed files with 369 additions and 136 deletions

View File

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

View File

@@ -2,6 +2,26 @@ Change Log
========== ==========
Version 1.6.1
--------------
Released 2024/12/3
Fix test cases for compatibility with httpx 0.28.0.
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 Version 1.5.11
-------------- --------------

View File

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

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # 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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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 from accounting.utils.user import UserUtilityInterface
VERSION: str = "1.5.11" VERSION: str = "1.6.1.2"
"""The package version.""" """The package version."""
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # 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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk 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, """The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples.""" English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""

View File

@@ -54,13 +54,20 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
@click.option("-u", "--username", metavar="USERNAME", prompt=True, @click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username, help="The username.", callback=__validate_username,
default=lambda: os.getlogin()) default=lambda: os.getlogin())
@click.option("--skip-accounts", is_flag=True, default=False,
help="Skip initializing accounts.")
@click.option("--skip-currencies", is_flag=True, default=False,
help="Skip initializing currencies.")
@with_appcontext @with_appcontext
def init_db_command(username: str) -> None: def init_db_command(username: str, skip_accounts: bool,
skip_currencies: bool) -> None:
"""Initializes the accounting database.""" """Initializes the accounting database."""
db.create_all() db.create_all()
init_base_accounts_command() init_base_accounts_command()
init_accounts_command(username) if not skip_accounts:
init_currencies_command(username) init_accounts_command(username)
if not skip_currencies:
init_currencies_command(username)
db.session.commit() db.session.commit()
click.echo("Accounting database initialized.") click.echo("Accounting database initialized.")

View File

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

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 # 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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit 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 accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \ 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() form.validate()
else: else:
form = journal_entry_op.form() 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) return journal_entry_op.render_create_template(form)

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
base.html: The application-wide base template. 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 %} {% block scripts %}
<script src="{{ url_for("accounting.babel_catalog") }}"></script> <script src="{{ url_for("accounting.babel_catalog") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/timezone.js") }}"></script>
{% block accounting_scripts %}{% endblock %} {% block accounting_scripts %}{% endblock %}
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # 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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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. 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, \ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
ParseResult ParseResult
@@ -62,11 +61,8 @@ class Redirection(RequestRedirect):
DEFAULT_PAGE_SIZE: int = 10 DEFAULT_PAGE_SIZE: int = 10
"""The default page size.""" """The default page size."""
T = TypeVar("T")
"""The pagination item type."""
class Pagination[T]:
class Pagination(Generic[T]):
"""The pagination utility.""" """The pagination utility."""
def __init__(self, items: list[T], is_reversed: bool = False): 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.""" """The options to the number of items in a page."""
class AbstractPagination(Generic[T]): class AbstractPagination[T]:
"""An abstract pagination.""" """An abstract pagination."""
def __init__(self): def __init__(self):
@@ -109,12 +105,12 @@ class AbstractPagination(Generic[T]):
"""The options to the number of items in a page.""" """The options to the number of items in a page."""
class EmptyPagination(AbstractPagination[T]): class EmptyPagination[T](AbstractPagination[T]):
"""The pagination from empty data.""" """The pagination from empty data."""
pass pass
class NonEmptyPagination(AbstractPagination[T]): class NonEmptyPagination[T](AbstractPagination[T]):
"""The pagination with real data.""" """The pagination with real data."""
PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200] PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
"""The page size options.""" """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. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # 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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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 abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type from typing import Type
import sqlalchemy as sa import sqlalchemy as sa
from flask import g, Response from flask import g, Response
from flask_sqlalchemy.model import Model from flask_sqlalchemy.model import Model
T = TypeVar("T", bound=Model)
"""The user data model data type."""
class UserUtilityInterface[T: Model](ABC):
class UserUtilityInterface(Generic[T], ABC):
"""The interface for the user utilities.""" """The interface for the user utilities."""
@abstractmethod @abstractmethod
@@ -113,7 +110,7 @@ class UserUtilityInterface(Generic[T], ABC):
__user_utils: UserUtilityInterface __user_utils: UserUtilityInterface
"""The user utilities.""" """The user utilities."""
user_cls: Type[Model] = Model type user_cls = Model
"""The user class.""" """The user class."""
user_pk_column: sa.Column = sa.Column(sa.Integer) user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class.""" """The primary key column of the user class."""

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -72,14 +72,10 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.__app: Flask = create_test_app() self.__app: Flask = create_test_app(is_skip_accounts=True)
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import Account, AccountL10n
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -105,6 +101,15 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{BANK.code}") f"{PREFIX}/{BANK.code}")
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import unittest
import httpx import httpx
from flask import Flask from flask import Flask
from test_site import db
from testlib import create_test_app, get_client from testlib import create_test_app, get_client
LIST_URI: str = "/accounting/base-accounts" LIST_URI: str = "/accounting/base-accounts"
@@ -42,6 +43,15 @@ class BaseAccountTestCase(unittest.TestCase):
self.__app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application.""" """The Flask application."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -45,6 +45,15 @@ class ConsoleCommandTestCase(unittest.TestCase):
self.__app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application.""" """The Flask application."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_init_db(self) -> None: def test_init_db(self) -> None:
"""Tests the "accounting-init-db" console command. """Tests the "accounting-init-db" console command.
@@ -84,7 +93,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
rows: list[dict[str, str]] = list(csv.DictReader(fp)) rows: list[dict[str, str]] = list(csv.DictReader(fp))
data: dict[dict[str, Any]] \ data: dict[str, dict[str, Any]] \
= {x["code"]: {"code": x["code"], = {x["code"]: {"code": x["code"],
"title": x["title"], "title": x["title"],
"l10n": {y[5:]: x[y] "l10n": {y[5:]: x[y]
@@ -158,7 +167,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
from accounting.models import Currency from accounting.models import Currency
with open(data_dir / "currencies.csv") as fp: with open(data_dir / "currencies.csv") as fp:
data: dict[dict[str, Any]] \ data: dict[str, dict[str, Any]] \
= {x["code"]: {"code": x["code"], = {x["code"]: {"code": x["code"],
"name": x["name"], "name": x["name"],
"l10n": {y[5:]: x[y] "l10n": {y[5:]: x[y]

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -65,15 +65,9 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.__app: Flask = create_test_app() self.__app: Flask = create_test_app(is_skip_currencies=True)
"""The Flask application.""" """The Flask application."""
with self.__app.app_context():
from accounting.models import Currency, CurrencyL10n
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
self.__client: httpx.Client = get_client(self.__app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client.""" """The user client."""
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
@@ -94,6 +88,15 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/28
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -24,6 +24,7 @@ import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next from accounting.utils.next_uri import encode_next
from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \ from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
get_csrf_token, add_journal_entry get_csrf_token, add_journal_entry
@@ -41,9 +42,6 @@ class DescriptionEditorTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -52,6 +50,15 @@ class DescriptionEditorTestCase(unittest.TestCase):
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_description_editor(self) -> None: def test_description_editor(self) -> None:
"""Test the description editor. """Test the description editor.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/24 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/24
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -52,9 +52,6 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -63,6 +60,15 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
@@ -677,9 +683,6 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -688,6 +691,15 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
@@ -1277,10 +1289,6 @@ class TransferJournalEntryTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, \
JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -1289,6 +1297,15 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
@@ -2158,9 +2175,6 @@ class JournalEntryReorderTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -2169,6 +2183,15 @@ class JournalEntryReorderTestCase(unittest.TestCase):
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_change_date(self) -> None: def test_change_date(self) -> None:
"""Tests to change the date of a journal entry. """Tests to change the date of a journal entry.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/11
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -49,9 +49,6 @@ class OffsetTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -63,6 +60,15 @@ class OffsetTestCase(unittest.TestCase):
"""The offset test data.""" """The offset test data."""
self.__data.populate() self.__data.populate()
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_add_receivable_offset(self) -> None: def test_add_receivable_offset(self) -> None:
"""Tests to add the receivable offset. """Tests to add the receivable offset.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -45,8 +45,6 @@ class OptionTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import Option
Option.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -55,6 +53,15 @@ class OptionTestCase(unittest.TestCase):
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/9 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/9
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,6 +23,7 @@ import unittest
import httpx import httpx
from flask import Flask from flask import Flask
from test_site import db
from test_site.lib import BaseTestData from test_site.lib import BaseTestData
from testlib import create_test_app, get_client, get_csrf_token, Accounts from testlib import create_test_app, get_client, get_csrf_token, Accounts
@@ -44,16 +45,20 @@ class ReportTestCase(unittest.TestCase):
self.__app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application.""" """The Flask application."""
with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__client: httpx.Client = get_client(self.__app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client.""" """The user client."""
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.

View File

@@ -40,16 +40,21 @@ db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
def create_app(is_testing: bool = False) -> Flask: def create_app(is_testing: bool = False, is_skip_accounts: bool = False,
is_skip_currencies: bool = False) -> Flask:
"""Create and configure the application. """Create and configure the application.
:param is_testing: True if we are running for testing, or False otherwise. :param is_testing: True if we are running for testing, or False otherwise.
:param is_skip_accounts: True to skip account initialization, or False
otherwise.
:param is_skip_currencies: True to skip currency initialization, or False
otherwise.
:return: The application. :return: The application.
""" """
import accounting import accounting
app: Flask = Flask(__name__) app: Flask = Flask(__name__)
db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite" db_uri: str = "sqlite://" if is_testing else "sqlite:///local.sqlite"
app.config.from_mapping({ app.config.from_mapping({
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)), "SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SESSION_COOKIE_SAMESITE": "Lax", "SESSION_COOKIE_SAMESITE": "Lax",
@@ -117,15 +122,20 @@ def create_app(is_testing: bool = False) -> Flask:
accounting.init_app(app, user_utils=UserUtilities()) accounting.init_app(app, user_utils=UserUtilities())
with app.app_context(): with app.app_context():
init_db(app) init_db(app, is_skip_accounts, is_skip_currencies)
return app return app
def init_db(app: Flask) -> None: def init_db(app: Flask, is_skip_accounts: bool,
is_skip_currencies: bool) -> None:
"""Initializes the database. """Initializes the database.
:param app: The Flask application. :param app: The Flask application.
:param is_skip_accounts: True to skip account initialization, or False
otherwise.
:param is_skip_currencies: True to skip currency initialization, or False
otherwise.
:return: None. :return: None.
""" """
db.create_all() db.create_all()
@@ -135,7 +145,12 @@ def init_db(app: Flask) -> None:
db.session.add(User(username=username)) db.session.add(User(username=username))
db.session.commit() db.session.commit()
runner: FlaskCliRunner = app.test_cli_runner() runner: FlaskCliRunner = app.test_cli_runner()
result: Result = runner.invoke(args=["accounting-init-db", "-u", "editor"]) args: list[str] = ["accounting-init-db", "-u", "editor"]
if is_skip_accounts:
args += ["--skip-accounts"]
if is_skip_currencies:
args += ["--skip-currencies"]
result: Result = runner.invoke(args=args)
assert result.exit_code == 0, result.output + str(result.exception) assert result.exit_code == 0, result.output + str(result.exception)

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
The Mia! Accounting Demonstration Website The Mia! Accounting Demonstration Website
base.html: The side-wide layout template 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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="{{ "imacat" }}" /> <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/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.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/@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.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/@eonasdan/tempus-dominus@6.9.6/dist/css/tempus-dominus.min.css" integrity="sha384-NzVf7b26bC2au5J9EqNceWlrs7iIkBa0bA46tRpK5C3J08J7MRTPmSdpRKhWNgDL" crossorigin="anonymous">
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script> <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/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 %} {% block scripts %}{% endblock %}
<link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}"> <link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>
</head> </head>
<body> <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"> <div class="container-fluid">
<a class="navbar-brand" href="{{ url_for("home.home") }}"> <a class="navbar-brand" href="{{ url_for("home.home") }}">
<i class="fa-solid fa-house"></i> <i class="fa-solid fa-house"></i>

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -46,9 +46,6 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
"""The Flask application.""" """The Flask application."""
with self.__app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI) self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI.""" """The encoded next URI."""
@@ -57,6 +54,15 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.__csrf_token: str = get_csrf_token(self.__client) self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token.""" """The CSRF token."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.

View File

@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -27,6 +27,7 @@ from accounting.utils.next_uri import append_next, inherit_next, or_next, \
encode_next, decode_next encode_next, decode_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords from accounting.utils.query import parse_query_keywords
from test_site import db
from testlib import TEST_SERVER, create_test_app, get_csrf_token, NEXT_URI from testlib import TEST_SERVER, create_test_app, get_csrf_token, NEXT_URI
@@ -43,6 +44,15 @@ class NextUriTestCase(unittest.TestCase):
self.__app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application.""" """The Flask application."""
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def test_next_uri(self) -> None: def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI. """Tests the next URI utilities with the next URI.
@@ -67,8 +77,9 @@ class NextUriTestCase(unittest.TestCase):
self.__app.add_url_rule("/test-next", view_func=test_next_uri_view, self.__app.add_url_rule("/test-next", view_func=test_next_uri_view,
methods=["GET", "POST"]) methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.__app, client: httpx.Client = httpx.Client(
base_url=TEST_SERVER) transport=httpx.WSGITransport(app=self.__app),
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client) csrf_token: str = get_csrf_token(client)
response: httpx.Response response: httpx.Response
@@ -96,8 +107,9 @@ class NextUriTestCase(unittest.TestCase):
self.__app.add_url_rule("/test-no-next", self.__app.add_url_rule("/test-no-next",
view_func=test_no_next_uri_view, view_func=test_no_next_uri_view,
methods=["GET", "POST"]) methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.__app, client: httpx.Client = httpx.Client(
base_url=TEST_SERVER) transport=httpx.WSGITransport(app=self.__app),
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client) csrf_token: str = get_csrf_token(client)
response: httpx.Response response: httpx.Response
@@ -122,8 +134,9 @@ class NextUriTestCase(unittest.TestCase):
self.__app.add_url_rule("/test-invalid-next", self.__app.add_url_rule("/test-invalid-next",
view_func=test_invalid_next_uri_view, view_func=test_invalid_next_uri_view,
methods=["GET", "POST"]) methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.__app, client: httpx.Client = httpx.Client(
base_url=TEST_SERVER) transport=httpx.WSGITransport(app=self.__app),
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client) csrf_token: str = get_csrf_token(client)
next_uri: str next_uri: str
@@ -227,11 +240,21 @@ class PaginationTestCase(unittest.TestCase):
self.assertEqual(pagination.list, self.__params.result) self.assertEqual(pagination.list, self.__params.result)
return "" return ""
self.__client: httpx.Client = httpx.Client(app=self.__app, self.__client: httpx.Client = httpx.Client(
base_url=TEST_SERVER) transport=httpx.WSGITransport(app=self.__app),
base_url=TEST_SERVER)
"""The user client.""" """The user client."""
self.__client.headers["Referer"] = TEST_SERVER self.__client.headers["Referer"] = TEST_SERVER
def tearDown(self) -> None:
"""Tears down the test.
This is run once per test.
:return: None.
"""
with self.__app.app_context():
db.engine.dispose()
def __test_success(self, query: str, items: range, def __test_success(self, query: str, items: range,
result: range, is_paged: bool = True, result: range, is_paged: bool = True,
is_reversed: bool | None = None) -> None: is_reversed: bool | None = None) -> None:

View File

@@ -60,12 +60,18 @@ class Accounts:
RENT_INCOME: str = "7482-001" RENT_INCOME: str = "7482-001"
def create_test_app() -> Flask: def create_test_app(is_skip_accounts: bool = False,
is_skip_currencies: bool = False) -> Flask:
"""Creates and returns the testing Flask application. """Creates and returns the testing Flask application.
:param is_skip_accounts: True to skip account initialization, or False
otherwise.
:param is_skip_currencies: True to skip currency initialization, or False
otherwise.
:return: The testing Flask application. :return: The testing Flask application.
""" """
app: Flask = create_app(is_testing=True) app: Flask = create_app(is_testing=True, is_skip_accounts=is_skip_accounts,
is_skip_currencies=is_skip_currencies)
@app.get("/.csrf-token") @app.get("/.csrf-token")
def get_csrf_token_view() -> str: def get_csrf_token_view() -> str:
@@ -96,7 +102,9 @@ def get_client(app: Flask, username: str) -> httpx.Client:
:param username: The username. :param username: The username.
:return: The user client. :return: The user client.
""" """
client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER) client: httpx.Client = httpx.Client(
transport=httpx.WSGITransport(app=app),
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client) csrf_token: str = get_csrf_token(client)
with app.app_context(): with app.app_context():