Compare commits

...

64 Commits
v1.5.3 ... main

Author SHA1 Message Date
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
e0ed81ad1f Advanced to version 1.5.11. 2023-12-16 21:52:15 +08:00
ece7481e9e Refined to enable the selection of the 3351-001 Accumulated Profit or Loss account. 2023-12-16 21:51:14 +08:00
50d4526e0b Advanced to version 1.5.10. 2023-11-28 08:27:31 +08:00
3f0a0b4227 Fixed the form validator to enable the selection of Accumulated Profit or Loss accounts other than 3351-001. 2023-11-28 08:26:37 +08:00
dcc9626b23 Fixed the release date of version 1.5.9 in the change log. 2023-11-28 08:17:25 +08:00
79eb077129 Advanced to version 1.5.9. 2023-11-28 08:10:00 +08:00
d5719ad223 Refined to enable the selection of Accumulated Profit or Loss accounts other than 3351-001, facilitating the consolidation of existing balances. 2023-11-28 08:09:35 +08:00
eb3fa8f414 Added docs/requirements.txt and the "sphinx_rtd_theme" theme to the readthedocs configuration, as Read the Docs does not install sphinx_rtd_theme by default
after August 7, 2023.
2023-11-28 08:04:11 +08:00
937908717b Advanced to version 1.5.8. 2023-10-24 05:00:53 +05:30
0104fa4c21 Fixed an icon in the detail of the cash receipt journal entry. 2023-10-24 04:43:11 +05:30
14365ca255 Advanced to version 1.5.7. 2023-07-29 13:24:52 +08:00
cd86651606 Added the "accounting-titleize" console command to capitalize the existing account titles that were already initialized. 2023-07-29 13:11:47 +08:00
9147744ff7 Renamed the test_init test to test_init_db in the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
1a212a5330 Updated the documentation of the test_init test of the ConsoleCommandTestCase test case. 2023-07-29 13:07:08 +08:00
0614457b7b Moved dropping tables from the setUp method to the test_init test in the ConsoleCommandTestCase test case. The other tests may not need to drop the tables first. 2023-07-29 13:07:08 +08:00
545f49043b Updated the Sphinx documentation. 2023-07-29 13:07:08 +08:00
cac0d66ca1 Updated the translation. 2023-07-29 13:07:08 +08:00
5ffd37c859 Revised to capitalize the account titles when initializing the base accounts instead of when displaying the account titles, so that the titles of the user-added accounts are not capitalized incorrectly. 2023-07-29 13:06:32 +08:00
9ae8c1bce9 Updated the translation. 2023-07-29 10:11:45 +08:00
ec0ff3e2e6 Updated the log in message at the home page, and removed the next URI from the log in link. The next URI is not clear text but encrypted now. There is no need to attach the next URI, as it defaults redirects to the accounting application without the next URI. 2023-07-29 10:11:45 +08:00
40a8080751 Removed unused imports from the test site. 2023-07-29 10:11:45 +08:00
736a4086ee Removed an unused import from testlib_journal_entry.py. 2023-07-29 10:11:45 +08:00
6723077b72 Revised the code to read from the CSV data files in the __test_base_account_data method of the ConsoleCommandTestCase test case, to prevent PyCharm from complaining. 2023-07-29 10:11:45 +08:00
0ae00bce79 Changed the properties of the test cases from public to private. 2023-07-29 10:11:45 +08:00
356d2010cc Removed the CSRF token from the get_client function in testlib.py, so that type hints and documentation can be added to the client and the CSRF token properties separately. 2023-07-29 10:11:45 +08:00
501c4b1d22 Added missing documentation to the global variables, class properties, and object properties. 2023-07-29 10:11:44 +08:00
64b9c8c11f Removed an excess property declaration in the populate_obj method of the JournalEntryForm form. 2023-07-29 10:03:46 +08:00
9072de82d4 Added the "decode_next" utility in the "accounting.utils.next_uri" module, and applied the "encode_next" and "decode_next" utilities to the NextUriTestCase test case, so that the test case do not need to get involved into the detail of the next URI encryption. 2023-07-29 10:03:45 +08:00
30fd9c2164 Fixed the documentation of the "is_default" property of the Period utility. 2023-06-05 22:43:35 +08:00
7cb01b4cee Revised the documentation of the columns of the data models. 2023-06-05 16:55:25 +08:00
9a4e04c41f Renamed the HTML ID "collapsible-navbar" to "accounting-collapsible-navbar" in the test site. 2023-06-03 11:12:28 +08:00
a9c4fa9de0 Advanced to version 1.5.6. 2023-05-23 09:32:48 +08:00
3a676e0b5a Fixed the back URL of the creation forms, applying the accounting_or_next filter for the decoded next URI instead of getting the next URI directly. 2023-05-23 09:32:48 +08:00
9cc7b64bb3 Moved the "__as_next" utility from the test site to the "accounting.utils.next_uri" module, and applied it to the template of the unmatched offset list. 2023-05-23 09:32:48 +08:00
352867797d Advanced to version 1.5.5. 2023-05-23 09:30:33 +08:00
09a344d749 Removed excess spaces from the test_change_date test of the JournalEntryReorderTestCase test case. 2023-05-23 09:30:33 +08:00
818c357613 Revised the next URI utilities to apply URLSafeSerializer for encoding and decoding the next URI, in order to prevent tampering with the next URI. 2023-05-23 09:30:19 +08:00
822c8fc49b Renamed the "__get_next_uri" function to "__get_next" in the "accounting.utils.next_uri" module. 2023-05-23 07:10:30 +08:00
3b8a2e3bb1 Replaced the "accounting-dummy-form" name with the dummy CSRF token to work with OWASP ZAP CSRF token scans. 2023-05-22 18:32:24 +08:00
9e4927ee0b Replaced the get_errors_view with the get_messages_view in the create_test_app function in testlib.py. 2023-05-22 00:03:13 +08:00
3b030c577c Added the integrity value of the CDN stylesheet links in the base template of the test site. 2023-05-19 18:17:29 +08:00
60b33f2a3b Revised the link to the stylesheet of tempus dominus in the base template of the test site. 2023-05-19 18:17:20 +08:00
08fdf59844 Revised the indent of the flashed success messages in the base template of the test site. 2023-05-19 18:17:11 +08:00
b397515457 Removed the size restriction in the next URI utilities. Buffer overflow may happen with any parameter, not only the "next" parameter. It should be solved in uWSGI, but not the application. 2023-05-18 23:30:36 +08:00
abe90d3483 Advanced to version 1.5.4. 2023-05-18 00:06:16 +08:00
65e7dcdf6d Replaced the "/next" next URI with the NEXT_URI constant in the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-18 00:06:05 +08:00
74e414badf Removed unnecessary f-strings from the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-17 23:54:52 +08:00
69175979ff Added the form name to the dummy forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 22:56:47 +08:00
2f69e0f215 Added the form name to the search forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 21:43:21 +08:00
961385c389 Added SESSION_COOKIE_SAMESITE and SESSION_COOKIE_SECURE to create_app of the test site, to set the SameSite and Secure flags for the session cookie. 2023-05-17 19:57:38 +08:00
a691cfd2da Applied the or_next utility to the set local route of the test site. 2023-05-17 19:57:23 +08:00
482a0faa23 Added safeguard to the next URI utilities from invalid or insecure next URI. 2023-05-17 16:26:35 +08:00
0ecf7b6617 Revised the documentation of the "accounting.utils.cast" module. 2023-05-17 15:33:42 +08:00
4408bbfc82 Updated the JavaScript library versions, and added decimal.js-light to the documentation. 2023-05-06 23:59:06 +08:00
433110f486 Revised the way to query accounts with Flask-SQLAlchemy style queries in the accounts method of the CurrentAccount data model. 2023-05-04 09:35:20 +08:00
94 changed files with 2127 additions and 1363 deletions

View File

@ -38,3 +38,4 @@ python:
install: install:
- method: pip - method: pip
path: . path: .
- requirements: docs/requirements.txt

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.

1
docs/requirements.txt Normal file
View File

@ -0,0 +1 @@
sphinx_rtd_theme

View File

@ -100,6 +100,14 @@ accounting.utils.strip\_text module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.utils.title\_case module
-----------------------------------
.. automodule:: accounting.utils.title_case
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module accounting.utils.user module
---------------------------- ----------------------------

View File

@ -2,6 +2,122 @@ 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
--------------
Released 2023/11/28
Bug fix.
* Fixed the form validator to enable the selection of Accumulated Profit or
Loss accounts other than 3351-001.
Version 1.5.9
-------------
Released 2023/11/28
Bug fix.
* Refined to enable the selection of Accumulated Profit or Loss accounts other
than 3351-001, facilitating the consolidation of existing balances.
Version 1.5.8
-------------
Released 2023/10/24
Bug fix.
* Fixed an icon in the detail of the cash receipt journal entry.
Released at Jaipur, India on vacation.
Version 1.5.7
-------------
Released 2023/7/29
Revised account title capitalization to capitalize account titles
upon initialization of base accounts, rather than when displaying
the accounts. This prevents the system from incorrectly
capitalizing titles of user-added accounts.
For existing installation, run the ``accounting-titleize`` console
command to capitalize the existing account titles that were already
initialized.
Other fixes:
* Added missing documentation to the global variables, class
properties, and object properties.
* Various minor fixes.
Version 1.5.6
-------------
Released 2023/5/23
Bug fixes.
* Fixed the return URI of the creation forms to decode the next URI.
* Fixed the unmatched offset list to use the encoded next URI.
Version 1.5.5
-------------
Released 2023/5/23
Security fixes.
* Revised the next URI utilities to encode and decode the next URI
preventing tampering with the next URI.
* Added the integrity value of the CDN stylesheet links.
* Various fixes.
Version 1.5.4
-------------
Released 2023/5/18
Security fixes.
* Added safeguard to the next URI utilities, to prevent Cross-Site
Scripting (XSS) attacks.
* Applied the safe next URI utilities to the test site.
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
of the test site.
Version 1.5.3 Version 1.5.3
------------- -------------

View File

@ -50,9 +50,9 @@ The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_. download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above * Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above * FontAwesome_ 6.4.0 or above
* `Decimal.js`_ 6.4.3 or above * `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
* `Tempus-Dominus`_ 6.4.3 or above * `Tempus-Dominus`_ 6.7.7 or above
Configuration Configuration
@ -114,6 +114,7 @@ Check your Flask application and see how it works.
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network .. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com .. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com .. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js .. _decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
.. _Tempus-Dominus: https://getdatepicker.com .. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/ .. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/

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-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,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,8 +42,7 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
test = [ devel = [
"unittest",
"httpx", "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.3" VERSION: str = "1.6.0"
"""The package version.""" """The package version."""
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
@ -63,8 +63,9 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
bp.add_app_template_global(default_currency_code, bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code") "accounting_default_currency_code")
from .commands import init_db_command from .commands import init_db_command, titleize_command
app.cli.add_command(init_db_command) app.cli.add_command(init_db_command)
app.cli.add_command(titleize_command)
from . import locale from . import locale
locale.init_app(app, bp) locale.init_app(app, bp)

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

@ -168,7 +168,9 @@ class AccountReorderForm:
:param base: The base account. :param base: The base account.
""" """
self.base: BaseAccount = base self.base: BaseAccount = base
"""The base account."""
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None: def save_order(self) -> None:
"""Saves the order of the account. """Saves the order of the account.

View File

@ -24,6 +24,7 @@ import sqlalchemy as sa
from accounting import data_dir from accounting import data_dir
from accounting import db from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.title_case import title_case
def init_base_accounts_command() -> None: def init_base_accounts_command() -> None:
@ -34,7 +35,7 @@ def init_base_accounts_command() -> None:
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
account_data: list[dict[str, str]] = [{"code": x["code"], account_data: list[dict[str, str]] = [{"code": x["code"],
"title_l10n": x["title"]} "title_l10n": title_case(x["title"])}
for x in data] for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")] locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"], l10n_data: list[dict[str, str]] = [{"account_code": x["code"],

View File

@ -26,7 +26,10 @@ from accounting import db
from accounting.account import init_accounts_command from accounting.account import init_accounts_command
from accounting.base_account import init_base_accounts_command from accounting.base_account import init_base_accounts_command
from accounting.currency import init_currencies_command from accounting.currency import init_currencies_command
from accounting.utils.user import has_user from accounting.models import BaseAccount, Account
from accounting.utils.title_case import title_case
from accounting.utils.user import has_user, get_user_pk
import sqlalchemy as sa
def __validate_username(ctx: click.core.Context, param: click.core.Option, def __validate_username(ctx: click.core.Context, param: click.core.Option,
@ -60,3 +63,32 @@ def init_db_command(username: str) -> None:
init_currencies_command(username) init_currencies_command(username)
db.session.commit() db.session.commit()
click.echo("Accounting database initialized.") click.echo("Accounting database initialized.")
@click.command("accounting-titleize")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def titleize_command(username: str) -> None:
"""Capitalize the account titles."""
updater_pk: int = get_user_pk(username)
updated: int = 0
for base in BaseAccount.query:
new_title: str = title_case(base.title_l10n)
if base.title_l10n != new_title:
base.title_l10n = new_title
updated = updated + 1
for account in Account.query:
if account.title_l10n.lower() == account.base.title_l10n.lower():
new_title: str = title_case(account.title_l10n)
if account.title_l10n != new_title:
account.title_l10n = new_title
account.updated_at = sa.func.now()
account.updated_by_id = updater_pk
updated = updated + 1
if updated == 0:
click.echo("All account titles were already capitalized.")
return
db.session.commit()
click.echo(f"{updated} account titles capitalized.")

View File

@ -65,12 +65,12 @@ class IsDebitAccount:
:param message: The error message. :param message: The error message.
""" """
self.__message: str | LazyString = message self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
return return
if re.match(r"^(?:[1235689]|7[5678])", field.data) \ if re.match(r"^(?:[1235689]|7[5678])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(self.__message) raise ValidationError(self.__message)
@ -85,12 +85,12 @@ class IsCreditAccount:
:param message: The error message. :param message: The error message.
""" """
self.__message: str | LazyString = message self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None: if field.data is None:
return return
if re.match(r"^(?:[123489]|7[1234])", field.data) \ if re.match(r"^(?:[123489]|7[1234])", field.data) \
and not field.data.startswith("3351-") \
and not field.data.startswith("3353-"): and not field.data.startswith("3353-"):
return return
raise ValidationError(self.__message) raise ValidationError(self.__message)

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
@ -151,7 +151,6 @@ class JournalEntryForm(FlaskForm):
is_new: bool = obj.id is None is_new: bool = obj.id is None
if is_new: if is_new:
obj.id = new_id(JournalEntry) obj.id = new_id(JournalEntry)
self.date: DateField
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
@ -309,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

@ -54,7 +54,9 @@ class JournalEntryReorderForm:
:param date: The date. :param date: The date.
""" """
self.date: dt.date = date self.date: dt.date = date
"""The date."""
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None: def save_order(self) -> None:
"""Saves the order of the account. """Saves the order of the account.

View File

@ -166,8 +166,11 @@ class DescriptionRecurring:
:param account: The account. :param account: The account.
""" """
self.name: str = name self.name: str = name
"""The name."""
self.account: DescriptionAccount = DescriptionAccount(account, 0) self.account: DescriptionAccount = DescriptionAccount(account, 0)
"""The account."""
self.description_template: str = description_template self.description_template: str = description_template
"""The description template."""
@property @property
def account_codes(self) -> list[str]: def account_codes(self) -> list[str]:

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

@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
from flask_babel_js import JAVASCRIPT, c2js from flask_babel_js import JAVASCRIPT, c2js
translation_dir: Path = Path(__file__).parent / "translations" translation_dir: Path = Path(__file__).parent / "translations"
"""The directory of the translation files."""
domain: Domain = Domain(translation_directories=[translation_dir], domain: Domain = Domain(translation_directories=[translation_dir],
domain="accounting") domain="accounting")
"""The message domain."""
def gettext(string, **variables) -> str: def gettext(string, **variables) -> str:
@ -120,6 +122,5 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param bp: The blueprint of the accounting application. :param bp: The blueprint of the accounting application.
:return: None. :return: None.
""" """
bp.add_url_rule("/_jstrans.js", "babel_catalog", bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
__babel_js_catalog_view)
app.jinja_env.globals["A_"] = domain.gettext app.jinja_env.globals["A_"] = domain.gettext

View File

@ -40,7 +40,7 @@ class BaseAccount(db.Model):
__tablename__ = "accounting_base_accounts" __tablename__ = "accounting_base_accounts"
"""The table name.""" """The table name."""
code: Mapped[str] = mapped_column(primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The account code."""
title_l10n: Mapped[str] = mapped_column("title") title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
l10n: Mapped[list[BaseAccountL10n]] \ l10n: Mapped[list[BaseAccountL10n]] \
@ -54,7 +54,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account. :return: The string representation of the base account.
""" """
return f"{self.code} {self.title.title()}" return f"{self.code} {self.title}"
@property @property
def title(self) -> str: def title(self) -> str:
@ -87,7 +87,7 @@ class BaseAccountL10n(db.Model):
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
primary_key=True) primary_key=True)
"""The code of the account.""" """The account code."""
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n") account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
"""The account.""" """The account."""
locale: Mapped[str] = mapped_column(primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
@ -151,7 +151,7 @@ class Account(db.Model):
:return: The string representation of this account. :return: The string representation of this account.
""" """
return f"{self.base_code}-{self.no:03d} {self.title.title()}" return f"{self.base_code}-{self.no:03d} {self.title}"
@property @property
def code(self) -> str: def code(self) -> str:
@ -304,7 +304,6 @@ class Account(db.Model):
cls.base_code.startswith("78"), cls.base_code.startswith("78"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3351",
cls.base_code != "3353")\ cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@ -326,7 +325,6 @@ class Account(db.Model):
cls.base_code.startswith("74"), cls.base_code.startswith("74"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3351",
cls.base_code != "3353")\ cls.base_code != "3353")\
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@ -369,9 +367,9 @@ class Currency(db.Model):
__tablename__ = "accounting_currencies" __tablename__ = "accounting_currencies"
"""The table name.""" """The table name."""
code: Mapped[str] = mapped_column(primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The currency code."""
name_l10n: Mapped[str] = mapped_column("name") name_l10n: Mapped[str] = mapped_column("name")
"""The name.""" """The currency name."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
@ -544,7 +542,7 @@ class JournalEntry(db.Model):
date: Mapped[dt.date] date: Mapped[dt.date]
"""The date.""" """The date."""
no: Mapped[int] = mapped_column(default=text("1")) no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the date.""" """The journal entry number under the date."""
note: Mapped[str | None] note: Mapped[str | None]
"""The note.""" """The note."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \

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

@ -42,7 +42,7 @@ class Period:
self.end: dt.date | None = end self.end: dt.date | None = end
"""The end of the period.""" """The end of the period."""
self.is_default: bool = False self.is_default: bool = False
"""Whether the is the default period.""" """Whether this is the default period."""
self.is_this_month: bool = False self.is_this_month: bool = False
"""Whether the period is this month.""" """Whether the period is this month."""
self.is_last_month: bool = False self.is_last_month: bool = False

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

@ -145,6 +145,7 @@ class AccountCollector:
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}), .filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351", Account.base_code == "3351",
Account.base_code == "3353")).all() Account.base_code == "3353")).all()
"""The accounts."""
account_by_id: dict[int, Account] \ account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts} = {x.id: x for x in self.__all_accounts}
self.accounts: list[ReportAccount] \ self.accounts: list[ReportAccount] \
@ -154,6 +155,7 @@ class AccountCollector:
account_by_id[x.id], account_by_id[x.id],
self.__period)) self.__period))
for x in account_balances] for x in account_balances]
"""The accounts on the balance sheet."""
self.__add_accumulated() self.__add_accumulated()
self.__add_current_period() self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no)) self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
@ -452,11 +454,11 @@ class BalanceSheet(BaseReport):
:return: The CSV rows for the section. :return: The CSV rows for the section.
""" """
rows: list[CSVHalfRow] \ rows: list[CSVHalfRow] \
= [CSVHalfRow(section.title.title.title(), None)] = [CSVHalfRow(section.title.title, None)]
for subsection in section.subsections: for subsection in section.subsections:
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None)) rows.append(CSVHalfRow(f" {subsection.title.title}", None))
for account in subsection.accounts: for account in subsection.accounts:
rows.append(CSVHalfRow(f" {str(account.account).title()}", rows.append(CSVHalfRow(f" {str(account.account)}",
account.amount)) account.amount))
return rows return rows

View File

@ -407,13 +407,13 @@ class IncomeExpenses(BaseReport):
gettext("Note"))] gettext("Note"))]
if self.__brought_forward is not None: if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date, rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account).title(), str(self.__brought_forward.account),
self.__brought_forward.description, self.__brought_forward.description,
self.__brought_forward.income, self.__brought_forward.income,
self.__brought_forward.expense, self.__brought_forward.expense,
self.__brought_forward.balance, self.__brought_forward.balance,
None)) None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.description, rows.extend([CSVRow(x.date, str(x.account), x.description,
x.income, x.expense, x.balance, x.note) x.income, x.expense, x.balance, x.note)
for x in self.__line_items]) for x in self.__line_items])
if self.__total is not None: if self.__total is not None:

View File

@ -106,6 +106,7 @@ class Section:
"""The subsections in the section.""" """The subsections in the section."""
self.accumulated: AccumulatedTotal \ self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title) = AccumulatedTotal(accumulated_title)
"""The accumulated total."""
@property @property
def total(self) -> Decimal: def total(self) -> Decimal:
@ -225,12 +226,12 @@ class IncomeStatement(BaseReport):
for x in balances})).all() for x in balances})).all()
total_titles: dict[str, str] \ total_titles: dict[str, str] \
= {"4": gettext("total operating revenue"), = {"4": gettext("Total Operating Revenue"),
"5": gettext("gross income"), "5": gettext("Gross Income"),
"6": gettext("operating income"), "6": gettext("Operating Income"),
"7": gettext("before tax income"), "7": gettext("Before Tax Income"),
"8": gettext("after tax income"), "8": gettext("After Tax Income"),
"9": gettext("net income or loss for current period")} "9": gettext("Net Income or Loss for Current Period")}
sections: dict[str, Section] \ sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles} = {x.code: Section(x, total_titles[x.code]) for x in titles}
@ -300,14 +301,14 @@ class IncomeStatement(BaseReport):
total_str: str = gettext("Total") total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))] rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections: for section in self.__sections:
rows.append(CSVRow(str(section.title).title(), None)) rows.append(CSVRow(str(section.title), None))
for subsection in section.subsections: for subsection in section.subsections:
rows.append(CSVRow(f" {str(subsection.title).title()}", None)) rows.append(CSVRow(f" {str(subsection.title)}", None))
for account in subsection.accounts: for account in subsection.accounts:
rows.append(CSVRow(f" {str(account.account).title()}", rows.append(CSVRow(f" {str(account.account)}",
account.amount)) account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total)) rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title.title(), rows.append(CSVRow(section.accumulated.title,
section.accumulated.amount)) section.accumulated.amount))
rows.append(CSVRow(None, None)) rows.append(CSVRow(None, None))
rows = rows[:-1] rows = rows[:-1]

View File

@ -160,7 +160,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
gettext("Debit"), gettext("Credit"), gettext("Debit"), gettext("Credit"),
gettext("Note"))] gettext("Note"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code, rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
str(x.account).title(), x.description, str(x.account), x.description,
x.debit, x.credit, x.journal_entry.note) x.debit, x.credit, x.journal_entry.note)
for x in line_items]) for x in line_items])
return rows return rows

View File

@ -224,7 +224,7 @@ class TrialBalance(BaseReport):
""" """
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"), rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
gettext("Credit"))] gettext("Credit"))]
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit) rows.extend([CSVRow(str(x.account), x.debit, x.credit)
for x in self.__accounts]) for x in self.__accounts])
rows.append(CSVRow(gettext("Total"), self.__total.debit, rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit)) self.__total.credit))

View File

@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))] rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count) rows.extend([CSVRow(str(x), x.count) for x in accounts])
for x in accounts])
return rows return rows

View File

@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows. :return: The CSV rows.
""" """
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))] rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count) rows.extend([CSVRow(str(x), x.count) for x in accounts])
for x in accounts])
return rows return rows

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

@ -23,6 +23,6 @@ First written: 2023/2/1
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %} {% block back_url %}{{ url_for("accounting.account.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %} {% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}

View File

@ -90,7 +90,7 @@ First written: 2023/1/31
{% endif %} {% endif %}
<div class="accounting-card col-sm-6"> <div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title|title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_need_offset %} {% if obj.is_need_offset %}
<div> <div>

View File

@ -32,7 +32,7 @@ First written: 2023/1/30
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -33,7 +33,7 @@ First written: 2023/2/1
</div> </div>
<div class="accounting-card col-sm-6"> <div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title|title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %} {% if obj.accounts %}
<div> <div>

View File

@ -26,7 +26,7 @@ First written: 2023/1/26
{% block content %} {% block content %}
<div class="mb-2 accounting-toolbar"> <div class="mb-2 accounting-toolbar">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

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

@ -23,6 +23,6 @@ First written: 2023/2/6
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %} {% block back_url %}{{ url_for("accounting.currency.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %} {% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}

View File

@ -32,7 +32,7 @@ First written: 2023/2/6
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %} {% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28 First written: 2023/2/28
#} #}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}"> <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true"> <div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -26,7 +26,7 @@ First written: 2023/3/14
<div> <div>
<div class="small"> <div class="small">
<span class="d-none d-md-inline">{{ line_item.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }} {{ line_item.account.title }}
</div> </div>
{% if line_item.description is not none %} {% if line_item.description is not none %}
<div>{{ line_item.description }}</div> <div>{{ line_item.description }}</div>

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
<form id="accounting-line-item-editor"> <form id="accounting-line-item-editor">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true"> <div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -42,7 +42,7 @@ First written: 2023/2/25
<div class="small"> <div class="small">
{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.journal_entry.date|accounting_format_date }}
<span class="d-none d-md-inline">{{ line_item.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }} {{ line_item.account.title }}
</div> </div>
{{ line_item.description|accounting_default }} {{ line_item.description|accounting_default }}
</div> </div>

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %} {% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -23,7 +23,7 @@ First written: 2023/2/26
{% block as_trasfer %} {% block as_trasfer %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a> </a>
{% endblock %} {% endblock %}

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %} {% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %} {% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %} {% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22 First written: 2023/3/22
#} #}
<form id="accounting-recurring-item-editor-{{ expense_income }}"> <form id="accounting-recurring-item-editor-{{ expense_income }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true"> <div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -20,21 +20,21 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<div class="accounting-report-table-row accounting-balance-sheet-section"> <div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ section.title.title|title }}</div> <div>{{ section.title.title }}</div>
</div> </div>
<div class="accounting-report-table-body"> <div class="accounting-report-table-body">
{% for subsection in section.subsections %} {% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection"> <div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div> <div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span> <span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }} {{ subsection.title.title }}
</div> </div>
</div> </div>
{% for account in subsection.accounts %} {% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}"> <a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div> <div>
<span class="d-none d-md-inline">{{ account.account.code }}</span> <span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }} {{ account.account.title }}
</div> </div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div> <div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a> </a>

View File

@ -20,7 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<div>{{ line_item.date|accounting_format_date }}</div> <div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ line_item.account.title|title }}</div> <div>{{ line_item.account.title }}</div>
<div>{{ line_item.description|accounting_default }}</div> <div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
{{ line_item.date|accounting_format_date }} {{ line_item.date|accounting_format_date }}
{% endif %} {% endif %}
{% if line_item.account %} {% if line_item.account %}
{{ line_item.account.title|title }} {{ line_item.account.title }}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}

View File

@ -19,7 +19,7 @@ search-modal.html: The search modal
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 First written: 2023/3/8
#} #}
<form action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label"> <form action="{{ url_for("accounting-report.search") }}" name="accounting-search-form" method="get" role="search" aria-labelledby="accounting-search-modal-label">
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true"> <div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -93,7 +93,7 @@ First written: 2023/3/8
{% for account in report.account_options %} {% for account in report.account_options %}
<li> <li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}"> <a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }} {{ account.title }}
</a> </a>
</li> </li>
{% endfor %} {% endfor %}
@ -118,7 +118,7 @@ First written: 2023/3/8
</button> </button>
{% endif %} {% endif %}
{% if use_search %} {% if use_search %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label"> <form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required"> <input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}

View File

@ -66,21 +66,21 @@ First written: 2023/3/7
<div class="accounting-report-table-row accounting-income-statement-section"> <div class="accounting-report-table-row accounting-income-statement-section">
<div> <div>
<span class="d-none d-md-inline">{{ section.title.code }}</span> <span class="d-none d-md-inline">{{ section.title.code }}</span>
{{ section.title.title|title }} {{ section.title.title }}
</div> </div>
</div> </div>
{% for subsection in section.subsections %} {% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-income-statement-subsection"> <div class="accounting-report-table-row accounting-income-statement-subsection">
<div> <div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span> <span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }} {{ subsection.title.title }}
</div> </div>
</div> </div>
{% for account in subsection.accounts %} {% for account in subsection.accounts %}
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}"> <a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
<div> <div>
<span class="d-none d-md-inline">{{ account.account.code }}</span> <span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }} {{ account.account.title }}
</div> </div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div> <div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a> </a>
@ -91,7 +91,7 @@ First written: 2023/3/7
</div> </div>
{% endfor %} {% endfor %}
<div class="accounting-report-table-row accounting-income-statement-total"> <div class="accounting-report-table-row accounting-income-statement-total">
<div>{{ section.accumulated.title|title }}</div> <div>{{ section.accumulated.title }}</div>
<div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div> <div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
</div> </div>
{% endfor %} {% endfor %}

View File

@ -65,7 +65,7 @@ First written: 2023/3/4
<div>{{ line_item.currency.name }}</div> <div>{{ line_item.currency.name }}</div>
<div> <div>
<span class="d-none d-md-inline">{{ line_item.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }} {{ line_item.account.title }}
</div> </div>
<div>{{ line_item.description|accounting_default }}</div> <div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
@ -82,7 +82,7 @@ First written: 2023/3/4
<div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.journal_entry.date|accounting_format_date }}
{{ line_item.account.title|title }} {{ line_item.account.title }}
{% if line_item.currency.code != accounting_default_currency_code() %} {% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %} {% endif %}

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}

View File

@ -62,7 +62,7 @@ First written: 2023/3/8
<div>{{ line_item.currency.name }}</div> <div>{{ line_item.currency.name }}</div>
<div> <div>
<span class="d-none d-md-inline">{{ line_item.account.code }}</span> <span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }} {{ line_item.account.title }}
</div> </div>
<div>{{ line_item.description|accounting_default }}</div> <div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
@ -79,7 +79,7 @@ First written: 2023/3/8
<div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}> <div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small"> <div class="text-muted small">
{{ line_item.journal_entry.date|accounting_format_date }} {{ line_item.journal_entry.date|accounting_format_date }}
{{ line_item.account.title|title }} {{ line_item.account.title }}
{% if line_item.currency.code != accounting_default_currency_code() %} {% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span> <span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %} {% endif %}

View File

@ -68,7 +68,7 @@ First written: 2023/3/5
<a class="accounting-report-table-row" href="{{ account.url }}"> <a class="accounting-report-table-row" href="{{ account.url }}">
<div> <div>
<span class="d-none d-md-inline">{{ account.account.code }}</span> <span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }} {{ account.account.title }}
</div> </div>
<div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div> <div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>

View File

@ -26,7 +26,7 @@ First written: 2023/4/8
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unapplied Items") }}{% else %}{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unapplied Items") }}{% else %}{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}
@ -46,9 +46,9 @@ First written: 2023/4/8
<div class="d-none d-sm-flex justify-content-center mb-3"> <div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center"> <h2 class="text-center">
{% if report.currency.code == accounting_default_currency_code() %} {% if report.currency.code == accounting_default_currency_code() %}
{{ A_("Accounts with Unapplied Items") }} {{ A_("Accounts With Unapplied Items") }}
{% else %} {% else %}
{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }} {{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
{% endif %} {% endif %}
</h2> </h2>
</div> </div>
@ -64,7 +64,7 @@ First written: 2023/4/8
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", currency=report.currency, account=account, period=report.period) }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", currency=report.currency, account=account, period=report.period) }}">
<div> <div>
<span class="d-none d-md-inline">{{ account.code }}</span> <span class="d-none d-md-inline">{{ account.code }}</span>
{{ account.title|title }} {{ account.title }}
</div> </div>
<div class="accounting-amount">{{ account.count }}</div> <div class="accounting-amount">{{ account.count }}</div>
</a> </a>

View File

@ -26,7 +26,7 @@ First written: 2023/4/7
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}

View File

@ -26,7 +26,7 @@ First written: 2023/4/17
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unmatched Offsets") }}{% else %}{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unmatched Offsets") }}{% else %}{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}
@ -46,9 +46,9 @@ First written: 2023/4/17
<div class="d-none d-sm-flex justify-content-center mb-3"> <div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center"> <h2 class="text-center">
{% if report.currency.code == accounting_default_currency_code() %} {% if report.currency.code == accounting_default_currency_code() %}
{{ A_("Accounts with Unmatched Offsets") }} {{ A_("Accounts With Unmatched Offsets") }}
{% else %} {% else %}
{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }} {{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
{% endif %} {% endif %}
</h2> </h2>
</div> </div>
@ -64,7 +64,7 @@ First written: 2023/4/17
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unmatched", currency=report.currency, account=account, period=report.period) }}"> <a class="accounting-report-table-row" href="{{ url_for("accounting-report.unmatched", currency=report.currency, account=account, period=report.period) }}">
<div> <div>
<span class="d-none d-md-inline">{{ account.code }}</span> <span class="d-none d-md-inline">{{ account.code }}</span>
{{ account.title|title }} {{ account.title }}
</div> </div>
<div class="accounting-amount">{{ account.count }}</div> <div class="accounting-amount">{{ account.count }}</div>
</a> </a>

View File

@ -26,7 +26,7 @@ First written: 2023/4/17
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %} {% block content %}
@ -49,7 +49,7 @@ First written: 2023/4/17
<form action="{{ url_for("accounting-report.match-offsets", currency=report.currency, account=report.account) }}" method="post"> <form action="{{ url_for("accounting-report.match-offsets", currency=report.currency, account=report.account) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}"> <input type="hidden" name="next" value="{{ accounting_as_next() }}">
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true"> <div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: mia-accounting 1.4.0\n" "Project-Id-Version: mia-accounting 1.4.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-18 09:32+0800\n" "POT-Creation-Date: 2023-07-29 08:55+0800\n"
"PO-Revision-Date: 2023-04-18 09:32+0800\n" "PO-Revision-Date: 2023-07-29 08:56+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -21,7 +21,7 @@ msgstr ""
#: src/accounting/forms.py:33 #: src/accounting/forms.py:33
#: src/accounting/static/js/journal-entry-form.js:1080 #: src/accounting/static/js/journal-entry-form.js:1080
#: src/accounting/static/js/journal-entry-line-item-editor.js:411 #: src/accounting/static/js/journal-entry-line-item-editor.js:415
#: src/accounting/static/js/option-form.js:537 #: src/accounting/static/js/option-form.js:537
#: src/accounting/static/js/option-form.js:803 #: src/accounting/static/js/option-form.js:803
msgid "Please select the account." msgid "Please select the account."
@ -35,22 +35,22 @@ msgstr "沒有這個貨幣。"
msgid "The account does not exist." msgid "The account does not exist."
msgstr "沒有這個科目。" msgstr "沒有這個科目。"
#: src/accounting/models.py:581 #: src/accounting/models.py:578
#, python-format #, python-format
msgid "Cash Disbursement Journal Entry#%(id)s" msgid "Cash Disbursement Journal Entry#%(id)s"
msgstr "現金支出傳票#%(id)s" msgstr "現金支出傳票#%(id)s"
#: src/accounting/models.py:584 #: src/accounting/models.py:581
#, python-format #, python-format
msgid "Cash Receipt Journal Entry#%(id)s" msgid "Cash Receipt Journal Entry#%(id)s"
msgstr "現金收入傳票#%(id)s" msgstr "現金收入傳票#%(id)s"
#: src/accounting/models.py:585 #: src/accounting/models.py:582
#, python-format #, python-format
msgid "Transfer Journal Entry#%(id)s" msgid "Transfer Journal Entry#%(id)s"
msgstr "轉帳傳票#%(id)s" msgstr "轉帳傳票#%(id)s"
#: src/accounting/models.py:714 #: src/accounting/models.py:706
#, python-format #, python-format
msgid "%(date)s %(description)s %(amount)s" msgid "%(date)s %(description)s %(amount)s"
msgstr "%(date)s %(description)s %(amount)s" msgstr "%(date)s %(description)s %(amount)s"
@ -101,7 +101,7 @@ msgid "Please fill in the title"
msgstr "請填上標題。" msgstr "請填上標題。"
#: src/accounting/account/queries.py:50 #: src/accounting/account/queries.py:50
#: src/accounting/report/reports/search.py:101 #: src/accounting/report/reports/search.py:100
#: src/accounting/templates/accounting/account/detail.html:97 #: src/accounting/templates/accounting/account/detail.html:97
#: src/accounting/templates/accounting/account/list.html:62 #: src/accounting/templates/accounting/account/list.html:62
msgid "Needs Offset" msgid "Needs Offset"
@ -205,24 +205,24 @@ msgstr "傳票不可刪除。"
msgid "The journal entry is deleted successfully." msgid "The journal entry is deleted successfully."
msgstr "傳票刪掉了" msgstr "傳票刪掉了"
#: src/accounting/journal_entry/forms/currency.py:39 #: src/accounting/journal_entry/forms/currency.py:38
msgid "Please select the currency." msgid "Please select the currency."
msgstr "請選擇貨幣。" msgstr "請選擇貨幣。"
#: src/accounting/journal_entry/forms/currency.py:62 #: src/accounting/journal_entry/forms/currency.py:61
msgid "The currency must be the same as the original line item." msgid "The currency must be the same as the original line item."
msgstr "貨幣需和原始分錄相同。" msgstr "貨幣需和原始分錄相同。"
#: src/accounting/journal_entry/forms/currency.py:89 #: src/accounting/journal_entry/forms/currency.py:88
msgid "The currency must not be changed when there is offset." msgid "The currency must not be changed when there is offset."
msgstr "抵銷過不可變更貨幣。" msgstr "抵銷過不可變更貨幣。"
#: src/accounting/journal_entry/forms/currency.py:98 #: src/accounting/journal_entry/forms/currency.py:97
#: src/accounting/static/js/journal-entry-form.js:773 #: src/accounting/static/js/journal-entry-form.js:773
msgid "Please add some line items." msgid "Please add some line items."
msgstr "請加上分錄。" msgstr "請加上分錄。"
#: src/accounting/journal_entry/forms/currency.py:111 #: src/accounting/journal_entry/forms/currency.py:110
#: src/accounting/static/js/journal-entry-form.js:522 #: src/accounting/static/js/journal-entry-form.js:522
msgid "The totals of the debit and credit amounts do not match." msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 " msgstr "借方貸方合計不符。 "
@ -251,62 +251,62 @@ msgstr "請加上貨幣。"
msgid "Line items with offset cannot be deleted." msgid "Line items with offset cannot be deleted."
msgstr "無法刪除抵銷過的分錄。" msgstr "無法刪除抵銷過的分錄。"
#: src/accounting/journal_entry/forms/line_item.py:49 #: src/accounting/journal_entry/forms/line_item.py:48
msgid "The original line item does not exist." msgid "The original line item does not exist."
msgstr "沒有這筆原始分錄。" msgstr "沒有這筆原始分錄。"
#: src/accounting/journal_entry/forms/line_item.py:70 #: src/accounting/journal_entry/forms/line_item.py:69
msgid "The original line item is on the same debit or credit." msgid "The original line item is on the same debit or credit."
msgstr "原始分錄在借貸同一邊。" msgstr "原始分錄在借貸同一邊。"
#: src/accounting/journal_entry/forms/line_item.py:85 #: src/accounting/journal_entry/forms/line_item.py:84
msgid "The original line item does not need offset." msgid "The original line item does not need offset."
msgstr "這筆原始分錄不需抵銷。" msgstr "這筆原始分錄不需抵銷。"
#: src/accounting/journal_entry/forms/line_item.py:101 #: src/accounting/journal_entry/forms/line_item.py:100
msgid "The original line item cannot be an offset item." msgid "The original line item cannot be an offset item."
msgstr "原始分錄不可以是抵銷分錄。" msgstr "原始分錄不可以是抵銷分錄。"
#: src/accounting/journal_entry/forms/line_item.py:119 #: src/accounting/journal_entry/forms/line_item.py:118
msgid "The account must be the same as the original line item." msgid "The account must be the same as the original line item."
msgstr "科目需和原始分錄相同。" msgstr "科目需和原始分錄相同。"
#: src/accounting/journal_entry/forms/line_item.py:135 #: src/accounting/journal_entry/forms/line_item.py:134
msgid "The account must not be changed when there is offset." msgid "The account must not be changed when there is offset."
msgstr "抵銷過不可變更科目。" msgstr "抵銷過不可變更科目。"
#: src/accounting/journal_entry/forms/line_item.py:151 #: src/accounting/journal_entry/forms/line_item.py:150
msgid "A payable line item cannot start from debit." msgid "A payable line item cannot start from debit."
msgstr "不可由借方新建應付款。" msgstr "不可由借方新建應付款。"
#: src/accounting/journal_entry/forms/line_item.py:167 #: src/accounting/journal_entry/forms/line_item.py:166
msgid "A receivable line item cannot start from credit." msgid "A receivable line item cannot start from credit."
msgstr "不可由貸方新建應收款。" msgstr "不可由貸方新建應收款。"
#: src/accounting/journal_entry/forms/line_item.py:178 #: src/accounting/journal_entry/forms/line_item.py:177
#: src/accounting/static/js/journal-entry-line-item-editor.js:436 #: src/accounting/static/js/journal-entry-line-item-editor.js:440
msgid "Please fill in a positive amount." msgid "Please fill in a positive amount."
msgstr "金額請填正數。" msgstr "金額請填正數。"
#: src/accounting/journal_entry/forms/line_item.py:220 #: src/accounting/journal_entry/forms/line_item.py:219
#: src/accounting/static/js/journal-entry-line-item-editor.js:442 #: src/accounting/static/js/journal-entry-line-item-editor.js:446
#, python-format #, python-format
msgid "" msgid ""
"The amount must not exceed the net balance %(balance)s of the original " "The amount must not exceed the net balance %(balance)s of the original "
"line item." "line item."
msgstr "金額不可超過原始分錄凈額 %(balance)s 。" msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
#: src/accounting/journal_entry/forms/line_item.py:241 #: src/accounting/journal_entry/forms/line_item.py:239
#: src/accounting/static/js/journal-entry-line-item-editor.js:450 #: src/accounting/static/js/journal-entry-line-item-editor.js:454
#, python-format #, python-format
msgid "The amount must not be less than the offset total %(total)s." msgid "The amount must not be less than the offset total %(total)s."
msgstr "金額不可低於抵銷總額 %(total)s 。" msgstr "金額不可低於抵銷總額 %(total)s 。"
#: src/accounting/journal_entry/forms/line_item.py:426 #: src/accounting/journal_entry/forms/line_item.py:424
msgid "This account is not for debit line items." msgid "This account is not for debit line items."
msgstr "科目不是借方科目。" msgstr "科目不是借方科目。"
#: src/accounting/journal_entry/forms/line_item.py:478 #: src/accounting/journal_entry/forms/line_item.py:476
msgid "This account is not for credit line items." msgid "This account is not for credit line items."
msgstr "科目不是貸方科目。" msgstr "科目不是貸方科目。"
@ -417,15 +417,15 @@ msgstr "去年"
msgid "All" msgid "All"
msgstr "全部" msgstr "全部"
#: src/accounting/report/reports/balance_sheet.py:423 #: src/accounting/report/reports/balance_sheet.py:425
#: src/accounting/report/reports/balance_sheet.py:427 #: src/accounting/report/reports/balance_sheet.py:429
#: src/accounting/report/reports/balance_sheet.py:439
#: src/accounting/report/reports/balance_sheet.py:441 #: src/accounting/report/reports/balance_sheet.py:441
#: src/accounting/report/reports/income_expenses.py:189 #: src/accounting/report/reports/balance_sheet.py:443
#: src/accounting/report/reports/income_expenses.py:423 #: src/accounting/report/reports/income_expenses.py:187
#: src/accounting/report/reports/income_statement.py:300 #: src/accounting/report/reports/income_expenses.py:420
#: src/accounting/report/reports/ledger.py:171 #: src/accounting/report/reports/income_statement.py:301
#: src/accounting/report/reports/ledger.py:380 #: src/accounting/report/reports/ledger.py:168
#: src/accounting/report/reports/ledger.py:376
#: src/accounting/report/reports/trial_balance.py:229 #: src/accounting/report/reports/trial_balance.py:229
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43 #: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:38 #: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:38
@ -445,14 +445,14 @@ msgstr "全部"
msgid "Total" msgid "Total"
msgstr "合計" msgstr "合計"
#: src/accounting/report/reports/income_expenses.py:136 #: src/accounting/report/reports/income_expenses.py:134
#: src/accounting/report/reports/ledger.py:132 #: src/accounting/report/reports/ledger.py:129
msgid "Brought forward" msgid "Brought forward"
msgstr "前期轉入" msgstr "前期轉入"
#: src/accounting/report/reports/income_expenses.py:407 #: src/accounting/report/reports/income_expenses.py:404
#: src/accounting/report/reports/journal.py:158 #: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/ledger.py:366 #: src/accounting/report/reports/ledger.py:362
#: src/accounting/report/reports/unapplied.py:148 #: src/accounting/report/reports/unapplied.py:148
#: src/accounting/report/reports/unmatched.py:158 #: src/accounting/report/reports/unmatched.py:158
#: src/accounting/templates/accounting/journal-entry/include/form.html:50 #: src/accounting/templates/accounting/journal-entry/include/form.html:50
@ -466,13 +466,13 @@ msgstr "前期轉入"
msgid "Date" msgid "Date"
msgstr "日期" msgstr "日期"
#: src/accounting/report/reports/income_expenses.py:407 #: src/accounting/report/reports/income_expenses.py:404
#: src/accounting/report/reports/journal.py:159 #: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/trial_balance.py:225 #: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/report/reports/unapplied_accounts.py:122 #: src/accounting/report/reports/unapplied_accounts.py:122
#: src/accounting/report/reports/unmatched_accounts.py:122 #: src/accounting/report/reports/unmatched_accounts.py:122
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:58
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:40
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90 #: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
#: src/accounting/templates/accounting/report/income-expenses.html:56 #: src/accounting/templates/accounting/report/income-expenses.html:56
#: src/accounting/templates/accounting/report/journal.html:55 #: src/accounting/templates/accounting/report/journal.html:55
@ -481,13 +481,13 @@ msgstr "日期"
msgid "Account" msgid "Account"
msgstr "科目" msgstr "科目"
#: src/accounting/report/reports/income_expenses.py:408 #: src/accounting/report/reports/income_expenses.py:405
#: src/accounting/report/reports/journal.py:159 #: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/ledger.py:366 #: src/accounting/report/reports/ledger.py:362
#: src/accounting/report/reports/unapplied.py:149 #: src/accounting/report/reports/unapplied.py:149
#: src/accounting/report/reports/unmatched.py:159 #: src/accounting/report/reports/unmatched.py:159
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:29
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:50
#: src/accounting/templates/accounting/report/income-expenses.html:57 #: src/accounting/templates/accounting/report/income-expenses.html:57
#: src/accounting/templates/accounting/report/journal.html:56 #: src/accounting/templates/accounting/report/journal.html:56
#: src/accounting/templates/accounting/report/ledger.html:56 #: src/accounting/templates/accounting/report/ledger.html:56
@ -497,18 +497,18 @@ msgstr "科目"
msgid "Description" msgid "Description"
msgstr "摘要" msgstr "摘要"
#: src/accounting/report/reports/income_expenses.py:408 #: src/accounting/report/reports/income_expenses.py:405
#: src/accounting/templates/accounting/report/income-expenses.html:58 #: src/accounting/templates/accounting/report/income-expenses.html:58
msgid "Income" msgid "Income"
msgstr "收入" msgstr "收入"
#: src/accounting/report/reports/income_expenses.py:409 #: src/accounting/report/reports/income_expenses.py:406
#: src/accounting/templates/accounting/report/income-expenses.html:59 #: src/accounting/templates/accounting/report/income-expenses.html:59
msgid "Expense" msgid "Expense"
msgstr "支出" msgstr "支出"
#: src/accounting/report/reports/income_expenses.py:409 #: src/accounting/report/reports/income_expenses.py:406
#: src/accounting/report/reports/ledger.py:368 #: src/accounting/report/reports/ledger.py:364
#: src/accounting/report/reports/unmatched.py:160 #: src/accounting/report/reports/unmatched.py:160
#: src/accounting/templates/accounting/report/income-expenses.html:60 #: src/accounting/templates/accounting/report/income-expenses.html:60
#: src/accounting/templates/accounting/report/ledger.html:60 #: src/accounting/templates/accounting/report/ledger.html:60
@ -516,41 +516,41 @@ msgstr "支出"
msgid "Balance" msgid "Balance"
msgstr "餘額" msgstr "餘額"
#: src/accounting/report/reports/income_expenses.py:410 #: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:161 #: src/accounting/report/reports/journal.py:161
#: src/accounting/report/reports/ledger.py:368 #: src/accounting/report/reports/ledger.py:364
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:179
#: src/accounting/templates/accounting/journal-entry/include/form.html:73 #: src/accounting/templates/accounting/journal-entry/include/form.html:73
msgid "Note" msgid "Note"
msgstr "備註" msgstr "備註"
#: src/accounting/report/reports/income_statement.py:228 #: src/accounting/report/reports/income_statement.py:229
msgid "total operating revenue" msgid "Total Operating Revenue"
msgstr "營業收入總額" msgstr "營業收入總額"
#: src/accounting/report/reports/income_statement.py:229 #: src/accounting/report/reports/income_statement.py:230
msgid "gross income" msgid "Gross Income"
msgstr "營業毛利" msgstr "營業毛利"
#: src/accounting/report/reports/income_statement.py:230 #: src/accounting/report/reports/income_statement.py:231
msgid "operating income" msgid "Operating Income"
msgstr "營業淨利" msgstr "營業淨利"
#: src/accounting/report/reports/income_statement.py:231 #: src/accounting/report/reports/income_statement.py:232
msgid "before tax income" msgid "Before Tax Income"
msgstr "稅前淨利" msgstr "稅前淨利"
#: src/accounting/report/reports/income_statement.py:232 #: src/accounting/report/reports/income_statement.py:233
msgid "after tax income" msgid "After Tax Income"
msgstr "稅後淨利" msgstr "稅後淨利"
#: src/accounting/report/reports/income_statement.py:233 #: src/accounting/report/reports/income_statement.py:234
msgid "net income or loss for current period" msgid "Net Income or Loss for Current Period"
msgstr "本期損益" msgstr "本期損益"
#: src/accounting/report/reports/income_statement.py:301 #: src/accounting/report/reports/income_statement.py:302
#: src/accounting/report/reports/unapplied.py:149 #: src/accounting/report/reports/unapplied.py:149
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:66
#: src/accounting/templates/accounting/report/income-statement.html:61 #: src/accounting/templates/accounting/report/income-statement.html:61
#: src/accounting/templates/accounting/report/unapplied.html:54 #: src/accounting/templates/accounting/report/unapplied.html:54
msgid "Amount" msgid "Amount"
@ -567,7 +567,7 @@ msgid "Currency"
msgstr "貨幣" msgstr "貨幣"
#: src/accounting/report/reports/journal.py:160 #: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367 #: src/accounting/report/reports/ledger.py:363
#: src/accounting/report/reports/trial_balance.py:225 #: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/report/reports/unmatched.py:159 #: src/accounting/report/reports/unmatched.py:159
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33 #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
@ -581,7 +581,7 @@ msgid "Debit"
msgstr "借方" msgstr "借方"
#: src/accounting/report/reports/journal.py:160 #: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367 #: src/accounting/report/reports/ledger.py:363
#: src/accounting/report/reports/trial_balance.py:226 #: src/accounting/report/reports/trial_balance.py:226
#: src/accounting/report/reports/unmatched.py:160 #: src/accounting/report/reports/unmatched.py:160
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49 #: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
@ -614,16 +614,16 @@ msgstr "淨額"
msgid "Count" msgid "Count"
msgstr "數量" msgstr "數量"
#: src/accounting/report/utils/offset_matcher.py:163 #: src/accounting/report/utils/offset_matcher.py:161
msgid "There is no unmatched offset." msgid "There is no unmatched offset."
msgstr "沒有遺漏的抵銷分錄" msgstr "沒有遺漏的抵銷分錄"
#: src/accounting/report/utils/offset_matcher.py:167 #: src/accounting/report/utils/offset_matcher.py:165
#, python-format #, python-format
msgid "%(total)s unmatched offsets without original items." msgid "%(total)s unmatched offsets without original items."
msgstr "%(total)s 筆遺漏的抵銷分錄無法自動抵銷。" msgstr "%(total)s 筆遺漏的抵銷分錄無法自動抵銷。"
#: src/accounting/report/utils/offset_matcher.py:172 #: src/accounting/report/utils/offset_matcher.py:170
#, python-format #, python-format
msgid "" msgid ""
"%(matches)s unmatched offsets out of %(total)s can match with their " "%(matches)s unmatched offsets out of %(total)s can match with their "
@ -752,7 +752,7 @@ msgid "December"
msgstr "十二月" msgstr "十二月"
#: src/accounting/static/js/journal-entry-form.js:1085 #: src/accounting/static/js/journal-entry-form.js:1085
#: src/accounting/static/js/journal-entry-line-item-editor.js:430 #: src/accounting/static/js/journal-entry-line-item-editor.js:434
msgid "Please fill in the amount." msgid "Please fill in the amount."
msgstr "請填上金額。" msgstr "請填上金額。"
@ -833,12 +833,12 @@ msgstr "確認刪除科目"
#: src/accounting/templates/accounting/account/include/form.html:91 #: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:73 #: src/accounting/templates/accounting/currency/detail.html:73
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:27 #: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:27
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:30 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:31
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78 #: src/accounting/templates/accounting/journal-entry/include/detail.html:78
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:29
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27 #: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27 #: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:29
#: src/accounting/templates/accounting/report/include/period-chooser.html:27 #: src/accounting/templates/accounting/report/include/period-chooser.html:27
#: src/accounting/templates/accounting/report/include/search-modal.html:28 #: src/accounting/templates/accounting/report/include/search-modal.html:28
#: src/accounting/templates/accounting/report/unmatched.html:58 #: src/accounting/templates/accounting/report/unmatched.html:58
@ -853,11 +853,11 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/include/form.html:112 #: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:79 #: src/accounting/templates/accounting/currency/detail.html:79
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:49 #: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:49
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:194 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:195
#: src/accounting/templates/accounting/journal-entry/include/detail.html:84 #: src/accounting/templates/accounting/journal-entry/include/detail.html:84
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:70 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48 #: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
#: src/accounting/templates/accounting/report/include/search-modal.html:37 #: src/accounting/templates/accounting/report/include/search-modal.html:37
#: src/accounting/templates/accounting/report/unmatched.html:74 #: src/accounting/templates/accounting/report/unmatched.html:74
msgid "Cancel" msgid "Cancel"
@ -942,12 +942,12 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75 #: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62 #: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57 #: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:195 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:196
#: src/accounting/templates/accounting/journal-entry/include/form.html:80 #: src/accounting/templates/accounting/journal-entry/include/form.html:80
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:72
#: src/accounting/templates/accounting/journal-entry/order.html:61 #: src/accounting/templates/accounting/journal-entry/order.html:61
#: src/accounting/templates/accounting/option/form.html:80 #: src/accounting/templates/accounting/option/form.html:80
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:67
msgid "Save" msgid "Save"
msgstr "儲存" msgstr "儲存"
@ -1008,7 +1008,7 @@ msgid "Code"
msgstr "代碼" msgstr "代碼"
#: src/accounting/templates/accounting/currency/include/form.html:50 #: src/accounting/templates/accounting/currency/include/form.html:50
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:33 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:34
msgid "Name" msgid "Name"
msgstr "名稱" msgstr "名稱"
@ -1077,53 +1077,53 @@ msgstr "選擇科目"
msgid "More…" msgid "More…"
msgstr "更多…" msgstr "更多…"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:36 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:37
msgid "Offset..." msgid "Offset..."
msgstr "抵銷…" msgstr "抵銷…"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:44 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:45
msgid "General" msgid "General"
msgstr "一般" msgstr "一般"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:49 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:50
msgid "Travel" msgid "Travel"
msgstr "差旅" msgstr "差旅"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:54 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:55
msgid "Bus" msgid "Bus"
msgstr "公車" msgstr "公車"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:59 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:60
msgid "Recurring" msgid "Recurring"
msgstr "常用" msgstr "常用"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:64 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:65
msgid "Annotation" msgid "Annotation"
msgstr "註記" msgstr "註記"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:73 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:74
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:90 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:91
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:125 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:126
msgid "Tag" msgid "Tag"
msgstr "標籤" msgstr "標籤"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:105 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:106
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:146 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:147
#: src/accounting/templates/accounting/report/include/period-chooser.html:129 #: src/accounting/templates/accounting/report/include/period-chooser.html:129
msgid "From" msgid "From"
msgstr "從" msgstr "從"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:114 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:115
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:151 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:152
#: src/accounting/templates/accounting/report/include/period-chooser.html:135 #: src/accounting/templates/accounting/report/include/period-chooser.html:135
msgid "To" msgid "To"
msgstr "至" msgstr "至"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:130 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:131
msgid "Route" msgid "Route"
msgstr "路線" msgstr "路線"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172 #: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:173
msgid "The Number of Items" msgid "The Number of Items"
msgstr "數量" msgstr "數量"
@ -1155,11 +1155,11 @@ msgstr "確認刪除傳票"
msgid "Do you really want to delete this journal entry?" msgid "Do you really want to delete this journal entry?"
msgstr "你確定要刪掉這張傳票嗎?" msgstr "你確定要刪掉這張傳票嗎?"
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:27 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
msgid "Line Item Content" msgid "Line Item Content"
msgstr "分錄內容" msgstr "分錄內容"
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:34 #: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:35
msgid "Original Line Item" msgid "Original Line Item"
msgstr "原始分錄" msgstr "原始分錄"
@ -1215,43 +1215,43 @@ msgstr "常用支出"
msgid "Recurring Income" msgid "Recurring Income"
msgstr "常用收入" msgstr "常用收入"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:47 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:48
msgid "Description Template" msgid "Description Template"
msgstr "摘要範本" msgstr "摘要範本"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:52 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:53
msgid "Available template variables:" msgid "Available template variables:"
msgstr "範本變數說明:" msgstr "範本變數說明:"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:54 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
msgid "This month, as a number." msgid "This month, as a number."
msgstr "這個月的數字。" msgstr "這個月的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
msgid "This month, in its name." msgid "This month, in its name."
msgstr "這個月的名稱。" msgstr "這個月的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
msgid "Last month, as a number." msgid "Last month, as a number."
msgstr "上個月的數字。" msgstr "上個月的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
msgid "Last month, in its name." msgid "Last month, in its name."
msgstr "上個月的名稱。" msgstr "上個月的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
msgid "The previous bimonthly period, as numbers." msgid "The previous bimonthly period, as numbers."
msgstr "前個雙月期的數字。" msgstr "前個雙月期的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:60
msgid "The previous bimonthly period, as their names." msgid "The previous bimonthly period, as their names."
msgstr "前個雙月期的名稱。" msgstr "前個雙月期的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
msgid "Example:" msgid "Example:"
msgstr "範例:" msgstr "範例:"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61 #: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
msgid "Water bill for {last_bimonthly_name}" msgid "Water bill for {last_bimonthly_name}"
msgstr "水費{last_bimonthly_number}月" msgstr "水費{last_bimonthly_number}月"
@ -1318,13 +1318,13 @@ msgstr "%(period)s%(currency)s試算表"
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29 #: src/accounting/templates/accounting/report/unapplied-accounts.html:29
#: src/accounting/templates/accounting/report/unapplied-accounts.html:49 #: src/accounting/templates/accounting/report/unapplied-accounts.html:49
msgid "Accounts with Unapplied Items" msgid "Accounts With Unapplied Items"
msgstr "含未抵銷項目的科目" msgstr "含未抵銷項目的科目"
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29 #: src/accounting/templates/accounting/report/unapplied-accounts.html:29
#: src/accounting/templates/accounting/report/unapplied-accounts.html:51 #: src/accounting/templates/accounting/report/unapplied-accounts.html:51
#, python-format #, python-format
msgid "Accounts with Unapplied Items in %(currency)s" msgid "Accounts With Unapplied Items in %(currency)s"
msgstr "%(currency)s含未抵銷項目的科目" msgstr "%(currency)s含未抵銷項目的科目"
#: src/accounting/templates/accounting/report/unapplied.html:29 #: src/accounting/templates/accounting/report/unapplied.html:29
@ -1339,13 +1339,13 @@ msgstr "%(currency)s%(account)s未抵銷項目"
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29 #: src/accounting/templates/accounting/report/unmatched-accounts.html:29
#: src/accounting/templates/accounting/report/unmatched-accounts.html:49 #: src/accounting/templates/accounting/report/unmatched-accounts.html:49
msgid "Accounts with Unmatched Offsets" msgid "Accounts With Unmatched Offsets"
msgstr "含遺漏抵銷項目的科目" msgstr "含遺漏抵銷項目的科目"
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29 #: src/accounting/templates/accounting/report/unmatched-accounts.html:29
#: src/accounting/templates/accounting/report/unmatched-accounts.html:51 #: src/accounting/templates/accounting/report/unmatched-accounts.html:51
#, python-format #, python-format
msgid "Accounts with Unmatched Offsets in %(currency)s" msgid "Accounts With Unmatched Offsets in %(currency)s"
msgstr "%(currency)s含遺漏抵銷項目的科目" msgstr "%(currency)s含遺漏抵銷項目的科目"
#: src/accounting/templates/accounting/report/unmatched.html:29 #: src/accounting/templates/accounting/report/unmatched.html:29
@ -1415,12 +1415,12 @@ msgstr "下載"
msgid "current assets and liabilities" msgid "current assets and liabilities"
msgstr "流動資產與負債" msgstr "流動資產與負債"
#: src/accounting/utils/pagination.py:206 #: src/accounting/utils/pagination.py:207
msgctxt "Pagination|" msgctxt "Pagination|"
msgid "Previous" msgid "Previous"
msgstr "上一頁" msgstr "上一頁"
#: src/accounting/utils/pagination.py:255 #: src/accounting/utils/pagination.py:256
msgctxt "Pagination|" msgctxt "Pagination|"
msgid "Next" msgid "Next"
msgstr "下一頁" msgstr "下一頁"

View File

@ -14,8 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The utility to cast a SQLAlchemy column into the column type, to avoid """The utilities to cast values into desired types, to avoid IDE warnings.
warnings from the IDE.
This module should not import any other module from the application. This module should not import any other module from the application.

View File

@ -21,7 +21,6 @@ from typing import Self
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Account from accounting.models import Account
@ -75,7 +74,7 @@ class CurrentAccount:
""" """
accounts: list[cls] = [cls.current_assets_and_liabilities()] accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x) accounts.extend([CurrentAccount(x)
for x in db.session.query(Account) for x in Account.query
.filter(cls.sql_condition()) .filter(cls.sql_condition())
.order_by(Account.base_code, Account.no)]) .order_by(Account.base_code, Account.no)])
return accounts return accounts

View File

@ -22,7 +22,17 @@ This module should not import any other module from the application.
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \ from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
urlunparse urlunparse
from flask import request, Blueprint from flask import request, Blueprint, current_app
from itsdangerous import URLSafeSerializer, BadData
def __as_next() -> str:
"""Encodes the current request URI as value for the next URI.
:return: The current request URI as value for the next URI.
"""
return encode_next(
request.full_path if request.query_string else request.path)
def append_next(uri: str) -> str: def append_next(uri: str) -> str:
@ -41,11 +51,8 @@ def inherit_next(uri: str) -> str:
:param uri: The URI. :param uri: The URI.
:return: The URI with the current next URI added at the query argument. :return: The URI with the current next URI added at the query argument.
""" """
next_uri: str | None = request.form.get("next") \ next_uri: str | None = __get_next()
if request.method == "POST" else request.args.get("next") return uri if next_uri is None else __set_next(uri, next_uri)
if next_uri is None:
return uri
return __set_next(uri, next_uri)
def or_next(uri: str) -> str: def or_next(uri: str) -> str:
@ -54,9 +61,23 @@ def or_next(uri: str) -> str:
:param uri: The URI. :param uri: The URI.
:return: The next URI or the supplied URI. :return: The next URI or the supplied URI.
""" """
next_uri: str | None = __get_next()
return uri if next_uri is None else next_uri
def __get_next() -> str | None:
"""Returns the valid next URI.
:return: The valid next URI.
"""
next_uri: str | None = request.form.get("next") \ next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.get("next") if request.method == "POST" else request.args.get("next")
return uri if next_uri is None else next_uri if next_uri is None:
return None
try:
return decode_next(next_uri)
except BadData:
return None
def __set_next(uri: str, next_uri: str) -> str: def __set_next(uri: str, next_uri: str) -> str:
@ -69,18 +90,39 @@ def __set_next(uri: str, next_uri: str) -> str:
uri_p: ParseResult = urlparse(uri) uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query) params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "next"] params = [x for x in params if x[0] != "next"]
params.append(("next", next_uri)) params.append(("next", encode_next(next_uri)))
parts: list[str] = list(uri_p) parts: list[str] = list(uri_p)
parts[4] = urlencode(params) parts[4] = urlencode(params)
return urlunparse(parts) return urlunparse(parts)
def encode_next(uri: str) -> str:
"""Encodes the next URI.
:param uri: The next URI.
:return: The encoded next URI.
"""
return URLSafeSerializer(current_app.config["SECRET_KEY"])\
.dumps(uri, "next")
def decode_next(uri: str) -> str:
"""Decodes the encoded next URI.
:param uri: The encoded next URI.
:return: The next URI.
"""
return URLSafeSerializer(current_app.config["SECRET_KEY"])\
.loads(uri, "next")
def init_app(bp: Blueprint) -> None: def init_app(bp: Blueprint) -> None:
"""Initializes the application. """Initializes the application.
:param bp: The blueprint of the accounting application. :param bp: The blueprint of the accounting application.
:return: None. :return: None.
""" """
bp.add_app_template_global(__as_next, "accounting_as_next")
bp.add_app_template_filter(append_next, "accounting_append_next") bp.add_app_template_filter(append_next, "accounting_append_next")
bp.add_app_template_filter(inherit_next, "accounting_inherit_next") bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
bp.add_app_template_filter(or_next, "accounting_or_next") bp.add_app_template_filter(or_next, "accounting_or_next")

View File

@ -39,8 +39,11 @@ class RecurringItem:
:param description_template: The description template. :param description_template: The description template.
""" """
self.name: str = name self.name: str = name
"""The name."""
self.account_code: str = account_code self.account_code: str = account_code
"""The account code."""
self.description_template: str = description_template self.description_template: str = description_template
"""The description template."""
@property @property
def account_text(self) -> str: def account_text(self) -> str:
@ -61,8 +64,10 @@ class Recurring:
""" """
self.expenses: list[RecurringItem] \ self.expenses: list[RecurringItem] \
= [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]] = [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]]
"""The recurring expenses."""
self.incomes: list[RecurringItem] \ self.incomes: list[RecurringItem] \
= [RecurringItem(x[0], x[1], x[2]) for x in data["income"]] = [RecurringItem(x[0], x[1], x[2]) for x in data["income"]]
"""The recurring incomes."""
@property @property
def codes(self) -> set[str]: def codes(self) -> set[str]:

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,10 +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")
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):
@ -91,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):
@ -108,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

@ -0,0 +1,59 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/7/29
# Copyright (c) 2023 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 title case capitalization for the base account titles.
This follows the APA style title case capitalization. See
https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case .
This module should not import any other module from the application.
"""
import re
CONJUNCTIONS: set[str] = {"and", "as", "but", "for", "if", "nor", "or", "so",
"yet"}
"""Short conjunctions."""
ARTICLES: set[str] = {"a", "an", "the"}
"""Articles."""
PREPOSITIONS: set[str] = {"as", "at", "by", "for", "in", "of", "on", "per",
"to", "up", "via"}
"""Short prepositions."""
MINOR_WORDS: set[str] \
= CONJUNCTIONS.copy().union(ARTICLES).union(PREPOSITIONS)
"""Minor words that should be in lowercase."""
# Excludes "by" as in "1223 by-products"
MINOR_WORDS.remove("by")
def title_case(s: str) -> str:
"""Capitalize a title string for the base account titles. Do not use it
in other places. This excludes "by" as in "1223 by-products".
:param s: The title string.
:return: The capitalized title string.
"""
return re.sub(r"\w+", __cap_word, s)
def __cap_word(m: re.Match) -> str:
"""Capitalize a matched title word.
:param m: The matched title word.
:return: The capitalized title word.
"""
if m.group(0).lower() in MINOR_WORDS:
return m.group(0)
return m.group(0).title()

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,16 +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)
class UserUtilityInterface[T: Model](ABC):
class UserUtilityInterface(Generic[T], ABC):
"""The interface for the user utilities.""" """The interface for the user utilities."""
@abstractmethod @abstractmethod
@ -112,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

@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
from opencc import OpenCC from opencc import OpenCC
root_dir: Path = Path(__file__).parent.parent root_dir: Path = Path(__file__).parent.parent
"""The project root directory."""
translation_dir: Path = root_dir / "tests" / "test_site" / "translations" translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
"""The directory of the translation files."""
domain: str = "messages" domain: str = "messages"
"""The message domain."""
@click.group() @click.group()

View File

@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
from opencc import OpenCC from opencc import OpenCC
root_dir: Path = Path(__file__).parent.parent root_dir: Path = Path(__file__).parent.parent
"""The project root directory."""
translation_dir: Path = root_dir / "src" / "accounting" / "translations" translation_dir: Path = root_dir / "src" / "accounting" / "translations"
"""The directory of the translation files."""
domain: str = "accounting" domain: str = "accounting"
"""The message domain."""
@click.group() @click.group()

View File

@ -23,9 +23,10 @@ import unittest
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \ from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
add_journal_entry set_locale, add_journal_entry
class AccountData: class AccountData:
@ -71,29 +72,35 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): with self.__app.app_context():
from accounting.models import Account, AccountL10n from accounting.models import Account, AccountL10n
AccountL10n.query.delete() AccountL10n.query.delete()
Account.query.delete() Account.query.delete()
db.session.commit() db.session.commit()
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
response: httpx.Response response: httpx.Response
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code, "base_code": CASH.base_code,
"title": CASH.title}) "title": CASH.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{CASH.code}") f"{PREFIX}/{CASH.code}")
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": BANK.base_code, "base_code": BANK.base_code,
"title": BANK.title}) "title": BANK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{BANK.code}") f"{PREFIX}/{BANK.code}")
@ -104,7 +111,8 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
client, csrf_token = get_client(self.app, "nobody") client: httpx.Client = get_client(self.__app, "nobody")
csrf_token: str = get_csrf_token(client)
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -138,12 +146,12 @@ class AccountTestCase(unittest.TestCase):
response = client.get(f"{PREFIX}/bases/{CASH.base_code}") response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
with self.app.app_context(): with self.__app.app_context():
cash_id: int = Account.find_by_code(CASH.code).id cash_id: int = Account.find_by_code(CASH.code).id
response = client.post(f"{PREFIX}/bases/{CASH.base_code}", response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": NEXT_URI, "next": self.__encoded_next_uri,
f"{cash_id}-no": "5"}) f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -153,7 +161,8 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
client, csrf_token = get_client(self.app, "viewer") client: httpx.Client = get_client(self.__app, "viewer")
csrf_token: str = get_csrf_token(client)
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -187,12 +196,12 @@ class AccountTestCase(unittest.TestCase):
response = client.get(f"{PREFIX}/bases/{CASH.base_code}") response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
with self.app.app_context(): with self.__app.app_context():
cash_id: int = Account.find_by_code(CASH.code).id cash_id: int = Account.find_by_code(CASH.code).id
response = client.post(f"{PREFIX}/bases/{CASH.base_code}", response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": NEXT_URI, "next": self.__encoded_next_uri,
f"{cash_id}-no": "5"}) f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -204,48 +213,48 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account from accounting.models import Account
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX) response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{CASH.code}") response = self.__client.get(f"{PREFIX}/{CASH.code}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create") response = self.__client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code, "base_code": STOCK.base_code,
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{STOCK.code}") f"{PREFIX}/{STOCK.code}")
response = self.client.get(f"{PREFIX}/{CASH.code}/edit") response = self.__client.get(f"{PREFIX}/{CASH.code}/edit")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{CASH.code}/update", response = self.__client.post(f"{PREFIX}/{CASH.code}/update",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code, "base_code": CASH.base_code,
"title": f"{CASH.title}-2"}) "title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
response = self.client.post(f"{PREFIX}/{BANK.code}/delete", response = self.__client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX) self.assertEqual(response.headers["Location"], PREFIX)
response = self.client.get(f"{PREFIX}/bases/{CASH.base_code}") response = self.__client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
with self.app.app_context(): with self.__app.app_context():
cash_id: int = Account.find_by_code(CASH.code).id cash_id: int = Account.find_by_code(CASH.code).id
response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}", response = self.__client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI, "next": self.__encoded_next_uri,
f"{cash_id}-no": "5"}) f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
@ -260,96 +269,97 @@ class AccountTestCase(unittest.TestCase):
detail_uri: str = f"{PREFIX}/{STOCK.code}" detail_uri: str = f"{PREFIX}/{STOCK.code}"
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, BANK.code}) {CASH.code, BANK.code})
# Missing CSRF token # Missing CSRF token
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"base_code": STOCK.base_code, data={"base_code": STOCK.base_code,
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
# CSRF token mismatch # CSRF token mismatch
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2", data={"csrf_token":
"base_code": STOCK.base_code, f"{self.__csrf_token}-2",
"title": STOCK.title}) "base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
# Empty base account code # Empty base account code
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": " ", "base_code": " ",
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Non-existing base account # Non-existing base account
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "9999", "base_code": "9999",
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Unavailable base account # Unavailable base account
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "1", "base_code": "1",
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Empty name # Empty name
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code, "base_code": STOCK.base_code,
"title": " "}) "title": " "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# A nominal account that needs offset # A nominal account that needs offset
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "6172", "base_code": "6172",
"title": STOCK.title, "title": STOCK.title,
"is_need_offset": "yes"}) "is_need_offset": "yes"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped # Success, with spaces to be stripped
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": f" {STOCK.base_code} ", "base_code": f" {STOCK.base_code} ",
"title": f" {STOCK.title} "}) "title": f" {STOCK.title} "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
# Success under the same base # Success under the same base
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code, "base_code": STOCK.base_code,
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{STOCK.base_code}-002") f"{PREFIX}/{STOCK.base_code}-002")
# Success under the same base, with order in a mess. # Success under the same base, with order in a mess.
with self.app.app_context(): with self.__app.app_context():
stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002") stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002")
stock_2.no = 66 stock_2.no = 66
db.session.commit() db.session.commit()
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code, "base_code": STOCK.base_code,
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{STOCK.base_code}-003") f"{PREFIX}/{STOCK.base_code}-003")
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, BANK.code, STOCK.code, {CASH.code, BANK.code, STOCK.code,
f"{STOCK.base_code}-002", f"{STOCK.base_code}-002",
@ -372,71 +382,71 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
# Success, with spaces to be stripped # Success, with spaces to be stripped
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": f" {CASH.base_code} ", "base_code": f" {CASH.base_code} ",
"title": f" {CASH.title}-1 "}) "title": f" {CASH.title}-1 "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
account: Account = Account.find_by_code(CASH.code) account: Account = Account.find_by_code(CASH.code)
self.assertEqual(account.base_code, CASH.base_code) self.assertEqual(account.base_code, CASH.base_code)
self.assertEqual(account.title_l10n, f"{CASH.title}-1") self.assertEqual(account.title_l10n, f"{CASH.title}-1")
# Empty base account code # Empty base account code
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": " ", "base_code": " ",
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing base account # Non-existing base account
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "9999", "base_code": "9999",
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Unavailable base account # Unavailable base account
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "1", "base_code": "1",
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Empty name # Empty name
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code, "base_code": STOCK.base_code,
"title": " "}) "title": " "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# A nominal account that needs offset # A nominal account that needs offset
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "6172", "base_code": "6172",
"title": STOCK.title, "title": STOCK.title,
"is_need_offset": "yes"}) "is_need_offset": "yes"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account # Change the base account
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code, "base_code": STOCK.base_code,
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri) self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri) response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri) response = self.__client.get(detail_c_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None: def test_update_not_modified(self) -> None:
@ -450,14 +460,14 @@ class AccountTestCase(unittest.TestCase):
account: Account account: Account
response: httpx.Response response: httpx.Response
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": f" {CASH.base_code} ", "base_code": f" {CASH.base_code} ",
"title": f" {CASH.title} "}) "title": f" {CASH.title} "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account) self.assertIsNotNone(account)
account.created_at \ account.created_at \
@ -465,14 +475,14 @@ class AccountTestCase(unittest.TestCase):
account.updated_at = account.created_at account.updated_at = account.created_at
db.session.commit() db.session.commit()
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code, "base_code": CASH.base_code,
"title": STOCK.title}) "title": STOCK.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account) self.assertIsNotNone(account)
self.assertLess(account.created_at, self.assertLess(account.created_at,
@ -485,13 +495,14 @@ class AccountTestCase(unittest.TestCase):
""" """
from accounting.models import Account from accounting.models import Account
editor_username, admin_username = "editor", "admin" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username) client: httpx.Client = get_client(self.__app, admin_username)
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}/{CASH.code}" detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update" update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account account: Account
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertEqual(account.created_by.username, editor_username) self.assertEqual(account.created_by.username, editor_username)
self.assertEqual(account.updated_by.username, editor_username) self.assertEqual(account.updated_by.username, editor_username)
@ -503,7 +514,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertEqual(account.created_by.username, self.assertEqual(account.created_by.username,
editor_username) editor_username)
@ -521,51 +532,51 @@ class AccountTestCase(unittest.TestCase):
account: Account account: Account
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, CASH.title) self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual(account.l10n, []) self.assertEqual(account.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant") set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code, "base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant"}) "title": f"{CASH.title}-zh_Hant"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, CASH.title) self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual({(x.locale, x.title) for x in account.l10n}, self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")}) {("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en") set_locale(self.__app, self.__client, self.__csrf_token, "en")
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code, "base_code": CASH.base_code,
"title": f"{CASH.title}-2"}) "title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, f"{CASH.title}-2") self.assertEqual(account.title_l10n, f"{CASH.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n}, self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")}) {("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant") set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code, "base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant-2"}) "title": f"{CASH.title}-zh_Hant-2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, f"{CASH.title}-2") self.assertEqual(account.title_l10n, f"{CASH.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n}, self.assertEqual({(x.locale, x.title) for x in account.l10n},
@ -582,53 +593,53 @@ class AccountTestCase(unittest.TestCase):
list_uri: str = PREFIX list_uri: str = PREFIX
response: httpx.Response response: httpx.Response
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": PETTY.base_code, "base_code": PETTY.base_code,
"title": PETTY.title}) "title": PETTY.title})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
add_journal_entry(self.client, add_journal_entry(self.__client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.__csrf_token,
"next": NEXT_URI, "next": self.__encoded_next_uri,
"date": dt.date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": "USD", "currency-1-code": "USD",
"currency-1-credit-1-account_code": BANK.code, "currency-1-credit-1-account_code": BANK.code,
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, PETTY.code, BANK.code}) {CASH.code, PETTY.code, BANK.code})
# Cannot delete the cash account # Cannot delete the cash account
response = self.client.post(f"{PREFIX}/{CASH.code}/delete", response = self.__client.post(f"{PREFIX}/{CASH.code}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
# Cannot delete the account that is in use # Cannot delete the account that is in use
response = self.client.post(f"{PREFIX}/{BANK.code}/delete", response = self.__client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}")
# Success # Success
response = self.client.get(detail_uri) response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri, response = self.__client.post(delete_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, BANK.code}) {CASH.code, BANK.code})
response = self.client.get(detail_uri) response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri, response = self.__client.post(delete_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def test_change_base_code(self) -> None: def test_change_base_code(self) -> None:
@ -640,15 +651,15 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
for i in range(2, 6): for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "1111", "base_code": "1111",
"title": "Title"}) "title": "Title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}") f"{PREFIX}/1111-00{i}")
with self.app.app_context(): with self.__app.app_context():
account_1: Account = Account.find_by_code("1111-001") account_1: Account = Account.find_by_code("1111-001")
id_1: int = account_1.id id_1: int = account_1.id
account_2: Account = Account.find_by_code("1111-002") account_2: Account = Account.find_by_code("1111-002")
@ -668,14 +679,14 @@ class AccountTestCase(unittest.TestCase):
account_5.no = 6 account_5.no = 6
db.session.commit() db.session.commit()
response = self.client.post(f"{PREFIX}/1111-005/update", response = self.__client.post(f"{PREFIX}/1111-005/update",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "1112", "base_code": "1112",
"title": "Title"}) "title": "Title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003") self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
with self.app.app_context(): with self.__app.app_context():
self.assertEqual(db.session.get(Account, id_1).no, 1) self.assertEqual(db.session.get(Account, id_1).no, 1)
self.assertEqual(db.session.get(Account, id_2).no, 3) self.assertEqual(db.session.get(Account, id_2).no, 3)
self.assertEqual(db.session.get(Account, id_3).no, 2) self.assertEqual(db.session.get(Account, id_3).no, 2)
@ -691,34 +702,34 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
for i in range(2, 6): for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"base_code": "1111", "base_code": "1111",
"title": "Title"}) "title": "Title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}") f"{PREFIX}/1111-00{i}")
# Normal reorder # Normal reorder
with self.app.app_context(): with self.__app.app_context():
id_1: int = Account.find_by_code("1111-001").id id_1: int = Account.find_by_code("1111-001").id
id_2: int = Account.find_by_code("1111-002").id id_2: int = Account.find_by_code("1111-002").id
id_3: int = Account.find_by_code("1111-003").id id_3: int = Account.find_by_code("1111-003").id
id_4: int = Account.find_by_code("1111-004").id id_4: int = Account.find_by_code("1111-004").id
id_5: int = Account.find_by_code("1111-005").id id_5: int = Account.find_by_code("1111-005").id
response = self.client.post(f"{PREFIX}/bases/1111", response = self.__client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI, "next": self.__encoded_next_uri,
f"{id_1}-no": "4", f"{id_1}-no": "4",
f"{id_2}-no": "1", f"{id_2}-no": "1",
f"{id_3}-no": "5", f"{id_3}-no": "5",
f"{id_4}-no": "2", f"{id_4}-no": "2",
f"{id_5}-no": "3"}) f"{id_5}-no": "3"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.__app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-004") self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
self.assertEqual(db.session.get(Account, id_2).code, "1111-001") self.assertEqual(db.session.get(Account, id_2).code, "1111-001")
self.assertEqual(db.session.get(Account, id_3).code, "1111-005") self.assertEqual(db.session.get(Account, id_3).code, "1111-005")
@ -726,7 +737,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(db.session.get(Account, id_5).code, "1111-003") self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
# Malformed orders # Malformed orders
with self.app.app_context(): with self.__app.app_context():
db.session.get(Account, id_1).no = 3 db.session.get(Account, id_1).no = 3
db.session.get(Account, id_2).no = 4 db.session.get(Account, id_2).no = 4
db.session.get(Account, id_3).no = 6 db.session.get(Account, id_3).no = 6
@ -734,16 +745,16 @@ class AccountTestCase(unittest.TestCase):
db.session.get(Account, id_5).no = 9 db.session.get(Account, id_5).no = 9
db.session.commit() db.session.commit()
response = self.client.post(f"{PREFIX}/bases/1111", response = self.__client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI, "next": self.__encoded_next_uri,
f"{id_2}-no": "3a", f"{id_2}-no": "3a",
f"{id_3}-no": "5", f"{id_3}-no": "5",
f"{id_4}-no": "2"}) f"{id_4}-no": "2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.__app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-003") self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
self.assertEqual(db.session.get(Account, id_2).code, "1111-004") self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
self.assertEqual(db.session.get(Account, id_3).code, "1111-002") self.assertEqual(db.session.get(Account, id_3).code, "1111-002")

View File

@ -39,14 +39,15 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") client: httpx.Client = get_client(self.__app, "nobody")
response: httpx.Response response: httpx.Response
response = client.get(LIST_URI) response = client.get(LIST_URI)
@ -60,7 +61,7 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") client: httpx.Client = get_client(self.__app, "viewer")
response: httpx.Response response: httpx.Response
response = client.get(LIST_URI) response = client.get(LIST_URI)
@ -74,7 +75,7 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "editor") client: httpx.Client = get_client(self.__app, "editor")
response: httpx.Response response: httpx.Response
response = client.get(LIST_URI) response = client.get(LIST_URI)

View File

@ -18,6 +18,8 @@
""" """
import csv import csv
import datetime as dt
import re
import unittest import unittest
from typing import Any from typing import Any
@ -40,11 +42,17 @@ class ConsoleCommandTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): def test_init_db(self) -> None:
# Drop every accounting table, to see if accounting-init recreates """Tests the "accounting-init-db" console command.
# them correctly.
:return: None.
"""
with self.__app.app_context():
# Drop every accounting table, to see if accounting-init-db
# recreates them correctly.
tables: list[sa.Table] \ tables: list[sa.Table] \
= [db.metadata.tables[x] for x in db.metadata.tables = [db.metadata.tables[x] for x in db.metadata.tables
if x.startswith("accounting_")] if x.startswith("accounting_")]
@ -56,13 +64,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
if x.startswith("accounting_")}), if x.startswith("accounting_")}),
0) 0)
def test_init(self) -> None: runner: FlaskCliRunner = self.__app.test_cli_runner()
"""Tests the "accounting-init" console command. with self.__app.app_context():
:return: None.
"""
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke( result: Result = runner.invoke(
args=["accounting-init-db", "-u", "editor"]) args=["accounting-init-db", "-u", "editor"])
self.assertEqual(result.exit_code, 0, self.assertEqual(result.exit_code, 0,
@ -80,20 +83,23 @@ class ConsoleCommandTestCase(unittest.TestCase):
from accounting.models import BaseAccount from accounting.models import BaseAccount
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
data: dict[dict[str, Any]] \ rows: list[dict[str, str]] = list(csv.DictReader(fp))
= {x["code"]: {"code": x["code"], data: dict[dict[str, Any]] \
"title": x["title"], = {x["code"]: {"code": x["code"],
"l10n": {y[5:]: x[y] "title": x["title"],
for y in x if y.startswith("l10n-")}} "l10n": {y[5:]: x[y]
for x in csv.DictReader(fp)} for y in x if y.startswith("l10n-")}}
for x in rows}
with self.app.app_context(): with self.__app.app_context():
accounts: list[BaseAccount] = BaseAccount.query.all() accounts: list[BaseAccount] = BaseAccount.query.all()
self.assertEqual(len(accounts), len(data)) self.assertEqual(len(accounts), len(data))
for account in accounts: for account in accounts:
self.assertIn(account.code, data) self.assertIn(account.code, data)
self.assertEqual(account.title_l10n, data[account.code]["title"]) self.assertEqual(account.title_l10n.lower(),
data[account.code]["title"].lower())
self.__test_title_case(account.title_l10n)
l10n: dict[str, str] = {x.locale: x.title for x in account.l10n} l10n: dict[str, str] = {x.locale: x.title for x in account.l10n}
self.assertEqual(len(l10n), len(data[account.code]["l10n"])) self.assertEqual(len(l10n), len(data[account.code]["l10n"]))
for locale in l10n: for locale in l10n:
@ -101,6 +107,23 @@ class ConsoleCommandTestCase(unittest.TestCase):
self.assertEqual(l10n[locale], self.assertEqual(l10n[locale],
data[account.code]["l10n"][locale]) data[account.code]["l10n"][locale])
def __test_title_case(self, s: str) -> None:
"""Tests the case of a base account title.
:param s: The base account title.
:return: None.
"""
from accounting.utils.title_case import MINOR_WORDS
self.assertTrue(s[0].isupper(), s)
for word in re.findall(r"\w+", s):
if len(word) >= 4:
self.assertTrue(word.istitle(), s)
elif word in MINOR_WORDS:
self.assertTrue(word.islower(), s)
else:
self.assertTrue(word.istitle(), s)
def __test_account_data(self) -> None: def __test_account_data(self) -> None:
"""Tests the account data. """Tests the account data.
@ -108,7 +131,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
""" """
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
with self.app.app_context(): with self.__app.app_context():
bases: list[BaseAccount] = BaseAccount.query\ bases: list[BaseAccount] = BaseAccount.query\
.filter(sa.func.char_length(BaseAccount.code) == 4).all() .filter(sa.func.char_length(BaseAccount.code) == 4).all()
accounts: list[Account] = Account.query.all() accounts: list[Account] = Account.query.all()
@ -142,7 +165,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
for y in x if y.startswith("l10n-")}} for y in x if y.startswith("l10n-")}}
for x in csv.DictReader(fp)} for x in csv.DictReader(fp)}
with self.app.app_context(): with self.__app.app_context():
currencies: list[Currency] = Currency.query.all() currencies: list[Currency] = Currency.query.all()
self.assertEqual(len(currencies), len(data)) self.assertEqual(len(currencies), len(data))
@ -155,3 +178,69 @@ class ConsoleCommandTestCase(unittest.TestCase):
self.assertIn(locale, data[currency.code]["l10n"]) self.assertIn(locale, data[currency.code]["l10n"])
self.assertEqual(l10n[locale], self.assertEqual(l10n[locale],
data[currency.code]["l10n"][locale]) data[currency.code]["l10n"][locale])
def test_titleize(self) -> None:
"""Tests the "accounting-titleize" console command.
:return: None.
"""
from accounting.models import BaseAccount, Account
from accounting.utils.random_id import new_id
from accounting.utils.user import get_user_pk
runner: FlaskCliRunner = self.__app.test_cli_runner()
with self.__app.app_context():
# Resets the accounts.
tables: list[sa.Table] \
= [db.metadata.tables[x] for x in db.metadata.tables
if x.startswith("accounting_")]
for table in tables:
db.session.execute(DropTable(table))
db.session.commit()
inspector: sa.Inspector = sa.inspect(db.session.connection())
self.assertEqual(len({x for x in inspector.get_table_names()
if x.startswith("accounting_")}),
0)
result: Result = runner.invoke(
args=["accounting-init-db", "-u", "editor"])
self.assertEqual(result.exit_code, 0,
result.output + str(result.exception))
# Turns the titles into lowercase.
for base in BaseAccount.query:
base.title_l10n = base.title_l10n.lower()
for account in Account.query:
account.title_l10n = account.title_l10n.lower()
account.created_at \
= account.created_at - dt.timedelta(seconds=5)
account.updated_at = account.created_at
# Adds a custom account.
custom_title = "MBK Bank"
creator_pk: int = get_user_pk("editor")
new_account: Account = Account(
id=new_id(Account),
base_code="1112",
no="2",
title_l10n=custom_title,
is_need_offset=False,
created_by_id=creator_pk,
updated_by_id=creator_pk)
db.session.add(new_account)
db.session.commit()
result: Result = runner.invoke(
args=["accounting-titleize", "-u", "editor"])
self.assertEqual(result.exit_code, 0,
result.output + str(result.exception))
for base in BaseAccount.query:
self.__test_title_case(base.title_l10n)
for account in Account.query:
if account.id != new_account.id:
self.__test_title_case(account.title_l10n)
self.assertNotEqual(account.created_at, account.updated_at)
else:
self.assertEqual(account.title_l10n, custom_title)
db.session.delete(new_account)
db.session.commit()

View File

@ -23,9 +23,10 @@ import unittest
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \ from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
add_journal_entry set_locale, add_journal_entry
class CurrencyData: class CurrencyData:
@ -64,28 +65,32 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): with self.__app.app_context():
from accounting.models import Currency, CurrencyL10n from accounting.models import Currency, CurrencyL10n
CurrencyL10n.query.delete() CurrencyL10n.query.delete()
Currency.query.delete() Currency.query.delete()
db.session.commit() db.session.commit()
self.client, self.csrf_token = get_client(self.app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
response: httpx.Response response: httpx.Response
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": USD.code, "code": USD.code,
"name": USD.name}) "name": USD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": EUR.code, "code": EUR.code,
"name": EUR.name}) "name": EUR.name})
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}")
@ -94,7 +99,8 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") client: httpx.Client = get_client(self.__app, "nobody")
csrf_token: str = get_csrf_token(client)
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -130,7 +136,8 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") client: httpx.Client = get_client(self.__app, "viewer")
csrf_token: str = get_csrf_token(client)
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -168,34 +175,34 @@ class CurrencyTestCase(unittest.TestCase):
""" """
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX) response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{USD.code}") response = self.__client.get(f"{PREFIX}/{USD.code}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create") response = self.__client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": TWD.code, "code": TWD.code,
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{TWD.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{TWD.code}")
response = self.client.get(f"{PREFIX}/{USD.code}/edit") response = self.__client.get(f"{PREFIX}/{USD.code}/edit")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{USD.code}/update", response = self.__client.post(f"{PREFIX}/{USD.code}/update",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": JPY.code, "code": JPY.code,
"name": JPY.name}) "name": JPY.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{JPY.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{JPY.code}")
response = self.client.post(f"{PREFIX}/{EUR.code}/delete", response = self.__client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX) self.assertEqual(response.headers["Location"], PREFIX)
@ -210,72 +217,73 @@ class CurrencyTestCase(unittest.TestCase):
detail_uri: str = f"{PREFIX}/{TWD.code}" detail_uri: str = f"{PREFIX}/{TWD.code}"
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code}) {USD.code, EUR.code})
# Missing CSRF token # Missing CSRF token
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"code": TWD.code, data={"code": TWD.code,
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
# CSRF token mismatch # CSRF token mismatch
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2", data={"csrf_token":
"code": TWD.code, f"{self.__csrf_token}-2",
"name": TWD.name}) "code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
# Empty code # Empty code
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": " ", "code": " ",
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Blocked code, with spaces to be stripped # Blocked code, with spaces to be stripped
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": " create ", "code": " create ",
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Bad code # Bad code
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": " zzc ", "code": " zzc ",
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Empty name # Empty name
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": TWD.code, "code": TWD.code,
"name": " "}) "name": " "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped # Success, with spaces to be stripped
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": f" {TWD.code} ", "code": f" {TWD.code} ",
"name": f" {TWD.name} "}) "name": f" {TWD.name} "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
# Duplicated code # Duplicated code
response = self.client.post(store_uri, response = self.__client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": TWD.code, "code": TWD.code,
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code, TWD.code}) {USD.code, EUR.code, TWD.code})
@ -296,70 +304,70 @@ class CurrencyTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
# Success, with spaces to be stripped # Success, with spaces to be stripped
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": f" {USD.code} ", "code": f" {USD.code} ",
"name": f" {USD.name}-1 "}) "name": f" {USD.name}-1 "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
currency: Currency = db.session.get(Currency, USD.code) currency: Currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.code, USD.code) self.assertEqual(currency.code, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-1") self.assertEqual(currency.name_l10n, f"{USD.name}-1")
# Empty code # Empty code
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": " ", "code": " ",
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Blocked code, with spaces to be stripped # Blocked code, with spaces to be stripped
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": " create ", "code": " create ",
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Bad code # Bad code
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": "abc/def", "code": "abc/def",
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Empty name # Empty name
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": TWD.code, "code": TWD.code,
"name": " "}) "name": " "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Duplicated code # Duplicated code
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": EUR.code, "code": EUR.code,
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Change code # Change code
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": TWD.code, "code": TWD.code,
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri) self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri) response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri) response = self.__client.get(detail_c_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None: def test_update_not_modified(self) -> None:
@ -373,14 +381,14 @@ class CurrencyTestCase(unittest.TestCase):
currency: Currency | None currency: Currency | None
response: httpx.Response response: httpx.Response
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": f" {USD.code} ", "code": f" {USD.code} ",
"name": f" {USD.name} "}) "name": f" {USD.name} "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency) self.assertIsNotNone(currency)
currency.created_at \ currency.created_at \
@ -388,14 +396,14 @@ class CurrencyTestCase(unittest.TestCase):
currency.updated_at = currency.created_at currency.updated_at = currency.created_at
db.session.commit() db.session.commit()
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": USD.code, "code": USD.code,
"name": TWD.name}) "name": TWD.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency) self.assertIsNotNone(currency)
self.assertLess(currency.created_at, self.assertLess(currency.created_at,
@ -408,13 +416,14 @@ class CurrencyTestCase(unittest.TestCase):
""" """
from accounting.models import Currency from accounting.models import Currency
editor_username, admin_username = "editor", "admin" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username) client: httpx.Client = get_client(self.__app, admin_username)
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}/{USD.code}" detail_uri: str = f"{PREFIX}/{USD.code}"
update_uri: str = f"{PREFIX}/{USD.code}/update" update_uri: str = f"{PREFIX}/{USD.code}/update"
currency: Currency currency: Currency
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username) self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor_username) self.assertEqual(currency.updated_by.username, editor_username)
@ -426,7 +435,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username) self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, admin_username) self.assertEqual(currency.updated_by.username, admin_username)
@ -438,14 +447,14 @@ class CurrencyTestCase(unittest.TestCase):
""" """
response: httpx.Response response: httpx.Response
response = self.client.get( response = self.__client.get(
f"/accounting/api/currencies/exists-code?q={USD.code}") f"/accounting/api/currencies/exists-code?q={USD.code}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
self.assertEqual(set(data.keys()), {"exists"}) self.assertEqual(set(data.keys()), {"exists"})
self.assertTrue(data["exists"]) self.assertTrue(data["exists"])
response = self.client.get( response = self.__client.get(
f"/accounting/api/currencies/exists-code?q={USD.code}-1") f"/accounting/api/currencies/exists-code?q={USD.code}-1")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
data = response.json() data = response.json()
@ -463,51 +472,51 @@ class CurrencyTestCase(unittest.TestCase):
currency: Currency currency: Currency
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, USD.name) self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual(currency.l10n, []) self.assertEqual(currency.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant") set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": USD.code, "code": USD.code,
"name": f"{USD.name}-zh_Hant"}) "name": f"{USD.name}-zh_Hant"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, USD.name) self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual({(x.locale, x.name) for x in currency.l10n}, self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")}) {("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en") set_locale(self.__app, self.__client, self.__csrf_token, "en")
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": USD.code, "code": USD.code,
"name": f"{USD.name}-2"}) "name": f"{USD.name}-2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-2") self.assertEqual(currency.name_l10n, f"{USD.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n}, self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")}) {("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant") set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri, response = self.__client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": USD.code, "code": USD.code,
"name": f"{USD.name}-zh_Hant-2"}) "name": f"{USD.name}-zh_Hant-2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-2") self.assertEqual(currency.name_l10n, f"{USD.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n}, self.assertEqual({(x.locale, x.name) for x in currency.l10n},
@ -521,54 +530,56 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{JPY.code}" detail_uri: str = f"{PREFIX}/{JPY.code}"
delete_uri: str = f"{PREFIX}/{JPY.code}/delete" delete_uri: str = f"{PREFIX}/{JPY.code}/delete"
with self.__app.app_context():
encoded_next_uri: str = encode_next(NEXT_URI)
list_uri: str = PREFIX list_uri: str = PREFIX
response: httpx.Response response: httpx.Response
response = self.client.post(f"{PREFIX}/store", response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"code": JPY.code, "code": JPY.code,
"name": JPY.name}) "name": JPY.name})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
add_journal_entry(self.client, add_journal_entry(self.__client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.__csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": dt.date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": EUR.code, "currency-1-code": EUR.code,
"currency-1-credit-1-account_code": "1111-001", "currency-1-credit-1-account_code": "1111-001",
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code, JPY.code}) {USD.code, EUR.code, JPY.code})
# Cannot delete the default currency # Cannot delete the default currency
response = self.client.post(f"{PREFIX}/{USD.code}/delete", response = self.__client.post(f"{PREFIX}/{USD.code}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}") self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
# Cannot delete the account that is in use # Cannot delete the account that is in use
response = self.client.post(f"{PREFIX}/{EUR.code}/delete", response = self.__client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
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}")
# Success # Success
response = self.client.get(detail_uri) response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri, response = self.__client.post(delete_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code}) {USD.code, EUR.code})
response = self.client.get(detail_uri) response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri, response = self.__client.post(delete_uri,
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)

View File

@ -20,10 +20,12 @@
import datetime as dt import datetime as dt
import unittest import unittest
import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \ from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
add_journal_entry get_csrf_token, add_journal_entry
class DescriptionEditorTestCase(unittest.TestCase): class DescriptionEditorTestCase(unittest.TestCase):
@ -35,14 +37,20 @@ class DescriptionEditorTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_description_editor(self) -> None: def test_description_editor(self) -> None:
"""Test the description editor. """Test the description editor.
@ -51,9 +59,9 @@ class DescriptionEditorTestCase(unittest.TestCase):
""" """
from accounting.journal_entry.utils.description_editor import \ from accounting.journal_entry.utils.description_editor import \
DescriptionEditor DescriptionEditor
for form in get_form_data(self.csrf_token): for form in get_form_data(self.__csrf_token, self.__encoded_next_uri):
add_journal_entry(self.client, form) add_journal_entry(self.__client, form)
with self.app.app_context(): with self.__app.app_context():
editor: DescriptionEditor = DescriptionEditor() editor: DescriptionEditor = DescriptionEditor()
# Debit-General # Debit-General
@ -143,22 +151,24 @@ class DescriptionEditorTestCase(unittest.TestCase):
Accounts.PREPAID) Accounts.PREPAID)
def get_form_data(csrf_token: str) -> list[dict[str, str]]: def get_form_data(csrf_token: str, encoded_next_uri: str) \
-> list[dict[str, str]]:
"""Returns the form data for multiple journal entry forms. """Returns the form data for multiple journal entry forms.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:return: A list of the form data. :return: A list of the form data.
""" """
journal_entry_date: str = dt.date.today().isoformat() journal_entry_date: str = dt.date.today().isoformat()
return [{"csrf_token": csrf_token, return [{"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE, "currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-description": " Salary ", "currency-0-credit-0-description": " Salary ",
"currency-0-credit-0-amount": "2500"}, "currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
@ -180,7 +190,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-2-description": " Dinner—Hamburger ", "currency-0-credit-2-description": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"}, "currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
@ -196,7 +206,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-1-description": " Dinner—Steak ", "currency-0-credit-1-description": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"}, "currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "currency-0-debit-0-account_code": Accounts.MEAL,
@ -212,14 +222,14 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-1-description": " Lunch—Noodles ", "currency-0-credit-1-description": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"}, "currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-description": " Airplane—Lake City↔Hill Town", "currency-0-debit-0-description": " Airplane—Lake City↔Hill Town",
"currency-0-debit-0-amount": "800"}, "currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "currency-0-debit-0-account_code": Accounts.TRAVEL,
@ -247,7 +257,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-3-description": " Train—Red—Mall→Museum ", "currency-0-credit-3-description": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"}, "currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "currency-0-debit-0-account_code": Accounts.TRAVEL,
@ -293,7 +303,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-6-description": " Bike—Theatre→Home ", "currency-0-credit-6-description": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"}, "currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PETTY_CASH, "currency-0-debit-0-account_code": Accounts.PETTY_CASH,

File diff suppressed because it is too large Load Diff

View File

@ -25,11 +25,12 @@ from decimal import Decimal
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from test_site.lib import JournalEntryLineItemData, JournalEntryCurrencyData, \ from test_site.lib import JournalEntryLineItemData, JournalEntryCurrencyData, \
JournalEntryData, BaseTestData JournalEntryData, BaseTestData
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \ from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
match_journal_entry_detail get_csrf_token, match_journal_entry_detail
PREFIX: str = "/accounting/journal-entries" PREFIX: str = "/accounting/journal-entries"
"""The URL prefix for the journal entry management.""" """The URL prefix for the journal entry management."""
@ -44,16 +45,23 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
self.data: OffsetTestData = OffsetTestData(self.app, "editor") """The user client."""
self.data.populate() self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
self.__data: OffsetTestData = OffsetTestData(self.__app, "editor")
"""The offset test data."""
self.__data.populate()
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.
@ -61,118 +69,129 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, JournalEntry from accounting.models import Account, JournalEntry
create_uri: str = f"{PREFIX}/create/receipt?next=%2F_next" create_uri: str = (f"{PREFIX}/create/receipt?"
f"next={self.__encoded_next_uri}")
store_uri: str = f"{PREFIX}/store/receipt" store_uri: str = f"{PREFIX}/store/receipt"
form: dict[str, str] form: dict[str, str]
old_amount: Decimal old_amount: Decimal
response: httpx.Response response: httpx.Response
journal_entry_data: JournalEntryData = JournalEntryData( journal_entry_data: JournalEntryData = JournalEntryData(
self.data.l_r_or3d.journal_entry.days, [JournalEntryCurrencyData( self.__data.l_r_or3d.journal_entry.days, [JournalEntryCurrencyData(
"USD", "USD",
[], [],
[JournalEntryLineItemData( [JournalEntryLineItemData(
Accounts.RECEIVABLE, Accounts.RECEIVABLE,
self.data.l_r_or1d.description, "300", self.__data.l_r_or1d.description, "300",
original_line_item=self.data.l_r_or1d), original_line_item=self.__data.l_r_or1d),
JournalEntryLineItemData( JournalEntryLineItemData(
Accounts.RECEIVABLE, Accounts.RECEIVABLE,
self.data.l_r_or1d.description, "100", self.__data.l_r_or1d.description, "100",
original_line_item=self.data.l_r_or1d), original_line_item=self.__data.l_r_or1d),
JournalEntryLineItemData( JournalEntryLineItemData(
Accounts.RECEIVABLE, Accounts.RECEIVABLE,
self.data.l_r_or3d.description, "100", self.__data.l_r_or3d.description, "100",
original_line_item=self.data.l_r_or3d)])]) original_line_item=self.__data.l_r_or3d)])])
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] = "9999" form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id) = str(self.__data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account form["currency-1-credit-1-account_code"] = self.__data.l_p_or1c.account
form["currency-1-credit-1-amount"] = "100" form["currency-1-credit-1-amount"] = "100"
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The original line item does not need offset # The original line item does not need offset
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.__client.post(
store_uri, store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI)) data=journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id) = str(self.__data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account form["currency-1-credit-1-account_code"] = self.__data.l_p_of1d.account
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # Not the same account
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount = str(journal_entry_data.currencies[0].credit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-3-amount"] \ form["currency-1-credit-3-amount"] \
= str(journal_entry_data.currencies[0].credit[2].amount = str(journal_entry_data.currencies[0].credit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not before the original line items # Not before the original line items
old_days = journal_entry_data.days old_days = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
response = self.client.post(store_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
response = self.client.post(store_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"]) = match_journal_entry_detail(response.headers["Location"])
with self.app.app_context(): with self.__app.app_context():
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
for offset in journal_entry.currencies[0].credit: for offset in journal_entry.currencies[0].credit:
self.assertIsNotNone(offset.original_line_item_id) self.assertIsNotNone(offset.original_line_item_id)
@ -183,113 +202,125 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
journal_entry_data: JournalEntryData = self.data.j_r_of2 journal_entry_data: JournalEntryData = self.__data.j_r_of2
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next" edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.__encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
journal_entry_data.days = self.data.j_r_or2.days journal_entry_data.days = self.__data.j_r_or2.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("600") journal_entry_data.currencies[0].debit[0].amount = Decimal("600")
journal_entry_data.currencies[0].credit[0].amount = Decimal("600") journal_entry_data.currencies[0].credit[0].amount = Decimal("600")
journal_entry_data.currencies[0].debit[2].amount = Decimal("600") journal_entry_data.currencies[0].debit[2].amount = Decimal("600")
journal_entry_data.currencies[0].credit[2].amount = Decimal("600") journal_entry_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] = "9999" form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id) = str(self.__data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account form["currency-1-credit-1-account_code"] = self.__data.l_p_or1c.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100" form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The original line item does not need offset # The original line item does not need offset
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.__client.post(
update_uri, update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI)) data=journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id) = str(self.__data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account form["currency-1-credit-1-account_code"] = self.__data.l_p_of1d.account
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount = str(journal_entry_data.currencies[0].credit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
form["currency-1-credit-3-amount"] \ form["currency-1-credit-3-amount"] \
= str(journal_entry_data.currencies[0].credit[2].amount = str(journal_entry_data.currencies[0].credit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_data.id}?next=%2F_next") f"{PREFIX}/{journal_entry_data.id}?"
f"next={self.__encoded_next_uri}")
def test_edit_receivable_original_line_item(self) -> None: def test_edit_receivable_original_line_item(self) -> None:
"""Tests to edit the receivable original line item. """Tests to edit the receivable original line item.
@ -297,87 +328,96 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.j_r_or1 journal_entry_data: JournalEntryData = self.__data.j_r_or1
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next" edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.__encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
journal_entry_data.days = self.data.j_r_of1.days journal_entry_data.days = self.__data.j_r_of1.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("800") journal_entry_data.currencies[0].debit[0].amount = Decimal("800")
journal_entry_data.currencies[0].credit[0].amount = Decimal("800") journal_entry_data.currencies[0].credit[0].amount = Decimal("800")
journal_entry_data.currencies[0].debit[1].amount = Decimal("3.4") journal_entry_data.currencies[0].debit[1].amount = Decimal("3.4")
journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4") journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4")
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount = str(journal_entry_data.currencies[0].credit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-2-amount"] \ form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount = str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
form["currency-1-credit-2-amount"] \ form["currency-1-credit-2-amount"] \
= str(journal_entry_data.currencies[0].credit[1].amount = str(journal_entry_data.currencies[0].credit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset items # Not after the offset items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1 journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Not deleting matched original line items # Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
del form["currency-1-debit-1-id"] del form["currency-1-debit-1-id"]
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_data.id}?next=%2F_next") f"{PREFIX}/{journal_entry_data.id}?"
f"next={self.__encoded_next_uri}")
# The original line item is always before the offset item, even when # The original line item is always before the offset item, even when
# they happen in the same day. # they happen in the same day.
with self.app.app_context(): with self.__app.app_context():
journal_entry_or: JournalEntry | None = db.session.get( journal_entry_or: JournalEntry | None = db.session.get(
JournalEntry, journal_entry_data.id) JournalEntry, journal_entry_data.id)
self.assertIsNotNone(journal_entry_or) self.assertIsNotNone(journal_entry_or)
journal_entry_of: JournalEntry | None = db.session.get( journal_entry_of: JournalEntry | None = db.session.get(
JournalEntry, self.data.j_r_of1.id) JournalEntry, self.__data.j_r_of1.id)
self.assertIsNotNone(journal_entry_of) self.assertIsNotNone(journal_entry_of)
self.assertEqual(journal_entry_or.date, journal_entry_of.date) self.assertEqual(journal_entry_or.date, journal_entry_of.date)
self.assertLess(journal_entry_or.no, journal_entry_of.no) self.assertLess(journal_entry_or.no, journal_entry_of.no)
@ -388,117 +428,128 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, JournalEntry from accounting.models import Account, JournalEntry
create_uri: str = f"{PREFIX}/create/disbursement?next=%2F_next" create_uri: str = (f"{PREFIX}/create/disbursement?"
f"next={self.__encoded_next_uri}")
store_uri: str = f"{PREFIX}/store/disbursement" store_uri: str = f"{PREFIX}/store/disbursement"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
journal_entry_data: JournalEntryData = JournalEntryData( journal_entry_data: JournalEntryData = JournalEntryData(
self.data.l_p_or3c.journal_entry.days, [JournalEntryCurrencyData( self.__data.l_p_or3c.journal_entry.days, [JournalEntryCurrencyData(
"USD", "USD",
[JournalEntryLineItemData( [JournalEntryLineItemData(
Accounts.PAYABLE, Accounts.PAYABLE,
self.data.l_p_or1c.description, "500", self.__data.l_p_or1c.description, "500",
original_line_item=self.data.l_p_or1c), original_line_item=self.__data.l_p_or1c),
JournalEntryLineItemData( JournalEntryLineItemData(
Accounts.PAYABLE, Accounts.PAYABLE,
self.data.l_p_or1c.description, "300", self.__data.l_p_or1c.description, "300",
original_line_item=self.data.l_p_or1c), original_line_item=self.__data.l_p_or1c),
JournalEntryLineItemData( JournalEntryLineItemData(
Accounts.PAYABLE, Accounts.PAYABLE,
self.data.l_p_or3c.description, "120", self.__data.l_p_or3c.description, "120",
original_line_item=self.data.l_p_or3c)], original_line_item=self.__data.l_p_or3c)],
[])]) [])])
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] = "9999" form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id) = str(self.__data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account form["currency-1-debit-1-account_code"] = self.__data.l_r_or1d.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The original line item does not need offset # The original line item does not need offset
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.__client.post(
store_uri, store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI)) data=journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id) = str(self.__data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account form["currency-1-debit-1-account_code"] = self.__data.l_r_of1c.account
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # Not the same account
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(store_uri, data=form) response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
response = self.client.post(store_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI) form = journal_entry_data.new_form(self.__csrf_token,
response = self.client.post(store_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"]) = match_journal_entry_detail(response.headers["Location"])
with self.app.app_context(): with self.__app.app_context():
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
for offset in journal_entry.currencies[0].debit: for offset in journal_entry.currencies[0].debit:
self.assertIsNotNone(offset.original_line_item_id) self.assertIsNotNone(offset.original_line_item_id)
@ -509,114 +560,125 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, JournalEntry from accounting.models import Account, JournalEntry
journal_entry_data: JournalEntryData = self.data.j_p_of2 journal_entry_data: JournalEntryData = self.__data.j_p_of2
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next" edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.__encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
journal_entry_data.days = self.data.j_p_or2.days journal_entry_data.days = self.__data.j_p_or2.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("1100") journal_entry_data.currencies[0].debit[0].amount = Decimal("1100")
journal_entry_data.currencies[0].credit[0].amount = Decimal("1100") journal_entry_data.currencies[0].credit[0].amount = Decimal("1100")
journal_entry_data.currencies[0].debit[2].amount = Decimal("900") journal_entry_data.currencies[0].debit[2].amount = Decimal("900")
journal_entry_data.currencies[0].credit[2].amount = Decimal("900") journal_entry_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original line item ID # Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] = "9999" form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit # The same debit or credit
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id) = str(self.__data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account form["currency-1-debit-1-account_code"] = self.__data.l_r_or1d.account
form["currency-1-debit-1-amount"] = "100" form["currency-1-debit-1-amount"] = "100"
form["currency-1-credit-1-amount"] = "100" form["currency-1-credit-1-amount"] = "100"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The original line item does not need offset # The original line item does not need offset
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = False account.is_need_offset = False
db.session.commit() db.session.commit()
response = self.client.post( response = self.__client.post(
update_uri, update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI)) data=journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
account.is_need_offset = True account.is_need_offset = True
db.session.commit() db.session.commit()
# The original line item is also an offset # The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id) = str(self.__data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account form["currency-1-debit-1-account_code"] = self.__data.l_r_of1c.account
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount = str(journal_entry_data.currencies[0].credit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-3-amount"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
form["currency-1-credit-3-amount"] \ form["currency-1-credit-3-amount"] \
= str(journal_entry_data.currencies[0].credit[2].amount = str(journal_entry_data.currencies[0].credit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
= match_journal_entry_detail(response.headers["Location"]) = match_journal_entry_detail(response.headers["Location"])
with self.app.app_context(): with self.__app.app_context():
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
for offset in journal_entry.currencies[0].debit: for offset in journal_entry.currencies[0].debit:
self.assertIsNotNone(offset.original_line_item_id) self.assertIsNotNone(offset.original_line_item_id)
@ -627,87 +689,96 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.j_p_or1 journal_entry_data: JournalEntryData = self.__data.j_p_or1
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next" edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.__encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
journal_entry_data.days = self.data.j_p_of1.days journal_entry_data.days = self.__data.j_p_of1.days
journal_entry_data.currencies[0].debit[0].amount = Decimal("1200") journal_entry_data.currencies[0].debit[0].amount = Decimal("1200")
journal_entry_data.currencies[0].credit[0].amount = Decimal("1200") journal_entry_data.currencies[0].credit[0].amount = Decimal("1200")
journal_entry_data.currencies[0].debit[1].amount = Decimal("0.9") journal_entry_data.currencies[0].debit[1].amount = Decimal("0.9")
journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9") journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9")
# Not the same currency # Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-code"] = "EUR" form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-1-amount"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
form["currency-1-credit-1-amount"] \ form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount = str(journal_entry_data.currencies[0].credit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
form["currency-1-debit-2-amount"] \ form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount = str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
form["currency-1-credit-2-amount"] \ form["currency-1-credit-2-amount"] \
= str(journal_entry_data.currencies[0].credit[1].amount = str(journal_entry_data.currencies[0].credit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not after the offset items # Not after the offset items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1 journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Not deleting matched original line items # Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
self.__encoded_next_uri)
del form["currency-1-credit-1-id"] del form["currency-1-credit-1-id"]
response = self.client.post(update_uri, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI) form = journal_entry_data.update_form(self.__csrf_token,
response = self.client.post(update_uri, data=form) self.__encoded_next_uri)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_data.id}?next=%2F_next") f"{PREFIX}/{journal_entry_data.id}?"
f"next={self.__encoded_next_uri}")
# The original line item is always before the offset item, even when # The original line item is always before the offset item, even when
# they happen in the same day # they happen in the same day
with self.app.app_context(): with self.__app.app_context():
journal_entry_or: JournalEntry | None = db.session.get( journal_entry_or: JournalEntry | None = db.session.get(
JournalEntry, journal_entry_data.id) JournalEntry, journal_entry_data.id)
self.assertIsNotNone(journal_entry_or) self.assertIsNotNone(journal_entry_or)
journal_entry_of: JournalEntry | None = db.session.get( journal_entry_of: JournalEntry | None = db.session.get(
JournalEntry, self.data.j_p_of1.id) JournalEntry, self.__data.j_p_of1.id)
self.assertIsNotNone(journal_entry_of) self.assertIsNotNone(journal_entry_of)
self.assertEqual(journal_entry_or.date, journal_entry_of.date) self.assertEqual(journal_entry_or.date, journal_entry_of.date)
self.assertLess(journal_entry_or.no, journal_entry_of.no) self.assertLess(journal_entry_or.no, journal_entry_of.no)
@ -742,18 +813,22 @@ class OffsetTestData(BaseTestData):
50, [JournalEntryCurrencyData( 50, [JournalEntryCurrencyData(
"USD", [self.l_r_or1d, self.l_r_or4d], "USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])]) [self.l_r_or1c, self.l_r_or4c])])
"""The receivable original journal entry #1."""
self.j_r_or2: JournalEntryData = JournalEntryData( self.j_r_or2: JournalEntryData = JournalEntryData(
30, [JournalEntryCurrencyData( 30, [JournalEntryCurrencyData(
"USD", [self.l_r_or2d, self.l_r_or3d], "USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])]) [self.l_r_or2c, self.l_r_or3c])])
"""The receivable original journal entry #2."""
self.j_p_or1: JournalEntryData = JournalEntryData( self.j_p_or1: JournalEntryData = JournalEntryData(
40, [JournalEntryCurrencyData( 40, [JournalEntryCurrencyData(
"USD", [self.l_p_or1d, self.l_p_or4d], "USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])]) [self.l_p_or1c, self.l_p_or4c])])
"""The payable original journal entry #1."""
self.j_p_or2: JournalEntryData = JournalEntryData( self.j_p_or2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData( 20, [JournalEntryCurrencyData(
"USD", [self.l_p_or2d, self.l_p_or3d], "USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])]) [self.l_p_or2c, self.l_p_or3c])])
"""The payable original journal entry #2."""
self._add_journal_entry(self.j_r_or1) self._add_journal_entry(self.j_r_or1)
self._add_journal_entry(self.j_r_or2) self._add_journal_entry(self.j_r_or2)
@ -798,23 +873,29 @@ class OffsetTestData(BaseTestData):
self.j_r_of1: JournalEntryData = JournalEntryData( self.j_r_of1: JournalEntryData = JournalEntryData(
25, [JournalEntryCurrencyData( 25, [JournalEntryCurrencyData(
"USD", [self.l_r_of1d], [self.l_r_of1c])]) "USD", [self.l_r_of1d], [self.l_r_of1c])])
"""The offset journal entry to the receivable #1."""
self.j_r_of2: JournalEntryData = JournalEntryData( self.j_r_of2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData( 20, [JournalEntryCurrencyData(
"USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d], "USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])]) [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
"""The offset journal entry to the receivable #2."""
self.j_r_of3: JournalEntryData = JournalEntryData( self.j_r_of3: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData( 15, [JournalEntryCurrencyData(
"USD", [self.l_r_of5d], [self.l_r_of5c])]) "USD", [self.l_r_of5d], [self.l_r_of5c])])
"""The offset journal entry to the receivable #3."""
self.j_p_of1: JournalEntryData = JournalEntryData( self.j_p_of1: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData( 15, [JournalEntryCurrencyData(
"USD", [self.l_p_of1d], [self.l_p_of1c])]) "USD", [self.l_p_of1d], [self.l_p_of1c])])
"""The offset journal entry to the payable #1."""
self.j_p_of2: JournalEntryData = JournalEntryData( self.j_p_of2: JournalEntryData = JournalEntryData(
10, [JournalEntryCurrencyData( 10, [JournalEntryCurrencyData(
"USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d], "USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])]) [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
"""The offset journal entry to the payable #2."""
self.j_p_of3: JournalEntryData = JournalEntryData( self.j_p_of3: JournalEntryData = JournalEntryData(
5, [JournalEntryCurrencyData( 5, [JournalEntryCurrencyData(
"USD", [self.l_p_of5d], [self.l_p_of5c])]) "USD", [self.l_p_of5d], [self.l_p_of5c])])
"""The offset journal entry to the payable #3."""
self._add_journal_entry(self.j_r_of1) self._add_journal_entry(self.j_r_of1)
self._add_journal_entry(self.j_r_of2) self._add_journal_entry(self.j_r_of2)

View File

@ -23,17 +23,13 @@ import unittest
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db 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
PREFIX: str = "/accounting/options" PREFIX: str = "/accounting/options"
"""The URL prefix for the option management.""" """The URL prefix for the option management."""
DETAIL_URI: str = f"{PREFIX}?next=%2F_next"
"""THE URI for the option detail."""
EDIT_URI: str = f"{PREFIX}/edit?next=%2F_next"
"""THE URI for the form to edit the options."""
UPDATE_URI: str = f"{PREFIX}/update"
"""THE URI to update the options."""
class OptionTestCase(unittest.TestCase): class OptionTestCase(unittest.TestCase):
@ -45,29 +41,39 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): with self.__app.app_context():
from accounting.models import Option from accounting.models import Option
Option.query.delete() Option.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "admin") self.__client: httpx.Client = get_client(self.__app, "admin")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") client: httpx.Client = get_client(self.__app, "nobody")
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response response: httpx.Response
response = client.get(DETAIL_URI) response = client.get(detail_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI) response = client.get(edit_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) response = client.post(update_uri, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None: def test_viewer(self) -> None:
@ -75,16 +81,20 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") client: httpx.Client = get_client(self.__app, "viewer")
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response response: httpx.Response
response = client.get(DETAIL_URI) response = client.get(detail_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI) response = client.get(edit_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) response = client.post(update_uri, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor(self) -> None: def test_editor(self) -> None:
@ -92,16 +102,20 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "editor") client: httpx.Client = get_client(self.__app, "editor")
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response response: httpx.Response
response = client.get(DETAIL_URI) response = client.get(detail_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI) response = client.get(edit_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) response = client.post(update_uri, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_admin(self) -> None: def test_admin(self) -> None:
@ -109,17 +123,20 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response response: httpx.Response
response = self.client.get(DETAIL_URI) response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(EDIT_URI) response = self.__client.get(edit_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(UPDATE_URI, data=self.__get_form()) response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
def test_set(self) -> None: def test_set(self) -> None:
"""Test to set the options. """Test to set the options.
@ -127,59 +144,62 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.utils.options import options from accounting.utils.options import options
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
# Empty currency code # Empty currency code
form = self.__get_form() form = self.__get_form()
form["default_currency_code"] = " " form["default_currency_code"] = " "
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing currency code # Non-existing currency code
form = self.__get_form() form = self.__get_form()
form["default_currency_code"] = "ZZZ" form["default_currency_code"] = "ZZZ"
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Empty current account # Empty current account
form = self.__get_form() form = self.__get_form()
form["default_ie_account_code"] = " " form["default_ie_account_code"] = " "
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing current account # Non-existing current account
form = self.__get_form() form = self.__get_form()
form["default_ie_account_code"] = "9999-999" form["default_ie_account_code"] = "9999-999"
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Not a current account # Not a current account
form = self.__get_form() form = self.__get_form()
form["default_ie_account_code"] = Accounts.MEAL form["default_ie_account_code"] = Accounts.MEAL
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item name empty # Recurring item name empty
form = self.__get_form() form = self.__get_form()
key = [x for x in form if x.endswith("-name")][0] key = [x for x in form if x.endswith("-name")][0]
form[key] = " " form[key] = " "
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item account empty # Recurring item account empty
form = self.__get_form() form = self.__get_form()
key = [x for x in form if x.endswith("-account_code")][0] key = [x for x in form if x.endswith("-account_code")][0]
form[key] = " " form[key] = " "
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item non-expense account # Recurring item non-expense account
form = self.__get_form() form = self.__get_form()
@ -187,9 +207,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.SERVICE form[key] = Accounts.SERVICE
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item non-income account # Recurring item non-income account
form = self.__get_form() form = self.__get_form()
@ -197,9 +217,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-income-") if x.startswith("recurring-income-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.UTILITIES form[key] = Accounts.UTILITIES
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item payable expense # Recurring item payable expense
form = self.__get_form() form = self.__get_form()
@ -207,9 +227,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.PAYABLE form[key] = Accounts.PAYABLE
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item receivable income # Recurring item receivable income
form = self.__get_form() form = self.__get_form()
@ -217,30 +237,30 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-income-") if x.startswith("recurring-income-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.RECEIVABLE form[key] = Accounts.RECEIVABLE
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item description template empty # Recurring item description template empty
form = self.__get_form() form = self.__get_form()
key = [x for x in form if x.endswith("-description_template")][0] key = [x for x in form if x.endswith("-description_template")][0]
form[key] = " " form[key] = " "
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Success, with malformed order # Success, with malformed order
with self.app.app_context(): with self.__app.app_context():
self.assertEqual(options.default_currency_code, "USD") self.assertEqual(options.default_currency_code, "USD")
self.assertEqual(options.default_ie_account_code, "1111-001") self.assertEqual(options.default_ie_account_code, "1111-001")
self.assertEqual(len(options.recurring.expenses), 0) self.assertEqual(len(options.recurring.expenses), 0)
self.assertEqual(len(options.recurring.incomes), 0) self.assertEqual(len(options.recurring.incomes), 0)
response = self.client.post(UPDATE_URI, data=self.__get_form()) response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
self.assertEqual(options.default_currency_code, "EUR") self.assertEqual(options.default_currency_code, "EUR")
self.assertEqual(options.default_ie_account_code, "0000-000") self.assertEqual(options.default_ie_account_code, "0000-000")
self.assertEqual(len(options.recurring.expenses), 4) self.assertEqual(len(options.recurring.expenses), 4)
@ -261,11 +281,11 @@ class OptionTestCase(unittest.TestCase):
# Success, with no recurring data # Success, with no recurring data
form = self.__get_form() form = self.__get_form()
form = {x: form[x] for x in form if not x.startswith("recurring-")} form = {x: form[x] for x in form if not x.startswith("recurring-")}
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
self.assertEqual(len(options.recurring.expenses), 0) self.assertEqual(len(options.recurring.expenses), 0)
self.assertEqual(len(options.recurring.incomes), 0) self.assertEqual(len(options.recurring.incomes), 0)
@ -275,15 +295,17 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Option from accounting.models import Option
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
form: dict[str, str] form: dict[str, str]
option: Option | None option: Option | None
resource: httpx.Response resource: httpx.Response
response = self.client.post(UPDATE_URI, data=self.__get_form()) response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
timestamp: dt.datetime \ timestamp: dt.datetime \
@ -295,11 +317,11 @@ class OptionTestCase(unittest.TestCase):
# The recurring setting was not modified # The recurring setting was not modified
form = self.__get_form() form = self.__get_form()
form["default_currency_code"] = "JPY" form["default_currency_code"] = "JPY"
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
self.assertEqual(option.created_at, timestamp) self.assertEqual(option.created_at, timestamp)
@ -311,11 +333,11 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL form[key] = Accounts.MEAL
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
self.assertLess(option.created_at, option.updated_at) self.assertLess(option.created_at, option.updated_at)
@ -328,14 +350,16 @@ class OptionTestCase(unittest.TestCase):
from accounting.models import Option from accounting.models import Option
from accounting.utils.user import get_user_pk from accounting.utils.user import get_user_pk
admin_username, editor_username = "admin", "editor" admin_username, editor_username = "admin", "editor"
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
option: Option | None option: Option | None
response: httpx.Response response: httpx.Response
response = self.client.post(UPDATE_URI, data=self.__get_form()) response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
editor_pk: int = get_user_pk(editor_username) editor_pk: int = get_user_pk(editor_username)
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
@ -348,11 +372,11 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL form[key] = Accounts.MEAL
response = self.client.post(UPDATE_URI, data=form) response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.__app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
self.assertEqual(option.created_by.username, editor_username) self.assertEqual(option.created_by.username, editor_username)
@ -365,9 +389,9 @@ class OptionTestCase(unittest.TestCase):
:return: The option form. :return: The option form.
""" """
if csrf_token is None: if csrf_token is None:
csrf_token = self.csrf_token csrf_token = self.__csrf_token
return {"csrf_token": csrf_token, return {"csrf_token": csrf_token,
"next": NEXT_URI, "next": self.__encoded_next_uri,
"default_currency_code": "EUR", "default_currency_code": "EUR",
"default_ie_account_code": "0000-000", "default_ie_account_code": "0000-000",
"recurring-expense-1-name": "Water bill", "recurring-expense-1-name": "Water bill",

View File

@ -24,7 +24,7 @@ import httpx
from flask import Flask from flask import Flask
from test_site.lib import BaseTestData from test_site.lib import BaseTestData
from testlib import create_test_app, get_client, Accounts from testlib import create_test_app, get_client, get_csrf_token, Accounts
PREFIX: str = "/accounting" PREFIX: str = "/accounting"
"""The URL prefix for the reports.""" """The URL prefix for the reports."""
@ -41,22 +41,26 @@ class ReportTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") client: httpx.Client = get_client(self.__app, "nobody")
ReportTestData(self.app, "editor").populate() ReportTestData(self.__app, "editor").populate()
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -146,8 +150,8 @@ class ReportTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") client: httpx.Client = get_client(self.__app, "viewer")
ReportTestData(self.app, "editor").populate() ReportTestData(self.__app, "editor").populate()
response: httpx.Response response: httpx.Response
response = client.get(PREFIX) response = client.get(PREFIX)
@ -248,101 +252,101 @@ class ReportTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
ReportTestData(self.app, "editor").populate() ReportTestData(self.__app, "editor").populate()
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX) response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}?as=csv") response = self.__client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/journal") response = self.__client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/journal?as=csv") response = self.__client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/ledger") response = self.__client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/ledger?as=csv") response = self.__client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-expenses") response = self.__client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-expenses?as=csv") response = self.__client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/trial-balance") response = self.__client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/trial-balance?as=csv") response = self.__client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-statement") response = self.__client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-statement?as=csv") response = self.__client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/balance-sheet") response = self.__client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/balance-sheet?as=csv") response = self.__client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied") response = self.__client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unapplied?as=csv") response = self.__client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}") f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv") f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unmatched") response = self.__client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unmatched?as=csv") response = self.__client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}") f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv") f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=Salary") response = self.__client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv") response = self.__client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=薪水") response = self.__client.get(f"{PREFIX}/search?q=薪水")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=薪水&as=csv") response = self.__client.get(f"{PREFIX}/search?q=薪水&as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
@ -353,91 +357,91 @@ class ReportTestCase(unittest.TestCase):
""" """
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX) response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}?as=csv") response = self.__client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/journal") response = self.__client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/journal?as=csv") response = self.__client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/ledger") response = self.__client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/ledger?as=csv") response = self.__client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-expenses") response = self.__client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-expenses?as=csv") response = self.__client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/trial-balance") response = self.__client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/trial-balance?as=csv") response = self.__client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-statement") response = self.__client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-statement?as=csv") response = self.__client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/balance-sheet") response = self.__client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/balance-sheet?as=csv") response = self.__client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied") response = self.__client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unapplied?as=csv") response = self.__client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}") f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv") f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unmatched") response = self.__client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unmatched?as=csv") response = self.__client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}") f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get( response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv") f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=Salary") response = self.__client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv") response = self.__client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME) self.assertEqual(response.headers["Content-Type"], CSV_MIME)

View File

@ -52,6 +52,8 @@ def create_app(is_testing: bool = False) -> Flask:
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_SECURE": True,
"SQLALCHEMY_DATABASE_URI": db_uri, "SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en", "BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",

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."""
@ -57,13 +69,20 @@ class JournalEntryLineItemData:
:param original_line_item: The original journal entry line item. :param original_line_item: The original journal entry line item.
""" """
self.journal_entry: JournalEntryData | None = None self.journal_entry: JournalEntryData | None = None
"""The journal entry data."""
self.id: int = -1 self.id: int = -1
"""The journal entry line item ID."""
self.no: int = -1 self.no: int = -1
"""The line item number under the journal entry and debit or credit."""
self.original_line_item: JournalEntryLineItemData | None \ self.original_line_item: JournalEntryLineItemData | None \
= original_line_item = original_line_item
"""The original journal entry line item."""
self.account: str = account self.account: str = account
"""The account code."""
self.description: str | None = description self.description: str | None = description
"""The description."""
self.amount: Decimal = Decimal(amount) self.amount: Decimal = Decimal(amount)
"""The amount."""
def form(self, prefix: str, debit_credit: str, index: int, def form(self, prefix: str, debit_credit: str, index: int,
is_update: bool) -> dict[str, str]: is_update: bool) -> dict[str, str]:
@ -101,8 +120,11 @@ class JournalEntryCurrencyData:
:param credit: The credit line items. :param credit: The credit line items.
""" """
self.code: str = currency self.code: str = currency
"""The currency code."""
self.debit: list[JournalEntryLineItemData] = debit self.debit: list[JournalEntryLineItemData] = debit
"""The debit line items."""
self.credit: list[JournalEntryLineItemData] = credit self.credit: list[JournalEntryLineItemData] = credit
"""The credit line items."""
def form(self, index: int, is_update: bool) -> dict[str, str]: def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data. """Returns the currency as form data.
@ -131,45 +153,51 @@ class JournalEntryData:
:param currencies: The journal entry currency data. :param currencies: The journal entry currency data.
""" """
self.id: int = -1 self.id: int = -1
"""The journal entry ID."""
self.days: int = days self.days: int = days
"""The number of days before today."""
self.currencies: list[JournalEntryCurrencyData] = currencies self.currencies: list[JournalEntryCurrencyData] = currencies
"""The journal entry currency data."""
self.note: str | None = None self.note: str | None = None
"""The note."""
for currency in self.currencies: for currency in self.currencies:
for line_item in currency.debit: for line_item in currency.debit:
line_item.journal_entry = self line_item.journal_entry = self
for line_item in currency.credit: for line_item in currency.credit:
line_item.journal_entry = self line_item.journal_entry = self
def new_form(self, csrf_token: str, next_uri: str) -> dict[str, str]: def new_form(self, csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the journal entry as a creation form. """Returns the journal entry as a creation form.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param next_uri: The next URI. :param encoded_next_uri: The encoded next URI.
:return: The journal entry as a creation form. :return: The journal entry as a creation form.
""" """
return self.__form(csrf_token, next_uri, is_update=False) return self.__form(csrf_token, encoded_next_uri, is_update=False)
def update_form(self, csrf_token: str, next_uri: str) -> dict[str, str]: def update_form(self, csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the journal entry as an update form. """Returns the journal entry as an update form.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param next_uri: The next URI. :param encoded_next_uri: The encoded next URI.
:return: The journal entry as an update form. :return: The journal entry as an update form.
""" """
return self.__form(csrf_token, next_uri, is_update=True) return self.__form(csrf_token, encoded_next_uri, is_update=True)
def __form(self, csrf_token: str, next_uri: str, is_update: bool = False) \ def __form(self, csrf_token: str, encoded_next_uri: str,
-> dict[str, str]: is_update: bool = False) -> dict[str, str]:
"""Returns the journal entry as a form. """Returns the journal entry as a form.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param next_uri: The next URI. :param encoded_next_uri: The encoded next URI.
: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": next_uri, "next": encoded_next_uri,
"date": date.isoformat()} "date": date.isoformat()}
for i in range(len(self.currencies)): for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update)) form.update(self.currencies[i].form(i + 1, is_update))
@ -188,13 +216,17 @@ class BaseTestData(ABC):
:param username: The username. :param username: The username.
""" """
self._app: Flask = app self._app: Flask = app
"""The Flask application."""
with self._app.app_context(): with self._app.app_context():
current_user: User | None = User.query\ current_user: User | None = User.query\
.filter(User.username == username).first() .filter(User.username == username).first()
assert current_user is not None assert current_user is not None
self.__current_user_id: int = current_user.id self.__current_user_id: int = current_user.id
"""The current user ID."""
self.__journal_entries: list[dict[str, Any]] = [] self.__journal_entries: list[dict[str, Any]] = []
"""The data of the journal entries."""
self.__line_items: list[dict[str, Any]] = [] self.__line_items: list[dict[str, Any]] = []
"""The data of the journal entry line items."""
self._init_data() self._init_data()
@abstractmethod @abstractmethod
@ -240,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

@ -19,11 +19,14 @@
""" """
from babel import Locale from babel import Locale
from flask import request, session, current_app, Blueprint, Response, \ from flask import request, session, current_app, Blueprint, Response, \
redirect, url_for, Flask redirect, Flask
from flask_babel import Babel from flask_babel import Babel
from werkzeug.datastructures import LanguageAccept from werkzeug.datastructures import LanguageAccept
from accounting.utils.next_uri import or_next
bp: Blueprint = Blueprint("locale", __name__, url_prefix="/") bp: Blueprint = Blueprint("locale", __name__, url_prefix="/")
"""The blueprint for the localization."""
def get_locale(): def get_locale():
@ -68,9 +71,7 @@ def set_locale() -> Response:
all_linguas: dict[str, str] = get_all_linguas() all_linguas: dict[str, str] = get_all_linguas()
if "locale" in request.form and request.form["locale"] in all_linguas: if "locale" in request.form and request.form["locale"] in all_linguas:
session["locale"] = request.form["locale"] session["locale"] = request.form["locale"]
if "next" in request.form: return redirect(or_next("/"))
return redirect(request.form["next"])
return redirect(url_for("home.home"))
def get_all_linguas() -> dict[str, str]: def get_all_linguas() -> dict[str, str]:

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,12 +23,14 @@ 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, \
JournalEntryCurrencyData, BaseTestData JournalEntryCurrencyData, BaseTestData
bp: Blueprint = Blueprint("reset", __name__, url_prefix="/") bp: Blueprint = Blueprint("reset", __name__, url_prefix="/")
"""The blueprint for the data reset."""
@bp.get("reset", endpoint="reset-page") @bp.get("reset", endpoint="reset-page")
@ -116,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
@ -159,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,31 +25,31 @@ 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" 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.2.1/css/all.min.css" 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" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/css/tempus-dominus.min.css" 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.4.3/dist/js/tempus-dominus.min.js" integrity="sha384-2MkID2vkc9sxBCqs2us3mB8fV+c0o7uPtOvAPjaC8gKv9Bk21UHT0r2Q7Kv70+zO" 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>
{{ _("Home") }} {{ _("Home") }}
</a> </a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collapsible-navbar" aria-controls="collapsible-navbar" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#accounting-collapsible-navbar" aria-controls="accounting-collapsible-navbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div id="collapsible-navbar" class="collapse navbar-collapse"> <div id="accounting-collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% include "accounting/include/nav.html" %} {% include "accounting/include/nav.html" %}
</ul> </ul>
@ -96,7 +96,7 @@ First written: 2023/1/27
</span> </span>
<form action="{{ url_for("locale.set-locale") }}" method="post"> <form action="{{ url_for("locale.set-locale") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}"> <input type="hidden" name="next" value="{{ accounting_as_next() }}">
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
{% for locale_code, locale_name in get_all_linguas().items() %} {% for locale_code, locale_name in get_all_linguas().items() %}
<li> <li>
@ -121,10 +121,10 @@ First written: 2023/1/27
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
{% if category == "success" %} {% if category == "success" %}
<div class="alert alert-success alert-dismissible fade show" role="alert"> <div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
{% elif category == "error" %} {% elif category == "error" %}
<div class="alert alert-danger alert-dismissible fade show" role="alert"> <div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>{{ _("Error:") }}</strong> {{ message }} <strong>{{ _("Error:") }}</strong> {{ message }}

View File

@ -25,7 +25,7 @@ First written: 2023/1/27
{% block content %} {% block content %}
<p>{{ _("This is the live demonstration of the Mia! Accounting project. Please <a href=\"/login?next=%%2Faccounting\">log in</a> to continue.") }}</p> <p>{{ _("This is the live demonstration of the Mia! Accounting project. Please <a href=\"%(url)s\">log in</a> to continue.", url=url_for("auth.login")) }}</p>
<p>{{ _("You may also want to check the <a href=\"https://mia-accounting.readthedocs.io\">full documentation</a> and the <a href=\"https://github.com/imacat/mia-accounting\">Github repository</a>.") }}</p> <p>{{ _("You may also want to check the <a href=\"https://mia-accounting.readthedocs.io\">full documentation</a> and the <a href=\"https://github.com/imacat/mia-accounting\">Github repository</a>.") }}</p>

View File

@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: mia-accounting-test-site 1.0.0\n" "Project-Id-Version: mia-accounting-test-site 1.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-12 17:59+0800\n" "POT-Creation-Date: 2023-06-10 10:42+0800\n"
"PO-Revision-Date: 2023-04-12 18:00+0800\n" "PO-Revision-Date: 2023-06-10 10:43+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -24,7 +24,7 @@ msgstr ""
msgid "The sample data are emptied and reset successfully." msgid "The sample data are emptied and reset successfully."
msgstr "範例資料已清空重設。" msgstr "範例資料已清空重設。"
#: tests/test_site/reset.py:68 #: tests/test_site/reset.py:69
msgid "The database is emptied successfully." msgid "The database is emptied successfully."
msgstr "資料庫已清空。" msgstr "資料庫已清空。"
@ -61,8 +61,8 @@ msgstr "Mia! Accounting 示範站"
#, python-format #, python-format
msgid "" msgid ""
"This is the live demonstration of the Mia! Accounting project. Please <a" "This is the live demonstration of the Mia! Accounting project. Please <a"
" href=\"/login?next=%%2Faccounting\">log in</a> to continue." " href=\"%(url)s\">log in</a> to continue."
msgstr "這是 Mia! Accounting 專案的示範站。請先<a href=\"/login?next=%%2Faccounting\">登入</a>。" msgstr "這是 Mia! Accounting 專案的示範站。請先<a href=\"%(url)s\">登入</a>。"
#: tests/test_site/templates/home.html:30 #: tests/test_site/templates/home.html:30
msgid "" msgid ""

View File

@ -22,10 +22,12 @@ import unittest
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \ from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \
BaseTestData BaseTestData
from testlib import NEXT_URI, create_test_app, get_client, Accounts from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
Accounts
PREFIX: str = "/accounting/match-offsets/USD" PREFIX: str = "/accounting/match-offsets/USD"
"""The URL prefix for the unmatched offset management.""" """The URL prefix for the unmatched offset management."""
@ -40,27 +42,34 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context(): with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "editor") self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") client: httpx.Client = get_client(self.__app, "nobody")
DifferentTestData(self.app, "nobody").populate() csrf_token: str = get_csrf_token(client)
DifferentTestData(self.__app, "nobody").populate()
response: httpx.Response response: httpx.Response
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None: def test_viewer(self) -> None:
@ -68,13 +77,14 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") client: httpx.Client = get_client(self.__app, "viewer")
DifferentTestData(self.app, "viewer").populate() csrf_token: str = get_csrf_token(client)
DifferentTestData(self.__app, "viewer").populate()
response: httpx.Response response: httpx.Response
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor(self) -> None: def test_editor(self) -> None:
@ -82,12 +92,12 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
DifferentTestData(self.app, "editor").populate() DifferentTestData(self.__app, "editor").populate()
response: httpx.Response response: httpx.Response
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = self.__client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
@ -98,9 +108,9 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
""" """
response: httpx.Response response: httpx.Response
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}", response = self.__client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
@ -112,7 +122,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
from accounting.models import Currency, Account, JournalEntryLineItem from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.report.utils.offset_matcher import OffsetMatcher from accounting.report.utils.offset_matcher import OffsetMatcher
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
data: DifferentTestData = DifferentTestData(self.app, "editor") data: DifferentTestData = DifferentTestData(self.__app, "editor")
data.populate() data.populate()
account: Account | None account: Account | None
line_item: JournalEntryLineItem | None line_item: JournalEntryLineItem | None
@ -120,13 +130,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri: str match_uri: str
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
currency: Currency | None \ currency: Currency | None \
= db.session.get(Currency, default_currency_code()) = db.session.get(Currency, default_currency_code())
assert currency is not None assert currency is not None
# The receivables # The receivables
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -148,13 +158,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNone(line_item.original_line_item_id) self.assertIsNone(line_item.original_line_item_id)
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri, response = self.__client.post(match_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -176,7 +186,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id) self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables # The payables
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -198,13 +208,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNone(line_item.original_line_item_id) self.assertIsNone(line_item.original_line_item_id)
match_uri = f"{PREFIX}/{Accounts.PAYABLE}" match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri, response = self.__client.post(match_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -233,7 +243,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
from accounting.models import Currency, Account, JournalEntryLineItem from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.report.utils.offset_matcher import OffsetMatcher from accounting.report.utils.offset_matcher import OffsetMatcher
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
data: SameTestData = SameTestData(self.app, "editor") data: SameTestData = SameTestData(self.__app, "editor")
data.populate() data.populate()
account: Account | None account: Account | None
line_item: JournalEntryLineItem | None line_item: JournalEntryLineItem | None
@ -241,13 +251,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri: str match_uri: str
response: httpx.Response response: httpx.Response
with self.app.app_context(): with self.__app.app_context():
currency: Currency | None \ currency: Currency | None \
= db.session.get(Currency, default_currency_code()) = db.session.get(Currency, default_currency_code())
assert currency is not None assert currency is not None
# The receivables # The receivables
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -276,13 +286,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id) self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}" match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri, response = self.__client.post(match_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE) account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -313,7 +323,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id) self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables # The payables
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -342,13 +352,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id) self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
match_uri = f"{PREFIX}/{Accounts.PAYABLE}" match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri, response = self.__client.post(match_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.__csrf_token,
"next": NEXT_URI}) "next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context(): with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE) account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None assert account is not None
matcher = OffsetMatcher(currency, account) matcher = OffsetMatcher(currency, account)
@ -408,18 +418,22 @@ class DifferentTestData(BaseTestData):
50, [JournalEntryCurrencyData( 50, [JournalEntryCurrencyData(
"USD", [self.l_r_or1d, self.l_r_or4d], "USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])]) [self.l_r_or1c, self.l_r_or4c])])
"""The receivable original journal entry #1."""
self.j_r_or2: JournalEntryData = JournalEntryData( self.j_r_or2: JournalEntryData = JournalEntryData(
30, [JournalEntryCurrencyData( 30, [JournalEntryCurrencyData(
"USD", [self.l_r_or2d, self.l_r_or3d], "USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])]) [self.l_r_or2c, self.l_r_or3c])])
"""The receivable original journal entry #2"""
self.j_p_or1: JournalEntryData = JournalEntryData( self.j_p_or1: JournalEntryData = JournalEntryData(
40, [JournalEntryCurrencyData( 40, [JournalEntryCurrencyData(
"USD", [self.l_p_or1d, self.l_p_or4d], "USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])]) [self.l_p_or1c, self.l_p_or4c])])
"""The payable original journal entry #1."""
self.j_p_or2: JournalEntryData = JournalEntryData( self.j_p_or2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData( 20, [JournalEntryCurrencyData(
"USD", [self.l_p_or2d, self.l_p_or3d], "USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])]) [self.l_p_or2c, self.l_p_or3c])])
"""The payable original journal entry #2."""
self._add_journal_entry(self.j_r_or1) self._add_journal_entry(self.j_r_or1)
self._add_journal_entry(self.j_r_or2) self._add_journal_entry(self.j_r_or2)
@ -454,23 +468,29 @@ class DifferentTestData(BaseTestData):
self.j_r_of1: JournalEntryData = JournalEntryData( self.j_r_of1: JournalEntryData = JournalEntryData(
25, [JournalEntryCurrencyData( 25, [JournalEntryCurrencyData(
"USD", [self.l_r_of1d], [self.l_r_of1c])]) "USD", [self.l_r_of1d], [self.l_r_of1c])])
"""The offset journal entry to the receivable #1."""
self.j_r_of2: JournalEntryData = JournalEntryData( self.j_r_of2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData( 20, [JournalEntryCurrencyData(
"USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d], "USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])]) [self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
"""The offset journal entry to the receivable #2."""
self.j_r_of3: JournalEntryData = JournalEntryData( self.j_r_of3: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData( 15, [JournalEntryCurrencyData(
"USD", [self.l_r_of5d], [self.l_r_of5c])]) "USD", [self.l_r_of5d], [self.l_r_of5c])])
"""The offset journal entry to the receivable #3."""
self.j_p_of1: JournalEntryData = JournalEntryData( self.j_p_of1: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData( 15, [JournalEntryCurrencyData(
"USD", [self.l_p_of1d], [self.l_p_of1c])]) "USD", [self.l_p_of1d], [self.l_p_of1c])])
"""The offset journal entry to the payable #1."""
self.j_p_of2: JournalEntryData = JournalEntryData( self.j_p_of2: JournalEntryData = JournalEntryData(
10, [JournalEntryCurrencyData( 10, [JournalEntryCurrencyData(
"USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d], "USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])]) [self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
"""The offset journal entry to the payable #2."""
self.j_p_of3: JournalEntryData = JournalEntryData( self.j_p_of3: JournalEntryData = JournalEntryData(
5, [JournalEntryCurrencyData( 5, [JournalEntryCurrencyData(
"USD", [self.l_p_of5d], [self.l_p_of5c])]) "USD", [self.l_p_of5d], [self.l_p_of5c])])
"""The offset journal entry to the payable #3."""
self._add_journal_entry(self.j_r_of1) self._add_journal_entry(self.j_r_of1)
self._add_journal_entry(self.j_r_of2) self._add_journal_entry(self.j_r_of2)

View File

@ -23,10 +23,11 @@ from urllib.parse import quote_plus
import httpx import httpx
from flask import Flask, request from flask import Flask, request
from accounting.utils.next_uri import append_next, inherit_next, or_next from accounting.utils.next_uri import append_next, inherit_next, or_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 testlib import TEST_SERVER, create_test_app, get_csrf_token from testlib import TEST_SERVER, create_test_app, get_csrf_token, NEXT_URI
class NextUriTestCase(unittest.TestCase): class NextUriTestCase(unittest.TestCase):
@ -39,7 +40,8 @@ class NextUriTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
"""The Flask application."""
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.
@ -50,26 +52,33 @@ class NextUriTestCase(unittest.TestCase):
"""The test view with the next URI.""" """The test view with the next URI."""
current_uri: str = request.full_path if request.query_string \ current_uri: str = request.full_path if request.query_string \
else request.path else request.path
with self.__app.app_context():
encoded_current: str = encode_next(current_uri)
self.assertEqual(append_next(self.TARGET), self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(current_uri)}") f"{self.TARGET}?next={encoded_current}")
next_uri: str = request.form["next"] if request.method == "POST" \ encoded_next_uri: str = request.form["next"] \
else request.args["next"] if request.method == "POST" else request.args["next"]
self.assertEqual(inherit_next(self.TARGET), self.assertEqual(inherit_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(next_uri)}") f"{self.TARGET}?next={encoded_next_uri}")
with self.__app.app_context():
next_uri: str = decode_next(encoded_next_uri)
self.assertEqual(or_next(self.TARGET), next_uri) self.assertEqual(or_next(self.TARGET), next_uri)
return "" return ""
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, base_url=TEST_SERVER) client: httpx.Client = httpx.Client(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
response = client.get("/test-next?next=/next&q=abc&page-no=4") with self.__app.app_context():
encoded_uri: str = encode_next(NEXT_URI)
response = client.get(f"/test-next?next={encoded_uri}&q=abc&page-no=4")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.post("/test-next", data={"csrf_token": csrf_token, response = client.post("/test-next", data={"csrf_token": csrf_token,
"next": "/next", "next": encoded_uri,
"name": "viewer"}) "name": "viewer"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -80,17 +89,15 @@ class NextUriTestCase(unittest.TestCase):
""" """
def test_no_next_uri_view() -> str: def test_no_next_uri_view() -> str:
"""The test view without the next URI.""" """The test view without the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(current_uri)}")
self.assertEqual(inherit_next(self.TARGET), self.TARGET) self.assertEqual(inherit_next(self.TARGET), self.TARGET)
self.assertEqual(or_next(self.TARGET), self.TARGET) self.assertEqual(or_next(self.TARGET), self.TARGET)
return "" return ""
self.app.add_url_rule("/test-no-next", view_func=test_no_next_uri_view, self.__app.add_url_rule("/test-no-next",
methods=["GET", "POST"]) view_func=test_no_next_uri_view,
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER) methods=["GET", "POST"])
client: httpx.Client = httpx.Client(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
@ -101,6 +108,38 @@ class NextUriTestCase(unittest.TestCase):
"name": "viewer"}) "name": "viewer"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_invalid(self) -> None:
"""Tests the next URI utilities without an invalid next URI.
:return: None.
"""
def test_invalid_next_uri_view() -> str:
"""The test view without the next URI."""
self.assertEqual(inherit_next(self.TARGET), self.TARGET)
self.assertEqual(or_next(self.TARGET), self.TARGET)
return ""
self.__app.add_url_rule("/test-invalid-next",
view_func=test_invalid_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.__app,
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
next_uri: str
expected1: str
expected2: str
response: httpx.Response
# A foreign URI
next_uri = "https://example.com"
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}")
self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next",
data={"csrf_token": csrf_token,
"next": next_uri})
self.assertEqual(response.status_code, 200)
class QueryKeywordParserTestCase(unittest.TestCase): class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser.""" """The test case for the query keyword parser."""
@ -143,7 +182,7 @@ class PaginationTestCase(unittest.TestCase):
"""The test case for pagination.""" """The test case for pagination."""
class Params: class Params:
"""The testing parameters.""" """The testing pagination parameters."""
def __init__(self, items: list[int], is_reversed: bool | None, def __init__(self, items: list[int], is_reversed: bool | None,
result: list[int], is_paged: bool): result: list[int], is_paged: bool):
@ -155,9 +194,13 @@ class PaginationTestCase(unittest.TestCase):
:param is_paged: Whether we need pagination. :param is_paged: Whether we need pagination.
""" """
self.items: list[int] = items self.items: list[int] = items
"""All the items in the list."""
self.is_reversed: bool | None = is_reversed self.is_reversed: bool | None = is_reversed
"""Whether the default page is the last page."""
self.result: list[int] = result self.result: list[int] = result
"""The expected items on the page."""
self.is_paged: bool = is_paged self.is_paged: bool = is_paged
"""Whether we need pagination."""
def setUp(self) -> None: def setUp(self) -> None:
"""Sets up the test. """Sets up the test.
@ -165,24 +208,29 @@ class PaginationTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.__app: Flask = create_test_app()
self.params = self.Params([], None, [], True) """The Flask application."""
self.__params: PaginationTestCase.Params \
= self.Params([], None, [], True)
"""The testing pagination parameters."""
@self.app.get("/test-pagination") @self.__app.get("/test-pagination")
def test_pagination_view() -> str: def test_pagination_view() -> str:
"""The test view with the pagination.""" """The test view with the pagination."""
pagination: Pagination pagination: Pagination
if self.params.is_reversed is not None: if self.__params.is_reversed is not None:
pagination = Pagination[int]( pagination = Pagination[int](
self.params.items, is_reversed=self.params.is_reversed) self.__params.items, is_reversed=self.__params.is_reversed)
else: else:
pagination = Pagination[int](self.params.items) pagination = Pagination[int](self.__params.items)
self.assertEqual(pagination.is_paged, self.params.is_paged) self.assertEqual(pagination.is_paged, self.__params.is_paged)
self.assertEqual(pagination.list, self.params.result) self.assertEqual(pagination.list, self.__params.result)
return "" return ""
self.client = httpx.Client(app=self.app, base_url=TEST_SERVER) self.__client: httpx.Client = httpx.Client(app=self.__app,
self.client.headers["Referer"] = TEST_SERVER base_url=TEST_SERVER)
"""The user client."""
self.__client.headers["Referer"] = TEST_SERVER
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,
@ -199,9 +247,9 @@ class PaginationTestCase(unittest.TestCase):
target: str = "/test-pagination" target: str = "/test-pagination"
if query != "": if query != "":
target = f"{target}?{query}" target = f"{target}?{query}"
self.params = self.Params(list(items), is_reversed, self.__params = self.Params(list(items), is_reversed,
list(result), is_paged) list(result), is_paged)
response: httpx.Response = self.client.get(target) response: httpx.Response = self.__client.get(target)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def __test_malformed(self, query: str, items: range, redirect_to: str, def __test_malformed(self, query: str, items: range, redirect_to: str,
@ -215,8 +263,8 @@ class PaginationTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
target: str = "/test-pagination" target: str = "/test-pagination"
self.params = self.Params(list(items), is_reversed, [], True) self.__params = self.Params(list(items), is_reversed, [], True)
response: httpx.Response = self.client.get(f"{target}?{query}") response: httpx.Response = self.__client.get(f"{target}?{query}")
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{target}?{redirect_to}") f"{target}?{redirect_to}")

View File

@ -25,6 +25,7 @@ from typing import Literal
import httpx import httpx
from flask import Flask, render_template_string from flask import Flask, render_template_string
from accounting.utils.next_uri import encode_next
from test_site import create_app from test_site import create_app
TEST_SERVER: str = "https://testserver" TEST_SERVER: str = "https://testserver"
@ -71,9 +72,9 @@ def create_test_app() -> Flask:
"""The test view to return the CSRF token.""" """The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}") return render_template_string("{{csrf_token()}}")
@app.get("/.errors") @app.get("/.messages")
def get_errors_view() -> str: def get_messages_view() -> str:
"""The test view to return the CSRF token.""" """The test view to return the flashed messages."""
return render_template_string("{{get_flashed_messages()|tojson}}") return render_template_string("{{get_flashed_messages()|tojson}}")
return app return app
@ -88,40 +89,45 @@ def get_csrf_token(client: httpx.Client) -> str:
return client.get("/.csrf-token").text return client.get("/.csrf-token").text
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]: def get_client(app: Flask, username: str) -> httpx.Client:
"""Returns a user client. """Returns a user client.
:param app: The Flask application. :param app: The Flask application.
:param username: The username. :param username: The username.
:return: A tuple of the client and the CSRF token. :return: The user client.
""" """
client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER) client: httpx.Client = httpx.Client(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():
encoded_next_uri: str = encode_next(NEXT_URI)
response: httpx.Response = client.post("/login", response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": "/", "next": encoded_next_uri,
"username": username}) "username": username})
assert response.status_code == 302 assert response.status_code == 302
assert response.headers["Location"] == "/" assert response.headers["Location"] == NEXT_URI
return client, csrf_token return client
def set_locale(client: httpx.Client, csrf_token: str, def set_locale(app: Flask, client: httpx.Client, csrf_token: str,
locale: Literal["en", "zh_Hant", "zh_Hans"]) -> None: locale: Literal["en", "zh_Hant", "zh_Hans"]) -> None:
"""Sets the current locale. """Sets the current locale.
:param app: The Flask application.
:param client: The test client. :param client: The test client.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param locale: The locale. :param locale: The locale.
:return: None. :return: None.
""" """
with app.app_context():
encoded_next_uri: str = encode_next(NEXT_URI)
response: httpx.Response = client.post("/locale", response: httpx.Response = client.post("/locale",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"locale": locale, "locale": locale,
"next": "/next"}) "next": encoded_next_uri})
assert response.status_code == 302 assert response.status_code == 302
assert response.headers["Location"] == "/next" assert response.headers["Location"] == NEXT_URI
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int: def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
@ -152,6 +158,6 @@ def match_journal_entry_detail(location: str) -> int:
:raise AssertionError: When the location is not the journal entry detail. :raise AssertionError: When the location is not the journal entry detail.
""" """
m: re.Match = re.match( m: re.Match = re.match(
r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location) r"^/accounting/journal-entries/(\d+)\?next=", location)
assert m is not None assert m is not None
return int(m.group(1)) return int(m.group(1))

View File

@ -25,7 +25,7 @@ from secrets import randbelow
from flask import Flask from flask import Flask
from test_site import db from test_site import db
from testlib import NEXT_URI, Accounts from testlib import Accounts
NON_EMPTY_NOTE: str = " This is \n\na test." NON_EMPTY_NOTE: str = " This is \n\na test."
"""The stripped content of an non-empty note.""" """The stripped content of an non-empty note."""
@ -33,14 +33,15 @@ EMPTY_NOTE: str = " \n\n "
"""The empty note content.""" """The empty note content."""
def get_add_form(csrf_token: str) -> dict[str, str]: def get_add_form(csrf_token: str, encoded_next_uri: str) -> dict[str, str]:
"""Returns the form data to add a new journal entry. """Returns the form data to add a new journal entry.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:return: The form data to add a new journal entry. :return: The form data to add a new journal entry.
""" """
return {"csrf_token": csrf_token, return {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": dt.date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-no": "16", "currency-0-debit-0-no": "16",
@ -102,13 +103,15 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
def get_unchanged_update_form(journal_entry_id: int, app: Flask, def get_unchanged_update_form(journal_entry_id: int, app: Flask,
csrf_token: str) -> dict[str, str]: csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the form data to update a journal entry, where the data are not """Returns the form data to update a journal entry, where the data are not
changed. changed.
:param journal_entry_id: The journal entry ID. :param journal_entry_id: The journal entry ID.
:param app: The Flask application. :param app: The Flask application.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:return: The form data to update the journal entry, where the data are not :return: The form data to update the journal entry, where the data are not
changed. changed.
""" """
@ -121,7 +124,7 @@ def get_unchanged_update_form(journal_entry_id: int, app: Flask,
form: dict[str, str] \ form: dict[str, str] \
= {"csrf_token": csrf_token, = {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry.date, "date": journal_entry.date,
"note": " \n \n\n " if journal_entry.note is None "note": " \n \n\n " if journal_entry.note is None
else f"\n \n\n \n \n{journal_entry.note} \n\n "} else f"\n \n\n \n \n{journal_entry.note} \n\n "}
@ -182,20 +185,22 @@ def __get_new_index(indices_used: set[int]) -> int:
def get_update_form(journal_entry_id: int, app: Flask, def get_update_form(journal_entry_id: int, app: Flask,
csrf_token: str, is_debit: bool | None) -> dict[str, str]: csrf_token: str, encoded_next_uri: str,
is_debit: bool | None) -> dict[str, str]:
"""Returns the form data to update a journal entry, where the data are """Returns the form data to update a journal entry, where the data are
changed. changed.
:param journal_entry_id: The journal entry ID. :param journal_entry_id: The journal entry ID.
:param app: The Flask application. :param app: The Flask application.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:param is_debit: True for a cash disbursement journal entry, False for a :param is_debit: True for a cash disbursement journal entry, False for a
cash receipt journal entry, or None for a transfer journal entry. cash receipt journal entry, or None for a transfer journal entry.
:return: The form data to update the journal entry, where the data are :return: The form data to update the journal entry, where the data are
changed. changed.
""" """
form: dict[str, str] = get_unchanged_update_form( form: dict[str, str] = get_unchanged_update_form(
journal_entry_id, app, csrf_token) journal_entry_id, app, csrf_token, encoded_next_uri)
# Mess up the line items in a currency # Mess up the line items in a currency
currency_prefix: str = __get_currency_prefix(form, "USD") currency_prefix: str = __get_currency_prefix(form, "USD")