101 Commits

Author SHA1 Message Date
8dc340dbf1 Advanced to version 0.2.0. 2023-02-07 20:55:00 +08:00
4b5b348270 Implemented the incremental search (search-as-you-type) in the base account selector of the account form. 2023-02-07 20:27:25 +08:00
d9585f0e53 Fixed a translated message. 2023-02-07 20:20:18 +08:00
5737d6cef4 Fixed the error message in the Javascript validateAsyncCodeIsDuplicated function in currency-form.js. 2023-02-07 20:20:16 +08:00
1d61fa93d3 Prepend all the HTML ID and class names with "accounting-" to avoid name conflict. 2023-02-07 20:20:01 +08:00
b1c7bc61c4 Renamed the can_view_accounting and can_edit_accounting template functions to accounting_can_view and accounting_can_edit, respectively. 2023-02-07 17:15:05 +08:00
708a434b5d Renamed the append_next, inherit_next, and or_next template filters to accounting_append_next, accounting_inherit_next, and accounting_or_next, to avoid name conflict. 2023-02-07 17:13:06 +08:00
8e524674a3 Added the init_app function to the "accounting.utils.next_url" module to initialize the template filters, and apply it to the init_app function of the accounting application. 2023-02-07 17:10:03 +08:00
699db20308 Revised the init_app function of the "accounting.utils.permission" module to register the "can_view" and "can_edit" functions under the blueprint instead of the whole application. 2023-02-07 17:05:27 +08:00
c3cedf714b Revised the documentation of the init_app function of the "accounting.locale", "accounting.base_account", "accounting.account", and "accounting.currency" modules. 2023-02-07 17:01:08 +08:00
c67ed4471c Fixed the permission so that the template helper also use the wrapper can_view and can_edit, that can_edit always requires the user to log in first. 2023-02-07 16:58:46 +08:00
2d3b9f68b8 Added the missing Material Floating Action Button to add a new currency for the mobile devices to the currency list. 2023-02-07 16:43:42 +08:00
f82278b48a Updated the icon of the currency management in the navigation menu. 2023-02-07 16:36:18 +08:00
85480804e7 Updated the translation. 2023-02-07 16:23:46 +08:00
9e85c14431 Changed the can_edit permission to at least require the user to log in first. 2023-02-07 16:03:13 +08:00
31dc8fab04 Changed the type hint of the "current_user" pseudo property of the AbstractUserUtils class to return None when the user has not logged in. 2023-02-07 16:00:51 +08:00
dc24af1db0 Added the get_current_user function to the "accounting.utils.user" module to retrieve the currently logged-in user and cache it in the current request. 2023-02-07 16:00:47 +08:00
59795635ee Updated the Sphinx documentation. 2023-02-07 11:41:28 +08:00
399afe56c8 Added the initial values for the database instance, the user class and the user primary key column, to allow the Sphinx documentation system to work properly. 2023-02-07 11:40:48 +08:00
16e2a146db Fixed the documentation in the Account data model. 2023-02-07 11:30:25 +08:00
f7ce94902f Revised the AccountTestCase test case, added the test_add, test_basic_update, test_update_not_modified, test_created_updated_by, test_l10n, and test_delete test to replace the simple test_change_base test. 2023-02-07 11:29:09 +08:00
5cf3cb1e11 Added the "is_modified" pseudo property to the Account data model, and applied it to the update_account view, to count the localized titles for modification. 2023-02-07 11:14:15 +08:00
a78057a8c3 Renamed the variable in the test_created_updated_by test of the CurrencyTestCase test case. 2023-02-07 09:47:32 +08:00
0491614ae4 Added the PREFIX constant to simplify the CurrencyTestCase test case. 2023-02-07 09:46:54 +08:00
fb9ff1d7ff Added to validate if the base account is available in the AccountForm form with the BaseAccountAvailable validator. 2023-02-07 09:30:06 +08:00
be10984cbb Fixed the documentation of the BaseAccountExists validator. 2023-02-07 09:28:10 +08:00
7b2089bdfb Revised the currency test cases. 2023-02-07 08:24:24 +08:00
be8dc21c5a Revised the code in the test_l10n test of the CurrencyTestCase test case. 2023-02-07 00:38:41 +08:00
2f8c6f6981 Removed the redundant unique constraint from the AccountL10n and CurrencyL10n data models. 2023-02-07 00:24:36 +08:00
cdd010427b Added documentation to the columns of the AccountL10n data model. 2023-02-07 00:23:45 +08:00
d78b941674 Applied the delete method of the Account data model to the delete_account view, to make things easier. 2023-02-07 00:22:23 +08:00
570c84c196 Added the currency management. 2023-02-07 00:13:33 +08:00
7873e16cc3 Added the editor2 user to the test site. 2023-02-06 23:28:21 +08:00
52351c52bc Revised the imports in test_base_account.py and test_account.py. 2023-02-06 21:45:56 +08:00
591fb4a7ab Replaced the UserClient class and the get_user_client function with the get_client function in the tests, for simplicity. 2023-02-06 21:45:28 +08:00
2a6c5de6d6 Removed the unused clients from the setUp method of the BaseAccountTestCase test case. 2023-02-06 21:37:41 +08:00
6b94cfb908 Removed excess blank lines in test_account.py and test_base_account.py. 2023-02-06 19:57:19 +08:00
eb90e83c98 Removed an unused import from the "accounting" module. 2023-02-06 19:31:06 +08:00
6bf18be455 Revised the coding style in the title setter of the Account data model. 2023-02-06 11:42:22 +08:00
895bca2508 Fixed the documentation of the list_accounts view. 2023-02-06 11:07:18 +08:00
6af29e7df7 Updated the icon to create a new account in the account list. 2023-02-06 10:08:50 +08:00
50f8f06687 Revised the translation. 2023-02-06 09:50:03 +08:00
cd5b1b97fd Added a different the page title of the search result in the base account list and account list, to be clear. 2023-02-06 09:47:19 +08:00
b7dd53d2f9 Added a complex query to the test_malformed test of the QueryKeywordParserTestCase test case. 2023-02-04 14:54:32 +08:00
b07b0e3be4 Added a complex query to the test_default test of the QueryKeywordParserTestCase test case. 2023-02-04 14:53:18 +08:00
e7fb2288ce Revised the parse_query_keywords utility to handle the case with an open double quotation mark without its corresponding close double quotation mark. 2023-02-04 14:51:09 +08:00
17ba7659b6 Removed the CSRF token from the NextUriTestCase test case, for simplicity. 2023-02-04 14:38:25 +08:00
2c8d5e7c8a Revised the translation. 2023-02-04 13:27:04 +08:00
e2f707f696 Replaced gettext with pgettext in the Pagination utility. 2023-02-04 13:26:58 +08:00
b5c0d0b7b3 Added the pgettext function to the "accounting.locale" module. 2023-02-04 13:26:32 +08:00
7fe2bb6135 Removed an excess blank line from the "accounting.utils.pagination" module. 2023-02-04 12:57:38 +08:00
4d870f1dcc Added the page size to the public properties of the Pagination utility. It is used in the pagination template. 2023-02-04 12:55:30 +08:00
16b2eb1c93 Renamed the page_links and page_sizes properties to pages and page_size_options in the Pagination utility. 2023-02-04 12:51:30 +08:00
fd63149066 Revised the pagination utility to handle the empty data. better 2023-02-04 12:19:30 +08:00
a7a432914d Added the empty condition in the __get_page_sizes method of the Pagination utility. 2023-02-04 11:37:00 +08:00
1a44f08b90 Revised the empty condition in the __get_page_links method of the Pagination utility. 2023-02-04 11:36:42 +08:00
3e68cfe690 Removed incorrect documentation in the Pagination utility. 2023-02-04 11:31:09 +08:00
809f2b6df3 Changed the page number and page size properties to private in the Pagination utility. 2023-02-04 11:26:33 +08:00
c286aa8b8b Added the missing parameter in the __uri_set method of the Pagination utility. 2023-02-04 11:24:10 +08:00
1326d9538c Added the missing is_found = True in the __uri_set method of the Pagination utility. 2023-02-04 11:21:22 +08:00
b9cecf343a Added the generic type to the pagination utility in the PaginationTestCase test case. 2023-02-04 11:09:20 +08:00
3d9e6c10da Removed the invalid page number handler in the __set_list method of the Pagination utility. The invalid page numbers are handled and redirected in the __get_page_no method now. 2023-02-04 11:07:04 +08:00
5090e59bb1 Added to redirect when the page size is invalid in the Pagination utility. 2023-02-04 10:55:49 +08:00
62697fb782 Added the exception to the documentation of the constructor of the Pagination utility. 2023-02-04 10:51:07 +08:00
8c462e7b2c Replaced the messy __get_base_uri_params __uri_set_params methods with the unified __uri_set method in the Pagination utility. 2023-02-04 10:49:35 +08:00
90a8229db9 Revised the Pagination so that the page size and page number that are the same as the default values are redirected and removed, too. 2023-02-04 10:37:39 +08:00
8be44ccf5f Renamed the is_needed property to is_paged in the Pagination utility. 2023-02-04 10:26:28 +08:00
511328a0bd Renamed the PageLink class to Link in the "accounting.utils.pagination" module. 2023-02-04 10:18:22 +08:00
0d8cf85ec0 Removed an excess blank line in test_utils.py. 2023-02-04 09:51:19 +08:00
6e212f0e33 Revised the Pagination utility to handle the malformed and illegal page number and page size values. 2023-02-04 09:34:52 +08:00
2fbe137243 Added test_utils.py with the NextUriTestCase, QueryKeywordParserTestCase, and PaginationTestCase test cases for the independent utilities. 2023-02-04 08:12:24 +08:00
f4e2c21ece Added test_temp.py to .gitignore, for temporary tests that should not be committed. 2023-02-04 08:11:52 +08:00
fff07a2552 Removed node_models from .gitignore. 2023-02-03 23:06:28 +08:00
975b00bce9 Advanced to version 0.1.1. 2023-02-03 17:14:47 +08:00
d648538fbb Added onupdate="CASCADE" to the foreign keys. 2023-02-03 17:14:32 +08:00
dde9c38bb8 Fixed the primary key of the Account data model to be not auto-incrementing. 2023-02-03 13:32:19 +08:00
fecf33baa8 Updated the minimal python version to 3.11, as for the use of the typing.Self type hint. 2023-02-03 13:01:03 +08:00
cea2a44226 Added the order and sorting routes to the test_nobody, test_viewer, and test_editor tests of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
b5d87d2387 Revised to allow the viewers to view the account order page. 2023-02-03 12:57:53 +08:00
784e7bde49 Added the test_reorder test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
60280f415d Shortened the variable names in the test_change_base test of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
f32d268494 Revised the order and sorting routes from "/base/" to "/bases/". 2023-02-03 12:57:53 +08:00
1c1be87f3e Revised the accounting reordering to handle the cases with only one account or no account. 2023-02-03 12:57:53 +08:00
589da0c1c6 Renamed "sorting" to "reorder", and the "sort-form" route to "order". 2023-02-03 12:57:53 +08:00
8363ce6602 Fixed the endpoint name in the account detail template. 2023-02-03 12:57:53 +08:00
6a83f95c9f Added the test_change_base test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
7dc754174c Revised the documentation of the views. 2023-02-03 12:57:53 +08:00
5238168b2d Added support to sort the accounts under the same base account. 2023-02-03 12:57:53 +08:00
eeb05b8616 Removed the unique constraint in the Account data model. 2023-02-03 12:57:53 +08:00
9920377266 Added a missing semicolon in account-form.js. 2023-02-03 12:57:53 +08:00
9f9c40c30e Revised the code to find the next number in the populate_obj method of the AccountForm form. 2023-02-03 12:57:53 +08:00
d368c5e062 Renamed the variable in the new_id function from "new" to "obj_id", to be clear. 2023-02-03 12:57:53 +08:00
4aed2f6ba7 Renamed the "testsite" application to "test_site". 2023-02-03 12:57:53 +08:00
6876fdf75e Added the test_editor test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
d9624c7be6 Revised the AccountTestCase test case for simplicity. 2023-02-03 12:57:53 +08:00
8364025668 Split the BaseAccountTestCase into BaseAccountCommandTestCase and BaseAccountTestCase, and rewrote the BaseAccountTestCase for simplicity. 2023-02-03 12:57:53 +08:00
dd3690dd6a Added the AccountTestCase test case with the test_nobody and test_viewer tests. 2023-02-03 12:57:53 +08:00
3312c835fd Added the AccountCommandTestCase test case. 2023-02-03 12:57:53 +08:00
fce9d04896 Removed SQLALCHEMY_ECHO from the test site. 2023-02-03 12:57:53 +08:00
c68786f78a Revised the import in the test_init test of the BaseAccountTestCase test case. 2023-02-03 12:57:53 +08:00
581e803707 Moved the user utilities from the "accounting.database" module to the "accounting.utils.users" module, and simplified its use. 2023-02-03 12:57:53 +08:00
63 changed files with 4029 additions and 432 deletions

2
.gitignore vendored
View File

@ -37,4 +37,4 @@ excludes
*.pot
*.mo
zh_Hans
node_modules
test_temp.py

View File

@ -0,0 +1,53 @@
accounting.account package
==========================
Submodules
----------
accounting.account.commands module
----------------------------------
.. automodule:: accounting.account.commands
:members:
:undoc-members:
:show-inheritance:
accounting.account.converters module
------------------------------------
.. automodule:: accounting.account.converters
:members:
:undoc-members:
:show-inheritance:
accounting.account.forms module
-------------------------------
.. automodule:: accounting.account.forms
:members:
:undoc-members:
:show-inheritance:
accounting.account.query module
-------------------------------
.. automodule:: accounting.account.query
:members:
:undoc-members:
:show-inheritance:
accounting.account.views module
-------------------------------
.. automodule:: accounting.account.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.account
:members:
:undoc-members:
:show-inheritance:

View File

@ -12,18 +12,10 @@ accounting.base\_account.commands module
:undoc-members:
:show-inheritance:
accounting.base\_account.database module
----------------------------------------
accounting.base\_account.converters module
------------------------------------------
.. automodule:: accounting.base_account.database
:members:
:undoc-members:
:show-inheritance:
accounting.base\_account.models module
--------------------------------------
.. automodule:: accounting.base_account.models
.. automodule:: accounting.base_account.converters
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,53 @@
accounting.currency package
===========================
Submodules
----------
accounting.currency.commands module
-----------------------------------
.. automodule:: accounting.currency.commands
:members:
:undoc-members:
:show-inheritance:
accounting.currency.converters module
-------------------------------------
.. automodule:: accounting.currency.converters
:members:
:undoc-members:
:show-inheritance:
accounting.currency.forms module
--------------------------------
.. automodule:: accounting.currency.forms
:members:
:undoc-members:
:show-inheritance:
accounting.currency.query module
--------------------------------
.. automodule:: accounting.currency.query
:members:
:undoc-members:
:show-inheritance:
accounting.currency.views module
--------------------------------
.. automodule:: accounting.currency.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.currency
:members:
:undoc-members:
:show-inheritance:

View File

@ -7,12 +7,22 @@ Subpackages
.. toctree::
:maxdepth: 4
accounting.account
accounting.base_account
accounting.currency
accounting.utils
Submodules
----------
accounting.database module
--------------------------
.. automodule:: accounting.database
:members:
:undoc-members:
:show-inheritance:
accounting.locale module
------------------------
@ -21,6 +31,14 @@ accounting.locale module
:undoc-members:
:show-inheritance:
accounting.models module
------------------------
.. automodule:: accounting.models
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -4,6 +4,14 @@ accounting.utils package
Submodules
----------
accounting.utils.next\_url module
---------------------------------
.. automodule:: accounting.utils.next_url
:members:
:undoc-members:
:show-inheritance:
accounting.utils.pagination module
----------------------------------
@ -28,6 +36,30 @@ accounting.utils.query module
:undoc-members:
:show-inheritance:
accounting.utils.random\_id module
----------------------------------
.. automodule:: accounting.utils.random_id
:members:
:undoc-members:
:show-inheritance:
accounting.utils.strip\_text module
-----------------------------------
.. automodule:: accounting.utils.strip_text
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------
.. automodule:: accounting.utils.user
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.1.0
version = 0.2.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.
@ -36,7 +36,7 @@ classifiers =
[options]
package_dir =
= src
python_requires = >=3.10
python_requires = >=3.11
install_requires =
flask
Flask-SQLAlchemy

View File

@ -18,55 +18,10 @@
"""
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask import Flask, Blueprint
from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model)
class AbstractUserUtils(t.Generic[T], ABC):
"""The abstract user utilities."""
@property
@abstractmethod
def cls(self) -> t.Type[T]:
"""Returns the user class.
:return: The user class.
"""
@property
@abstractmethod
def pk_column(self) -> sa.Column:
"""Returns the primary key column.
:return: The primary key column.
"""
@property
@abstractmethod
def current_user(self) -> T:
"""Returns the current user.
:return: The current user.
"""
@abstractmethod
def get_by_username(self, username: str) -> T | None:
"""Returns the user by her username.
:return: The user by her username, or None if the user was not found.
"""
@abstractmethod
def get_pk(self, user: T) -> int:
"""Returns the primary key of the user.
:return: The primary key of the user.
"""
from accounting.utils.user import AbstractUserUtils
def init_app(app: Flask, user_utils: AbstractUserUtils,
@ -87,7 +42,9 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
# The database instance must be set before loading everything
# in the application.
from .database import set_db
set_db(app.extensions["sqlalchemy"], user_utils)
set_db(app.extensions["sqlalchemy"])
from .utils.user import init_user_utils
init_user_utils(user_utils)
bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix,
@ -98,7 +55,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
locale.init_app(app, bp)
from .utils import permission
permission.init_app(app, can_view_func, can_edit_func)
permission.init_app(bp, can_view_func, can_edit_func)
from . import base_account
base_account.init_app(app, bp)
@ -106,9 +63,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import account
account.init_app(app, bp)
from .utils.next_url import append_next, inherit_next, or_next
bp.add_app_template_filter(append_next, "append_next")
bp.add_app_template_filter(inherit_next, "inherit_next")
bp.add_app_template_filter(or_next, "or_next")
from . import currency
currency.init_app(app, bp)
from .utils import next_url
next_url.init_app(bp)
app.register_blueprint(bp)

View File

@ -23,8 +23,8 @@ from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import AccountConverter

View File

@ -24,8 +24,9 @@ from secrets import randbelow
import click
from flask.cli import with_appcontext
from accounting.database import db, user_utils
from accounting.database import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import has_user, get_user_pk
AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number,
@ -45,8 +46,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
user: user_utils.cls | None = user_utils.get_by_username(value)
if user is None:
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@ -58,7 +58,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
@with_appcontext
def init_accounts_command(username: str) -> None:
"""Initializes the accounts."""
creator_pk: int = user_utils.get_pk(user_utils.get_by_username(username))
creator_pk: int = get_user_pk(username)
bases: list[BaseAccount] = BaseAccount.query\
.filter(db.func.length(BaseAccount.code) == 4)\

View File

@ -18,19 +18,21 @@
"""
import sqlalchemy as sa
from flask import request
from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField
from wtforms.validators import DataRequired, ValidationError
from accounting.database import db, user_utils
from accounting.database import db
from accounting.locale import lazy_gettext
from accounting.models import BaseAccount, Account
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class BaseAccountExists:
"""The validator to check if the base account code exists."""
"""The validator to check if the base account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "":
@ -40,13 +42,25 @@ class BaseAccountExists:
"The base account does not exist."))
class BaseAccountAvailable:
"""The validator to check if the base account is available."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "":
return
if len(field.data) != 4:
raise ValidationError(lazy_gettext(
"The base account is not available."))
class AccountForm(FlaskForm):
"""The form to create or edit an account."""
base_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext("Please select the base account.")),
BaseAccountExists()])
BaseAccountExists(),
BaseAccountAvailable()])
"""The code of the base account."""
title = StringField(
filters=[strip_text],
@ -67,14 +81,14 @@ class AccountForm(FlaskForm):
obj.id = new_id(Account)
obj.base_code = self.base_code.data
if prev_base_code != self.base_code.data:
last_same_base: Account = Account.query\
.filter(Account.base_code == self.base_code.data)\
.order_by(Account.base_code.desc()).first()
obj.no = 1 if last_same_base is None else last_same_base.no + 1
max_no: int = db.session.scalars(
sa.select(sa.func.max(Account.no))
.filter(Account.base_code == self.base_code.data)).one()
obj.no = 1 if max_no is None else max_no + 1
obj.title = self.title.data
obj.is_offset_needed = self.is_offset_needed.data
if is_new:
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
if prev_base_code is not None \
@ -87,7 +101,7 @@ class AccountForm(FlaskForm):
:return: None
"""
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
current_user_pk: int = get_current_user_pk()
obj.updated_by_id = current_user_pk
obj.updated_at = sa.func.now()
if hasattr(self, "__post_update"):
@ -127,3 +141,48 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
for i in range(len(accounts)):
if accounts[i].no != i + 1:
accounts[i].no = i + 1
class AccountReorderForm:
"""The form to reorder the accounts."""
def __init__(self, base: BaseAccount):
"""Constructs the form to reorder the accounts under a base account.
:param base: The base account.
"""
self.base: BaseAccount = base
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
accounts: list[Account] = self.base.accounts
# Collects the specified order.
orders: dict[Account, int] = {}
for account in accounts:
if f"{account.id}-no" in request.form:
try:
orders[account] = int(request.form[f"{account.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Account] = [x for x in accounts if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for account in missing:
orders[account] = next_no
# Sort by the specified order first, and their original order.
accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(accounts)):
if accounts[i].no != i + 1:
accounts[i].no = i + 1
self.is_modified = True

View File

@ -29,7 +29,7 @@ from accounting.models import Account, BaseAccount
from accounting.utils.next_url import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit
from .forms import AccountForm, sort_accounts_in
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management."""
@ -38,7 +38,7 @@ bp: Blueprint = Blueprint("account", __name__)
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_accounts() -> str:
"""Lists the base accounts.
"""Lists the accounts.
:return: The account list.
"""
@ -95,7 +95,8 @@ def add_account() -> redirect:
def show_account_detail(account: Account) -> str:
"""Shows the account detail.
:return: The account detail.
:param account: The account.
:return: The detail.
"""
return render_template("accounting/account/detail.html", obj=account)
@ -105,7 +106,8 @@ def show_account_detail(account: Account) -> str:
def show_account_edit_form(account: Account) -> str:
"""Shows the form to edit an account.
:return: The form to edit an account.
:param account: The account.
:return: The form to edit the account.
"""
form: AccountForm
if "form" in session:
@ -123,6 +125,7 @@ def show_account_edit_form(account: Account) -> str:
def update_account(account: Account) -> redirect:
"""Updates an account.
:param account: The account.
:return: The redirection to the account detail on success, or the account
edit form on error.
"""
@ -136,7 +139,7 @@ def update_account(account: Account) -> redirect:
account=account)))
with db.session.no_autoflush:
form.populate_obj(account)
if not db.session.is_modified(account):
if not account.is_modified:
flash(lazy_gettext("The account was not modified."), "success")
return redirect(inherit_next(url_for("accounting.account.detail",
account=account)))
@ -152,13 +155,42 @@ def update_account(account: Account) -> redirect:
def delete_account(account: Account) -> redirect:
"""Deletes an account.
:param account: The account.
:return: The redirection to the account list on success, or the account
detail on error.
"""
for l10n in account.l10n:
db.session.delete(l10n)
db.session.delete(account)
account.delete()
sort_accounts_in(account.base_code, account.id)
db.session.commit()
flash(lazy_gettext("The account is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.account.list")))
@bp.get("/bases/<baseAccount:base>", endpoint="order")
@has_permission(can_view)
def show_account_order(base: BaseAccount) -> str:
"""Shows the order of the accounts under a same base account.
:param base: The base account.
:return: The order of the accounts under the base account.
"""
return render_template("accounting/account/order.html", base=base)
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect:
"""Reorders the accounts under a base account.
:param base: The base account.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: AccountReorderForm = AccountReorderForm(base)
form.save_order()
if not form.is_modified:
flash(lazy_gettext("The order was not modified."), "success")
return redirect(or_next(url_for("accounting.account.list")))
db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success")
return redirect(or_next(url_for("accounting.account.list")))

View File

@ -23,8 +23,8 @@ from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import BaseAccountConverter

View File

@ -46,7 +46,8 @@ def list_accounts() -> str:
def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail.
:return: The account detail.
:param account: The account.
:return: The detail.
"""
return render_template("accounting/base-account/detail.html", obj=account)

View File

@ -0,0 +1,38 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 currency management.
"""
from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .converters import CurrencyConverter
app.url_map.converters["currency"] = CurrencyConverter
from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -0,0 +1,78 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 console commands for the currency management.
"""
import os
import click
from flask.cli import with_appcontext
from accounting.database import db
from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import has_user, get_user_pk
CurrencyData = tuple[str, str, str, str]
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None:
"""Initializes the currencies."""
data: list[CurrencyData] = [
("TWD", "New Taiwan dollar", "新臺幣", "新台币"),
("USD", "United States dollar", "美元", "美元"),
]
creator_pk: int = get_user_pk(username)
existing: list[Currency] = Currency.query.all()
existing_code: set[str] = {x.code for x in existing}
to_add: list[CurrencyData] = [x for x in data if x[0] not in existing_code]
if len(to_add) == 0:
click.echo("No more currency to add.")
return
db.session.bulk_save_objects(
[Currency(code=x[0], name_l10n=x[1],
created_by_id=creator_pk, updated_by_id=creator_pk)
for x in data])
db.session.bulk_save_objects(
[CurrencyL10n(currency_code=x[0], locale=y[0], name=y[1])
for x in data for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")

View File

@ -0,0 +1,48 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 path converters for the currency management.
"""
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.database import db
from accounting.models import Currency
class CurrencyConverter(BaseConverter):
"""The currency converter to convert the currency code and to the
corresponding currency in the routes."""
def to_python(self, value: str) -> Currency:
"""Converts a currency code to a currency.
:param value: The currency code.
:return: The corresponding currency.
"""
currency: Currency | None = db.session.get(Currency, value)
if currency is None:
abort(404)
return currency
def to_url(self, value: Currency) -> str:
"""Converts a currency to its code.
:param value: The currency.
:return: The code.
"""
return value.code

View File

@ -0,0 +1,93 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 forms for the currency management.
"""
from __future__ import annotations
import sqlalchemy as sa
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf
from accounting.database import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
class CodeUnique:
"""The validator to check if the code is unique."""
def __call__(self, form: CurrencyForm, field: StringField) -> None:
if field.data == "":
return
if form.obj_code is not None and form.obj_code == field.data:
return
if db.session.get(Currency, field.data) is not None:
raise ValidationError(lazy_gettext(
"Code conflicts with another currency."))
code = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
Regexp(r"^[A-Z]{3}$",
message=lazy_gettext(
"Code can only be composed of 3 upper-cased"
" letters.")),
NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
"This code is not available.")),
CodeUnique()])
"""The code. It may not conflict with another currency."""
name = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
"""The name."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.obj_code: str | None = None
"""The current code of the currency, or None when adding a new
currency."""
def populate_obj(self, obj: Currency) -> None:
"""Populates the form data into a currency object.
:param obj: The currency object.
:return: None.
"""
is_new: bool = obj.code is None
obj.code = self.code.data
obj.name = self.name.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
def post_update(self, obj) -> None:
"""The post-processing after the update.
:return: None
"""
current_user_pk: int = get_current_user_pk()
obj.updated_by_id = current_user_pk
obj.updated_at = sa.func.now()

View File

@ -0,0 +1,44 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 currency query.
"""
import sqlalchemy as sa
from flask import request
from accounting.models import Currency, CurrencyL10n
from accounting.utils.query import parse_query_keywords
def get_currency_query() -> list[Currency]:
"""Returns the base accounts, optionally filtered by the query.
:return: The base accounts.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Currency.query.order_by(Currency.code).all()
conditions: list[sa.BinaryExpression] = []
for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.contains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\
.order_by(Currency.code).all()

View File

@ -0,0 +1,178 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# 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 views for the currency management.
"""
from urllib.parse import urlencode, parse_qsl
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting.database import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.next_url import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from .forms import CurrencyForm
bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management."""
api_bp: Blueprint = Blueprint("currency-api", __name__)
"""The view blueprint for the currency management API."""
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_currencies() -> str:
"""Lists the currencies.
:return: The currency list.
"""
from .query import get_currency_query
currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html",
list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create")
@has_permission(can_edit)
def show_add_currency_form() -> str:
"""Shows the form to add a currency.
:return: The form to add a currency.
"""
if "form" in session:
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = CurrencyForm()
return render_template("accounting/currency/create.html",
form=form)
@bp.post("/store", endpoint="store")
@has_permission(can_edit)
def add_currency() -> redirect:
"""Adds a currency.
:return: The redirection to the currency detail on success, or the currency
creation form on error.
"""
form = CurrencyForm(request.form)
if not form.validate():
for key in form.errors:
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.create")))
currency: Currency = Currency()
form.populate_obj(currency)
db.session.add(currency)
db.session.commit()
flash(lazy_gettext("The currency is added successfully"), "success")
return redirect(inherit_next(url_for("accounting.currency.detail",
currency=currency)))
@bp.get("/<currency:currency>", endpoint="detail")
@has_permission(can_view)
def show_currency_detail(currency: Currency) -> str:
"""Shows the currency detail.
:param currency: The currency.
:return: The detail.
"""
return render_template("accounting/currency/detail.html", obj=currency)
@bp.get("/<currency:currency>/edit", endpoint="edit")
@has_permission(can_edit)
def show_currency_edit_form(currency: Currency) -> str:
"""Shows the form to edit a currency.
:param currency: The currency.
:return: The form to edit the currency.
"""
form: CurrencyForm
if "form" in session:
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = CurrencyForm(obj=currency)
return render_template("accounting/currency/edit.html",
currency=currency, form=form)
@bp.post("/<currency:currency>/update", endpoint="update")
@has_permission(can_edit)
def update_currency(currency: Currency) -> redirect:
"""Updates a currency.
:param currency: The currency.
:return: The redirection to the currency detail on success, or the currency
edit form on error.
"""
form = CurrencyForm(request.form)
form.obj_code = currency.code
if not form.validate():
for key in form.errors:
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.edit",
currency=currency)))
with db.session.no_autoflush:
form.populate_obj(currency)
if not currency.is_modified:
flash(lazy_gettext("The currency was not modified."), "success")
return redirect(inherit_next(url_for("accounting.currency.detail",
currency=currency)))
form.post_update(currency)
db.session.commit()
flash(lazy_gettext("The currency is updated successfully."), "success")
return redirect(inherit_next(url_for("accounting.currency.detail",
currency=currency)))
@bp.post("/<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect:
"""Deletes a currency.
:param currency: The currency.
:return: The redirection to the currency list on success, or the currency
detail on error.
"""
currency.delete()
db.session.commit()
flash(lazy_gettext("The currency is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.currency.list")))
@api_bp.get("/exists-code", endpoint="exists")
@has_permission(can_edit)
def exists_code() -> dict[str, bool]:
"""Validates whether a currency code exists.
:return: Whether the currency code exists.
"""
return {"exists": db.session.get(Currency, request.args["q"]) is not None}

View File

@ -25,21 +25,15 @@ time.
from flask_sqlalchemy import SQLAlchemy
from accounting import AbstractUserUtils
db: SQLAlchemy
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""
user_utils: AbstractUserUtils
"""The user utilities."""
def set_db(new_db: SQLAlchemy, new_user_utils: AbstractUserUtils) -> None:
def set_db(new_db: SQLAlchemy) -> None:
"""Sets the database instance.
:param new_db: The database instance.
:param new_user_utils: The user utilities.
:return: None.
"""
global db, user_utils
global db
db = new_db
user_utils = new_user_utils

View File

@ -39,6 +39,17 @@ def gettext(string, **variables) -> str:
return domain.gettext(string, **variables)
def pgettext(context, string, **variables) -> str:
"""A replacement of the Babel gettext() function..
:param context: The context.
:param string: The message to translate.
:param variables: The variable substitution.
:return: The translated message.
"""
return domain.pgettext(context, string, **variables)
def lazy_gettext(string, **variables) -> LazyString:
"""A replacement of the Babel lazy_gettext() function..
@ -105,8 +116,8 @@ def __babel_js_catalog_view() -> Response:
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initializes the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
bp.add_url_rule("/_jstrans.js", "babel_catalog",

View File

@ -25,10 +25,8 @@ from flask import current_app
from flask_babel import get_locale
from sqlalchemy import text
from accounting.database import db, user_utils
user_cls: db.Model = user_utils.cls
user_pk_column: db.Column = user_utils.pk_column
from accounting.database import db
from accounting.utils.user import user_cls, user_pk_column
class BaseAccount(db.Model):
@ -66,13 +64,23 @@ class BaseAccount(db.Model):
return l10n.title
return self.title_l10n
@property
def query_values(self) -> list[str]:
"""Returns the values to be queried.
:return: The values to be queried.
"""
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
class BaseAccountL10n(db.Model):
"""A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n"
"""The table name."""
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
ondelete="CASCADE"),
account_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code,
onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The code of the account."""
account = db.relationship(BaseAccount, back_populates="l10n")
@ -87,10 +95,12 @@ class Account(db.Model):
"""An account."""
__tablename__ = "accounting_accounts"
"""The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True)
id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The account ID."""
base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
ondelete="CASCADE"),
base_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False)
"""The code of the base account."""
base = db.relationship(BaseAccount, back_populates="accounts")
@ -104,7 +114,9 @@ class Account(db.Model):
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
@ -112,7 +124,9 @@ class Account(db.Model):
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
@ -120,7 +134,6 @@ class Account(db.Model):
l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False)
"""The localized titles."""
db.UniqueConstraint(base_code, no)
__CASH = "1111-001"
"""The code of the cash account,"""
@ -182,16 +195,14 @@ class Account(db.Model):
if l10n.locale == current_locale:
l10n.title = value
return
self.l10n.append(AccountL10n(
locale=current_locale, title=value))
self.l10n.append(AccountL10n(locale=current_locale, title=value))
@classmethod
def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an accounting account by its code.
"""Finds an account by its code.
:param code: The code.
:return: The accounting account, or None if this account does not
exist.
:return: The account, or None if this account does not exist.
"""
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
if m is None:
@ -288,8 +299,21 @@ class Account(db.Model):
"""
return cls.find_by_code(cls.__NET_CHANGE)
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes this accounting account.
"""Deletes this account.
:return: None.
"""
@ -301,10 +325,128 @@ class Account(db.Model):
class AccountL10n(db.Model):
"""A localized account title."""
__tablename__ = "accounting_accounts_l10n"
account_id = db.Column(db.Integer, db.ForeignKey(Account.id,
ondelete="CASCADE"),
"""The table name."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The account ID."""
account = db.relationship(Account, back_populates="l10n")
"""The account."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
title = db.Column(db.String, nullable=False)
db.UniqueConstraint(account_id, locale)
"""The localized title."""
class Currency(db.Model):
"""A currency."""
__tablename__ = "accounting_currencies"
"""The table name."""
code = db.Column(db.String, nullable=False, primary_key=True)
"""The code."""
name_l10n = db.Column("name", db.String, nullable=False)
"""The name."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False)
"""The localized names."""
def __str__(self) -> str:
"""Returns the string representation of the currency.
:return: The string representation of the currency.
"""
return F"{self.name} ({self.code})"
@property
def name(self) -> str:
"""Returns the name in the current locale.
:return: The name in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
return self.name_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
return l10n.name
return self.name_l10n
@name.setter
def name(self, value: str) -> None:
"""Sets the name in the current locale.
:param value: The new name.
:return: None.
"""
if self.name_l10n is None:
self.name_l10n = value
return
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
self.name_l10n = value
return
for l10n in self.l10n:
if l10n.locale == current_locale:
l10n.name = value
return
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes the currency.
:return: None.
"""
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.code == self.code).delete()
class CurrencyL10n(db.Model):
"""A localized currency name."""
__tablename__ = "accounting_currencies_l10n"
"""The table name."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The currency code."""
currency = db.relationship(Currency, back_populates="l10n")
"""The currency."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
name = db.Column(db.String, nullable=False)
"""The localized name."""

View File

@ -21,48 +21,50 @@
* First written: 2023/2/1
*/
.clickable {
.accounting-clickable {
cursor: pointer;
}
.btn-group .btn .search-input {
.btn-group .btn .accounting-search-input {
min-height: calc(1em + .5rem + 2px);
padding: 0 0.5rem;
}
.btn-group .btn .search-label button {
.btn-group .btn .accounting-search-label button {
border: none;
background-color: transparent;
color: inherit;
padding-right: 0;
}
/** The account management */
.account {
/** The card layout */
.accounting-card {
padding: 2em 1.5em;
margin: 1em;
background-color: #E9ECEF;
border-radius: 0.3em;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.account .account-title {
.accounting-card-title {
font-size: 1.8rem;
font-weight: bolder;
}
.account .account-code {
.accounting-card-code {
font-size: 1.4rem;
color: #373b3e;
}
.list-base-selector {
/** The option selector */
.accounting-selector-list {
height: 20rem;
overflow-y: scroll;
}
/* The Material Design text field (floating form control in Bootstrap) */
.material-text-field {
.accounting-material-text-field {
position: relative;
min-height: calc(3.5rem + 2px);
padding-top: 1.625rem;
}
.material-text-field > .form-label {
.accounting-material-text-field > .form-label {
position: absolute;
top: 0;
left: 0;
@ -71,27 +73,27 @@
transform-origin: 0 0;
transition: opacity .1s ease-in-out,transform .1s ease-in-out;
}
.material-text-field.not-empty > .form-label {
.accounting-material-text-field.accounting-not-empty > .form-label {
opacity: 0.65;
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
}
/* The Material Design floating action buttons */
.material-fab {
.accounting-material-fab {
position: fixed;
right: 2rem;
bottom: 1rem;
z-index: 10;
flex-direction: column-reverse;
}
.material-fab .btn {
.accounting-material-fab .btn {
border-radius: 50%;
transform: scale(1.5);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
display: block;
margin-top: 2.5rem;
}
.material-fab .btn:hover, .material-fab .btn:focus {
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
}

View File

@ -23,12 +23,12 @@
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeBaseAccountSelector()
document.getElementById("account-base-code")
initializeBaseAccountSelector();
document.getElementById("accounting-base-code")
.onchange = validateBase;
document.getElementById("account-title")
document.getElementById("accounting-title")
.onchange = validateTitle;
document.getElementById("account-form")
document.getElementById("accounting-form")
.onsubmit = validateForm;
});
@ -38,25 +38,25 @@ document.addEventListener("DOMContentLoaded", function () {
* @private
*/
function initializeBaseAccountSelector() {
const selector = document.getElementById("select-base-modal");
const base = document.getElementById("account-base");
const baseCode = document.getElementById("account-base-code");
const baseContent = document.getElementById("account-base-content");
const options = Array.from(document.getElementsByClassName("list-group-item-base"));
const btnClear = document.getElementById("btn-clear-base");
const selector = document.getElementById("accounting-base-selector-model");
const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("accounting-base-content");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () {
base.classList.add("not-empty");
base.classList.add("accounting-not-empty");
options.forEach(function (item) {
item.classList.remove("active");
});
const selected = document.getElementById("list-group-item-base-" + baseCode.value);
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
if (selected !== null) {
selected.classList.add("active");
}
});
selector.addEventListener("hidden.bs.modal", function () {
if (baseCode.value === "") {
base.classList.remove("not-empty");
base.classList.remove("accounting-not-empty");
}
});
options.forEach(function (option) {
@ -79,6 +79,54 @@ function initializeBaseAccountSelector() {
validateBase();
bootstrap.Modal.getInstance(selector).hide();
}
initializeBaseAccountQuery();
}
/**
* Initializes the query on the base account options.
*
* @private
*/
function initializeBaseAccountQuery() {
const query = document.getElementById("accounting-base-selector-query");
const optionList = document.getElementById("accounting-base-option-list");
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const queryNoResult = document.getElementById("accounting-base-option-no-result");
query.addEventListener("input", function () {
console.log(query.value);
if (query.value === "") {
options.forEach(function (option) {
option.classList.remove("d-none");
});
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
return
}
let hasAnyMatched = false;
options.forEach(function (option) {
const queryValues = JSON.parse(option.dataset.queryValues);
let isMatched = false;
for (let i = 0; i < queryValues.length; i++) {
if (queryValues[i].includes(query.value)) {
isMatched = true;
break;
}
}
if (isMatched) {
option.classList.remove("d-none");
hasAnyMatched = true;
} else {
option.classList.add("d-none");
}
});
if (!hasAnyMatched) {
optionList.classList.add("d-none");
queryNoResult.classList.remove("d-none");
} else {
optionList.classList.remove("d-none");
queryNoResult.classList.add("d-none");
}
});
}
/**
@ -101,9 +149,9 @@ function validateForm() {
* @private
*/
function validateBase() {
const field = document.getElementById("account-base-code");
const error = document.getElementById("account-base-code-error");
const displayField = document.getElementById("account-base");
const field = document.getElementById("accounting-base-code");
const error = document.getElementById("accounting-base-code-error");
const displayField = document.getElementById("accounting-base");
field.value = field.value.trim();
if (field.value === "") {
displayField.classList.add("is-invalid");
@ -122,8 +170,8 @@ function validateBase() {
* @private
*/
function validateTitle() {
const field = document.getElementById("account-title");
const error = document.getElementById("account-title-error");
const field = document.getElementById("accounting-title");
const error = document.getElementById("accounting-title-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");

View File

@ -0,0 +1,39 @@
/* The Mia! Accounting Flask Project
* account-order.js: The JavaScript for the account order
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/2
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
const list = document.getElementById("accounting-order-list");
if (list !== null) {
const onReorder = function () {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
no.value = String(i + 1);
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
}
};
initializeDragAndDropReordering(list, onReorder);
}
});

View File

@ -0,0 +1,174 @@
/* The Mia! Accounting Flask Project
* currency-form.js: The JavaScript for the currency form
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/6
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.getElementById("accounting-code")
.onchange = validateCode;
document.getElementById("accounting-name")
.onchange = validateName;
document.getElementById("accounting-form")
.onsubmit = validateForm;
});
/**
* The asynchronous validation result
* @type {object}
* @private
*/
let isAsyncValid = {};
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
Object.keys(isAsyncValid).forEach(function (key) {
isValid = isAsyncValid[key] && isValid;
});
if (isValid) {
document.getElementById("accounting-form").submit()
}
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
return false;
}
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
return true;
}
/**
* Validates asynchronously whether the code is duplicated.
* The boolean validation result is stored in isAsyncValid[key].
*
* @param isSubmission {boolean} whether this is invoked from a form submission
* @param key {string} the key to store the result in isAsyncValid
* @private
*/
function validateAsyncCodeIsDuplicated(isSubmission, key) {
const field = document.getElementById("accounting-code");
const error = document.getElementById("accounting-code-error");
const url = field.dataset.existsUrl;
const onLoad = function () {
if (this.status === 200) {
const result = JSON.parse(this.responseText);
if (result["exists"]) {
field.classList.add("is-invalid");
error.innerText = A_("Code conflicts with another currency.");
if (isSubmission) {
isAsyncValid[key] = false;
}
return;
}
field.classList.remove("is-invalid");
error.innerText = "";
if (isSubmission) {
isAsyncValid[key] = true;
submitFormIfAllAsyncValid();
}
}
};
const request = new XMLHttpRequest();
request.onload = onLoad;
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
request.send();
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateName() {
const field = document.getElementById("accounting-name");
const error = document.getElementById("accounting-name-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -0,0 +1,108 @@
/* The Mia! Accounting Flask Project
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/3
*/
/**
* Initializes the drag-and-drop reordering on a list.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
*/
function initializeDragAndDropReordering(list, onReorder) {
initializeMouseDragAndDropReordering(list, onReorder);
initializeTouchDragAndDropReordering(list, onReorder);
}
/**
* Initializes the drag-and-drop reordering with mouse.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
* @private
*/
function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
let dragged = null;
items.forEach(function (item) {
item.draggable = true;
item.addEventListener("dragstart", function () {
dragged = item;
dragged.classList.add("list-group-item-dark");
});
item.addEventListener("dragover", function () {
onDragOver(dragged, item);
onReorder();
});
item.addEventListener("dragend", function () {
dragged.classList.remove("list-group-item-dark");
dragged = null;
});
});
}
/**
* Initializes the drag-and-drop reordering with touch devices.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
* @private
*/
function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
items.forEach(function (item) {
item.addEventListener("touchstart", function () {
item.classList.add("list-group-item-dark");
});
item.addEventListener("touchmove", function (event) {
const touch = event.targetTouches[0];
const target = document.elementFromPoint(touch.pageX, touch.pageY);
onDragOver(item, target);
onReorder();
});
item.addEventListener("touchend", function () {
item.classList.remove("list-group-item-dark");
});
});
}
/**
* Handles when an item is dragged over the other item.
*
* @param dragged {Element} the item that was dragged
* @param target {Element} the other item that was dragged over
*/
function onDragOver(dragged, target) {
if (target.parentElement !== dragged.parentElement || target === dragged) {
return;
}
let isBefore = false;
for (let p = target; p !== null; p = p.previousSibling) {
if (p === dragged) {
isBefore = true;
}
}
if (isBefore) {
target.parentElement.insertBefore(dragged, target.nextSibling);
} else {
target.parentElement.insertBefore(dragged, target);
}
}

View File

@ -26,41 +26,47 @@ First written: 2023/1/31
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if can_edit_accounting() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
</a>
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if can_edit_accounting() %}
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if can_edit_accounting() %}
<form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
<h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
@ -76,9 +82,9 @@ First written: 2023/1/31
</form>
{% endif %}
<div class="account col-sm-6">
<div class="account-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div>
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_offset_needed %}
<div>
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>

View File

@ -23,6 +23,6 @@ First written: 2023/2/1
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|inherit_next }}{% endblock %}
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}

View File

@ -34,16 +34,16 @@ First written: 2023/2/1
</a>
</div>
<form id="account-form" action="{% block action_url %}{% endblock %}" method="post">
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="account-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
<div id="account-base" class="form-control clickable material-text-field {% if form.base_code.data %} not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#select-base-modal">
<label id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label>
<div id="account-base-content">
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-model">
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
<div id="accounting-base-content">
{% if form.base_code.data %}
{% if form.base_code.errors %}
{{ A_("(Unknown)") }}
@ -53,18 +53,18 @@ First written: 2023/2/1
{% endif %}
</div>
</div>
<div id="account-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
<label class="form-label" for="account-title">{{ A_("Title") }}</label>
<div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div>
<div class="form-check form-switch mb-3">
<input id="account-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="account-is-offset-needed">
<input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="accounting-is-offset-needed">
{{ A_("The entries in the account need offsets.") }}
</label>
</div>
@ -76,43 +76,44 @@ First written: 2023/2/1
</button>
</div>
<div class="d-md-none material-fab">
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
<div class="modal fade" id="select-base-modal" tabindex="-1" aria-labelledby="select-base-model-label" aria-hidden="true">
<div class="modal fade" id="accounting-base-selector-model" tabindex="-1" aria-labelledby="accounting-base-selector-model-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="base-selector-model-label">{{ A_("Select Base Account") }}</h1>
<h1 class="modal-title fs-5" id="accounting-base-selector-model-label">{{ A_("Select Base Account") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="select-base-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
<label class="input-group-text" for="select-base-query">
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
<label class="input-group-text" for="accounting-base-selector-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul class="list-group list-base-selector">
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
{% for base in form.base_options %}
<li id="list-group-item-base-{{ base.code }}" class="list-group-item list-group-item-base clickable" data-code="{{ base.code }}" data-content="{{ base }}">
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
{{ base }}
</li>
{% endfor %}
</ul>
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
{% if form.base_code.data %}
<button id="btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
{% else %}
<button id="btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
{% endif %}
</div>
</div>

View File

@ -21,20 +21,20 @@ First written: 2023/1/30
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Account Management") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
{% if can_edit_accounting() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
<i class="fa-solid fa-user-plus"></i>
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="search-input" class="search-label">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="accounting-search" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
@ -43,9 +43,9 @@ First written: 2023/1/30
</form>
</div>
{% if can_edit_accounting() %}
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}">
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>
@ -56,7 +56,7 @@ First written: 2023/1/30
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|append_next }}">
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
{{ item }}
{% if item.is_offset_needed %}
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>

View File

@ -0,0 +1,84 @@
{#
The Mia! Accounting Flask Project
order.html: The order of the accounts under a same base account
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/2
#}
{% extends "accounting/account/include/form.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("The Accounts of %(base)s", base=base) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
{% if base.accounts|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
{% for account in base.accounts|sort(attribute="no") %}
<li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}">
<input id="accounting-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
<div>
<span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
{{ account.title }}
</div>
<i class="fa-solid fa-bars"></i>
</li>
{% endfor %}
</ul>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% elif base.accounts %}
<ul class="list-group mb-3">
{% for account in base.accounts|sort(attribute="no") %}
<li class="list-group-item">
{{ account }}
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -26,19 +26,19 @@ First written: 2023/2/1
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<div class="account col-sm-6">
<div class="account-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div>
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %}
<div>
{% for account in obj.accounts %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|append_next }}">
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
{{ account }}
</a>
{% endfor %}

View File

@ -21,14 +21,14 @@ First written: 2023/1/26
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Base Account Managements") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="search-input" class="search-label">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="accounting-search" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
@ -42,7 +42,7 @@ First written: 2023/1/26
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|append_next }}">
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}">
{{ item }}
</a>
{% endfor %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The currency creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/currency/include/form.html" %}
{% 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 action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}

View File

@ -0,0 +1,90 @@
{#
The Mia! Accounting Flask Project
detail.html: The currency detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
{% endif %}
{% if accounting_can_edit() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if accounting_can_edit() %}
<form action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Currency Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this currency?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.name }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{#
The Mia! Accounting Flask Project
edit.html: The currency edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/currency/include/form.html" %}
{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|accounting_inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %}
{% block original_code %}{{ currency.code }}{% endblock %}

View File

@ -0,0 +1,68 @@
{#
The Mia! Accounting Flask Project
form.html: The currency form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group btn-actions mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="accounting-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
<label class="form-label" for="accounting-code">{{ A_("Code") }}</label>
<div id="accounting-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="accounting-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
<label class="form-label" for="accounting-name">{{ A_("Name") }}</label>
<div id="accounting-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,68 @@
{#
The Mia! Accounting Flask Project
list.html: The currency list
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search">
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="accounting-search" class="accounting-search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>
{% endif %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|accounting_append_next }}">
{{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -19,7 +19,7 @@ nav.html: The navigation menu for the accounting application.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/26
#}
{% if can_view_accounting() %}
{% if accounting_can_view() %}
<li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
<i class="fa-solid fa-gear"></i>
@ -38,6 +38,12 @@ First written: 2023/1/26
{{ A_("Base Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<i class="fa-solid fa-money-bill-wave"></i>
{{ A_("Currencies") }}
</a>
</li>
</ul>
</li>
{% endif %}

View File

@ -19,10 +19,10 @@ pagination.html: The pagination navigation bar.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/26
#}
{% if pagination.is_needed %}
{% if pagination.is_paged %}
<nav aria-label="Page navigation">
<ul class="pagination">
{% for link in pagination.page_links %}
{% for link in pagination.pages %}
{% if link.uri is none %}
<li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
<span class="page-link">
@ -42,7 +42,7 @@ First written: 2023/1/26
{{ pagination.page_size }}
</div>
<ul class="dropdown-menu">
{% for link in pagination.page_sizes %}
{% for link in pagination.page_size_options %}
<li>
<a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
{{ link.text }}

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-01 19:51+0800\n"
"PO-Revision-Date: 2023-02-01 19:52+0800\n"
"POT-Creation-Date: 2023-02-07 16:22+0800\n"
"PO-Revision-Date: 2023-02-07 18:04+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -19,35 +19,94 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
#: src/accounting/account/forms.py:39
#: src/accounting/account/forms.py:41
msgid "The base account does not exist."
msgstr "沒有這個基本科目。"
#: src/accounting/account/forms.py:48
#: src/accounting/account/forms.py:52
msgid "The base account is not available."
msgstr "不能選這個基本科目。"
#: src/accounting/account/forms.py:61
#: src/accounting/static/js/account-form.js:110
msgid "Please select the base account."
msgstr "請選擇基本科目。"
#: src/accounting/account/forms.py:53
#: src/accounting/account/forms.py:67
msgid "Please fill in the title"
msgstr "請填上標題。"
#: src/accounting/account/query.py:50
#: src/accounting/templates/accounting/account/detail.html:90
#: src/accounting/templates/accounting/account/list.html:62
msgid "Offset needed"
msgstr "逐筆核銷"
#: src/accounting/account/views.py:88
msgid "The account is added successfully"
msgstr "科目加好了。"
#: src/accounting/account/views.py:140
#: src/accounting/account/views.py:143
msgid "The account was not modified."
msgstr "科目未異動。"
#: src/accounting/account/views.py:145
#: src/accounting/account/views.py:148
msgid "The account is updated successfully."
msgstr "科目存好了。"
#: src/accounting/account/views.py:163
#: src/accounting/account/views.py:165
msgid "The account is deleted successfully."
msgstr "科目刪掉了"
#: src/accounting/account/views.py:192
msgid "The order was not modified."
msgstr "順序未異動。"
#: src/accounting/account/views.py:195
msgid "The order is updated successfully."
msgstr "順序存好了。"
#: src/accounting/currency/forms.py:47
#: src/accounting/static/js/currency-form.js:136
msgid "Code conflicts with another currency."
msgstr "代碼與其它貨幣重複。"
#: src/accounting/currency/forms.py:52
#: src/accounting/static/js/currency-form.js:92
msgid "Please fill in the code."
msgstr "請填上代碼。"
#: src/accounting/currency/forms.py:54
#: src/accounting/static/js/currency-form.js:103
msgid "Code can only be composed of 3 upper-cased letters."
msgstr "代碼限為三個大寫英文字母。"
#: src/accounting/currency/forms.py:57
#: src/accounting/static/js/currency-form.js:98
msgid "This code is not available."
msgstr "不能用這個代碼。"
#: src/accounting/currency/forms.py:63
#: src/accounting/static/js/currency-form.js:168
msgid "Please fill in the name."
msgstr "請填上名稱。"
#: src/accounting/currency/views.py:90
msgid "The currency is added successfully"
msgstr "貨幣加好了。"
#: src/accounting/currency/views.py:146
msgid "The currency was not modified."
msgstr "貨幣未異動。"
#: src/accounting/currency/views.py:151
msgid "The currency is updated successfully."
msgstr "貨幣存好了。"
#: src/accounting/currency/views.py:167
msgid "The currency is deleted successfully."
msgstr "貨幣刪掉了"
#: src/accounting/static/js/account-form.js:130
msgid "Please fill in the title."
msgstr "請填上標題。"
@ -58,45 +117,53 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/account/detail.html:31
#: src/accounting/templates/accounting/account/include/form.html:33
#: src/accounting/templates/accounting/account/order.html:36
#: src/accounting/templates/accounting/base-account/detail.html:31
#: src/accounting/templates/accounting/currency/detail.html:31
#: src/accounting/templates/accounting/currency/include/form.html:33
msgid "Back"
msgstr "回上頁"
#: src/accounting/templates/accounting/account/detail.html:36
#: src/accounting/templates/accounting/currency/detail.html:36
msgid "Settings"
msgstr "設定"
#: src/accounting/templates/accounting/account/detail.html:40
#: src/accounting/templates/accounting/account/detail.html:41
msgid "Order"
msgstr "次序"
#: src/accounting/templates/accounting/account/detail.html:46
#: src/accounting/templates/accounting/currency/detail.html:42
msgid "Delete"
msgstr "刪除"
#: src/accounting/templates/accounting/account/detail.html:63
#: src/accounting/templates/accounting/account/detail.html:69
msgid "Delete Account Confirmation"
msgstr "科目刪除確認"
#: src/accounting/templates/accounting/account/detail.html:67
#: src/accounting/templates/accounting/account/detail.html:73
msgid "Do you really want to delete this account?"
msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/detail.html:76
#: src/accounting/templates/accounting/account/include/form.html:111
#: src/accounting/templates/accounting/currency/detail.html:72
msgid "Cancel"
msgstr "取消"
#: src/accounting/templates/accounting/account/detail.html:71
#: src/accounting/templates/accounting/account/detail.html:77
#: src/accounting/templates/accounting/currency/detail.html:73
msgid "Confirm"
msgstr "確定"
#: src/accounting/templates/accounting/account/detail.html:84
#: src/accounting/templates/accounting/account/list.html:62
msgid "Offset needed"
msgstr "逐筆核銷"
#: src/accounting/templates/accounting/account/detail.html:88
#: src/accounting/templates/accounting/account/detail.html:94
#: src/accounting/templates/accounting/currency/detail.html:85
msgid "Created"
msgstr "建檔"
#: src/accounting/templates/accounting/account/detail.html:89
#: src/accounting/templates/accounting/account/detail.html:95
#: src/accounting/templates/accounting/currency/detail.html:86
msgid "Updated"
msgstr "更新"
@ -105,25 +172,47 @@ msgstr "更新"
msgid "%(account)s Settings"
msgstr "%(account)s設定"
#: src/accounting/templates/accounting/account/list.html:24
#: src/accounting/templates/accounting/base-account/list.html:24
#: src/accounting/templates/accounting/currency/list.html:24
#, python-format
msgid "Search Result for \"%(query)s\""
msgstr "「%(query)s」搜尋結果"
#: src/accounting/templates/accounting/account/list.html:24
msgid "Account Management"
msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32
msgid "New"
msgstr "新增"
#: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40
#: src/accounting/templates/accounting/base-account/list.html:34
#: src/accounting/templates/accounting/currency/list.html:40
msgid "Search"
msgstr "搜尋"
#: src/accounting/templates/accounting/account/list.html:68
#: src/accounting/templates/accounting/account/order.html:81
#: src/accounting/templates/accounting/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:57
msgid "There is no data."
msgstr "沒有資料。"
#: src/accounting/templates/accounting/account/order.html:29
#, python-format
msgid "The Accounts of %(base)s"
msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57
msgid "Save"
msgstr "儲存"
#: src/accounting/templates/accounting/account/include/form.html:45
msgid "Base account"
msgstr "基本科目"
@ -140,10 +229,6 @@ msgstr "標題"
msgid "The entries in the account need offsets."
msgstr "帳目要逐筆核銷。"
#: src/accounting/templates/accounting/account/include/form.html:75
msgid "Save"
msgstr "儲存"
#: src/accounting/templates/accounting/account/include/form.html:90
msgid "Select Base Account"
msgstr "選擇基本科目"
@ -157,6 +242,35 @@ msgstr "清除"
msgid "Base Account Managements"
msgstr "基本科目管理"
#: src/accounting/templates/accounting/currency/create.html:24
msgid "Add a New Currency"
msgstr "新增貨幣"
#: src/accounting/templates/accounting/currency/detail.html:65
msgid "Delete Currency Confirmation"
msgstr "貨幣刪除確認"
#: src/accounting/templates/accounting/currency/detail.html:69
msgid "Do you really want to delete this currency?"
msgstr "你確定要刪掉這個貨幣嗎?"
#: src/accounting/templates/accounting/currency/edit.html:24
#, python-format
msgid "%(currency)s Settings"
msgstr "%(currency)s設定"
#: src/accounting/templates/accounting/currency/list.html:24
msgid "Currency Management"
msgstr "貨幣管理"
#: src/accounting/templates/accounting/currency/include/form.html:44
msgid "Code"
msgstr "代碼"
#: src/accounting/templates/accounting/currency/include/form.html:50
msgid "Name"
msgstr "名稱"
#: src/accounting/templates/accounting/include/nav.html:26
msgid "Accounting"
msgstr "記帳"
@ -169,11 +283,17 @@ msgstr "科目"
msgid "Base Accounts"
msgstr "基本科目"
#: src/accounting/utils/pagination.py:146
msgid "Previous"
msgstr "前一頁"
#: src/accounting/templates/accounting/include/nav.html:44
msgid "Currencies"
msgstr "貨幣"
#: src/accounting/utils/pagination.py:194
#: src/accounting/utils/pagination.py:206
msgctxt "Pagination|"
msgid "Previous"
msgstr "上一頁"
#: src/accounting/utils/pagination.py:255
msgctxt "Pagination|"
msgid "Next"
msgstr "下一頁"

View File

@ -22,7 +22,7 @@ This module should not import any other module from the application.
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
urlunparse
from flask import request
from flask import request, Blueprint
def append_next(uri: str) -> str:
@ -73,3 +73,14 @@ def __set_next(uri: str, next_uri: str) -> str:
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def init_app(bp: Blueprint) -> None:
"""Initializes the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
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(or_next, "accounting_or_next")

View File

@ -24,12 +24,13 @@ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
ParseResult
from flask import request
from werkzeug.routing import RequestRedirect
from accounting.locale import gettext
from accounting.locale import gettext, pgettext
class PageLink:
"""A link in the pagination."""
class Link:
"""A link."""
def __init__(self, text: str, uri: str | None = None,
is_current: bool = False, is_for_mobile: bool = False):
@ -52,15 +53,20 @@ class PageLink:
"""Whether the link should be shown on mobile screens."""
class Redirection(RequestRedirect):
"""The redirection."""
code = 302
"""The HTTP code."""
DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
T = t.TypeVar("T")
class Pagination(t.Generic[T]):
"""The pagination utilities"""
AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200]
"""The available page sizes."""
DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
"""The pagination utility."""
def __init__(self, items: list[T], is_reversed: bool = False):
"""Constructs the pagination.
@ -68,130 +74,186 @@ class Pagination(t.Generic[T]):
:param items: The items.
:param is_reversed: True if the default page is the last page, or False
otherwise.
:raise Redirection: When the pagination parameters are malformed.
"""
self.__items: list[T] = items
"""All the items."""
self.__is_reversed: bool = is_reversed
"""Whether the default page is the last page."""
self.page_size: int = int(request.args.get("page-size",
self.DEFAULT_PAGE_SIZE))
"""The number of items in a page."""
self.__total_pages: int = 0 if len(items) == 0 \
else int((len(items) - 1) / self.page_size) + 1
"""The total number of pages."""
self.is_needed: bool = self.__total_pages > 1
pagination: AbstractPagination[T] = EmptyPagination[T]() \
if len(items) == 0 \
else NonEmptyPagination[T](items, is_reversed)
self.is_paged: bool = pagination.is_paged
"""Whether there should be pagination."""
self.list: list[T] = pagination.list
"""The items shown in the list"""
self.pages: list[Link] = pagination.pages
"""The pages."""
self.page_size: int = pagination.page_size
"""The number of items in a page."""
self.page_size_options: list[Link] = pagination.page_size_options
"""The options to the number of items in a page."""
class AbstractPagination(t.Generic[T]):
"""An abstract pagination."""
def __init__(self):
"""Constructs an empty pagination."""
self.page_size: int = DEFAULT_PAGE_SIZE
"""The number of items in a page."""
self.is_paged: bool = False
"""Whether there should be pagination."""
self.__default_page_no: int = 0
"""The default page number."""
self.page_no: int = 0
"""The current page number."""
self.list: list[T] = []
"""The items shown in the list"""
if self.__total_pages > 0:
self.__set_list()
self.pages: list[Link] = []
"""The pages."""
self.page_size_options: list[Link] = []
"""The options to the number of items in a page."""
class EmptyPagination(AbstractPagination[T]):
"""The pagination from empty data."""
pass
class NonEmptyPagination(AbstractPagination[T]):
"""The pagination with real data."""
PAGE_SIZE_OPTIONS: list[int] = [10, 100, 200]
"""The page size options."""
def __init__(self, items: list[T], is_reversed: bool = False):
"""Constructs the pagination.
:param items: The items.
:param is_reversed: True if the default page is the last page, or False
otherwise.
:raise Redirection: When the pagination parameters are malformed.
"""
super().__init__()
self.__current_uri: str = request.full_path if request.query_string \
else request.path
"""The current URI."""
self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \
= self.__get_base_uri_params()
"""The base URI parameters."""
self.page_links: list[PageLink] = self.__get_page_links()
"""The pagination links."""
self.page_sizes: list[PageLink] = self.__get_page_sizes()
"""The links to switch the number of items in a page."""
def __set_list(self) -> None:
"""Sets the items to show in the list.
:return: None.
"""
self.__default_page_no = self.__total_pages if self.__is_reversed \
else 1
self.page_no = int(request.args.get("page-no",
self.__default_page_no))
if self.page_no < 1:
self.page_no = 1
if self.page_no > self.__total_pages:
self.page_no = self.__total_pages
lower_bound: int = (self.page_no - 1) * self.page_size
self.__is_reversed: bool = is_reversed
"""Whether the default page is the last page."""
self.page_size = self.__get_page_size()
self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1
"""The total number of pages."""
self.is_paged = self.__total_pages > 1
self.__default_page_no: int = self.__total_pages \
if self.__is_reversed else 1
"""The default page number."""
self.__page_no: int = self.__get_page_no()
"""The current page number."""
lower_bound: int = (self.__page_no - 1) * self.page_size
upper_bound: int = lower_bound + self.page_size
if upper_bound > len(self.__items):
upper_bound = len(self.__items)
self.list = self.__items[lower_bound:upper_bound]
if upper_bound > len(items):
upper_bound = len(items)
self.list = items[lower_bound:upper_bound]
self.pages = self.__get_pages()
self.page_size_options = self.__get_page_size_options()
def __get_base_uri_params(self) -> tuple[list[str], list[tuple[str, str]]]:
"""Returns the base URI and its parameters, with the "page-no" and
"page-size" parameters removed.
def __get_page_size(self) -> int:
"""Returns the page size.
:return: The URI parts and the cleaned-up query parameters.
:return: The page size.
:raise Redirection: When the page size is malformed.
"""
uri_p: ParseResult = urlparse(self.__current_uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] not in ["page-no", "page-size"]]
parts: list[str] = list(uri_p)
return parts, params
if "page-size" not in request.args:
return DEFAULT_PAGE_SIZE
try:
page_size: int = int(request.args["page-size"])
except ValueError:
raise Redirection(self.__uri_set("page-size", None))
if page_size == DEFAULT_PAGE_SIZE or page_size < 1:
raise Redirection(self.__uri_set("page-size", None))
return page_size
def __get_page_links(self) -> list[PageLink]:
def __get_page_no(self) -> int:
"""Returns the page number.
:return: The page number.
:raise Redirection: When the page number is malformed.
"""
if "page-no" not in request.args:
return self.__default_page_no
try:
page_no: int = int(request.args["page-no"])
except ValueError:
raise Redirection(self.__uri_set("page-no", None))
if page_no == self.__default_page_no:
raise Redirection(self.__uri_set("page-no", None))
if page_no < 1:
if not self.__is_reversed:
raise Redirection(self.__uri_set("page-no", None))
raise Redirection(self.__uri_set("page-no", "1"))
if page_no > self.__total_pages:
if self.__is_reversed:
raise Redirection(self.__uri_set("page-no", None))
raise Redirection(self.__uri_set("page-no",
str(self.__total_pages)))
return page_no
def __get_pages(self) -> list[Link]:
"""Returns the page links in the pagination navigation.
:return: The page links in the pagination navigation.
"""
if self.__total_pages < 2:
if not self.is_paged:
return []
uri: str | None
links: list[PageLink] = []
links: list[Link] = []
# The previous page.
uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1)
links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True))
uri = None if self.__page_no == 1 \
else self.__uri_page(self.__page_no - 1)
links.append(Link(pgettext("Pagination|", "Previous"), uri,
is_for_mobile=True))
# The first page.
if self.page_no > 1:
links.append(PageLink("1", self.__uri_page(1)))
if self.__page_no > 1:
links.append(Link("1", self.__uri_page(1)))
# The eclipse of the previous pages.
if self.page_no - 3 == 2:
links.append(PageLink(str(self.page_no - 3),
self.__uri_page(self.page_no - 3)))
elif self.page_no - 3 > 2:
links.append(PageLink(""))
if self.__page_no - 3 == 2:
links.append(Link(str(self.__page_no - 3),
self.__uri_page(self.__page_no - 3)))
elif self.__page_no - 3 > 2:
links.append(Link(""))
# The previous two pages.
if self.page_no - 2 > 1:
links.append(PageLink(str(self.page_no - 2),
self.__uri_page(self.page_no - 2)))
if self.page_no - 1 > 1:
links.append(PageLink(str(self.page_no - 1),
self.__uri_page(self.page_no - 1)))
if self.__page_no - 2 > 1:
links.append(Link(str(self.__page_no - 2),
self.__uri_page(self.__page_no - 2)))
if self.__page_no - 1 > 1:
links.append(Link(str(self.__page_no - 1),
self.__uri_page(self.__page_no - 1)))
# The current page.
links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no),
is_current=True))
links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no),
is_current=True))
# The next two pages.
if self.page_no + 1 < self.__total_pages:
links.append(PageLink(str(self.page_no + 1),
self.__uri_page(self.page_no + 1)))
if self.page_no + 2 < self.__total_pages:
links.append(PageLink(str(self.page_no + 2),
self.__uri_page(self.page_no + 2)))
if self.__page_no + 1 < self.__total_pages:
links.append(Link(str(self.__page_no + 1),
self.__uri_page(self.__page_no + 1)))
if self.__page_no + 2 < self.__total_pages:
links.append(Link(str(self.__page_no + 2),
self.__uri_page(self.__page_no + 2)))
# The eclipse of the next pages.
if self.page_no + 3 == self.__total_pages - 1:
links.append(PageLink(str(self.page_no + 3),
self.__uri_page(self.page_no + 3)))
elif self.page_no + 3 < self.__total_pages - 1:
links.append(PageLink(""))
if self.__page_no + 3 == self.__total_pages - 1:
links.append(Link(str(self.__page_no + 3),
self.__uri_page(self.__page_no + 3)))
elif self.__page_no + 3 < self.__total_pages - 1:
links.append(Link(""))
# The last page.
if self.page_no < self.__total_pages:
links.append(PageLink(str(self.__total_pages),
self.__uri_page(self.__total_pages)))
if self.__page_no < self.__total_pages:
links.append(Link(str(self.__total_pages),
self.__uri_page(self.__total_pages)))
# The next page.
uri = None if self.page_no == self.__total_pages \
else self.__uri_page(self.page_no + 1)
links.append(PageLink(gettext("Next"), uri, is_for_mobile=True))
uri = None if self.__page_no == self.__total_pages \
else self.__uri_page(self.__page_no + 1)
links.append(Link(pgettext("Pagination|", "Next"), uri,
is_for_mobile=True))
return links
@ -201,21 +263,22 @@ class Pagination(t.Generic[T]):
:param page_no: The page number.
:return: The URI of the page.
"""
params: list[tuple[str, str]] = []
if page_no != self.__default_page_no:
params.append(("page-no", str(page_no)))
if self.page_size != self.DEFAULT_PAGE_SIZE:
params.append(("page-size", str(self.page_size)))
return self.__uri_set_params(params)
if page_no == self.__page_no:
return self.__current_uri
if page_no == self.__default_page_no:
return self.__uri_set("page-no", None)
return self.__uri_set("page-no", str(page_no))
def __get_page_sizes(self) -> list[PageLink]:
"""Returns the available page sizes.
def __get_page_size_options(self) -> list[Link]:
"""Returns the page size options.
:return: The available page sizes.
:return: The page size options.
"""
return [PageLink(str(x), self.__uri_size(x),
is_current=x == self.page_size)
for x in self.AVAILABLE_PAGE_SIZES]
if not self.is_paged:
return []
return [Link(str(x), self.__uri_size(x),
is_current=x == self.page_size)
for x in self.PAGE_SIZE_OPTIONS]
def __uri_size(self, page_size: int) -> str:
"""Returns the URI of a page size.
@ -225,16 +288,34 @@ class Pagination(t.Generic[T]):
"""
if page_size == self.page_size:
return self.__current_uri
return self.__uri_set_params([("page-size", str(page_size))])
if page_size == DEFAULT_PAGE_SIZE:
return self.__uri_set("page-size", None)
return self.__uri_set("page-size", str(page_size))
def __uri_set_params(self, params: list[tuple[str, str]]) -> str:
"""Returns the URI with the query parameters set.
def __uri_set(self, name: str, value: str | None) -> str:
"""Raises current URI with a parameter set.
:param params: The query parameters.
:return: The URI with the query parameters set.
:param name: The name of the parameter.
:param value: The value, or None to remove the parameter.
:return: The URI with the parameter set.
"""
cur_params: list[tuple[str, str]] = self.__base_uri_params[1].copy()
cur_params.extend(params)
parts: list[str] = self.__base_uri_params[0].copy()
parts[4] = urlencode(cur_params)
uri_p: ParseResult = urlparse(self.__current_uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
# Try to keep the position of the parameter.
i: int = 0
is_found: bool = False
while i < len(params):
if params[i][0] == name:
if is_found or value is None:
params = params[:i] + params[i + 1:]
continue
params[i] = (name, value)
is_found = True
i = i + 1
if not is_found and value is not None:
params.append((name, value))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)

View File

@ -21,7 +21,9 @@ This module should not import any other module from the application.
"""
import typing as t
from flask import Flask, abort
from flask import abort, Blueprint
from accounting.utils.user import get_current_user
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
@ -75,17 +77,22 @@ def can_view() -> bool:
def can_edit() -> bool:
"""Returns whether the current user can edit the account data.
The user has to log in.
:return: True if the current user can edit the accounting data, or False
otherwise.
"""
if get_current_user() is None:
return False
return __can_edit_func()
def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
def init_app(bp: Blueprint,
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initializes the application.
:param app: The Flask application.
:param bp: The blueprint of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
:param can_edit_func: A callback that returns whether the current user can
@ -97,5 +104,5 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
__can_view_func = can_view_func
if can_edit_func is not None:
__can_edit_func = can_edit_func
app.jinja_env.globals["can_view_accounting"] = __can_view_func
app.jinja_env.globals["can_edit_accounting"] = __can_edit_func
bp.add_app_template_global(can_view, "accounting_can_view")
bp.add_app_template_global(can_edit, "accounting_can_edit")

View File

@ -34,11 +34,22 @@ def parse_query_keywords(q: str | None) -> list[str]:
if q == "":
return []
keywords: list[str] = []
while q is not None:
m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q)
if m.group(1) is not None:
while True:
m: re.Match
m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
if m is not None:
keywords.append(m.group(1))
else:
keywords.append(m.group(2))
q = m.group(3)
q = m.group(2)
continue
m = re.match(r"\"([^\"]+)\"?$", q)
if m is not None:
keywords.append(m.group(1))
break
m = re.match(r"(\S+)\s+(.+)$", q)
if m is not None:
keywords.append(m.group(1))
q = m.group(2)
continue
keywords.append(q)
break
return keywords

View File

@ -32,6 +32,6 @@ def new_id(cls: t.Type):
:return: The generated new random ID.
"""
while True:
new: int = 100000000 + randbelow(900000000)
if db.session.get(cls, new) is None:
return new
obj_id: int = 100000000 + randbelow(900000000)
if db.session.get(cls, obj_id) is None:
return obj_id

View File

@ -0,0 +1,129 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 user utilities.
This module should not import any other module from the application.
"""
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask import g
from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model)
class AbstractUserUtils(t.Generic[T], ABC):
"""The abstract user utilities."""
@property
@abstractmethod
def cls(self) -> t.Type[T]:
"""Returns the user class.
:return: The user class.
"""
@property
@abstractmethod
def pk_column(self) -> sa.Column:
"""Returns the primary key column.
:return: The primary key column.
"""
@property
@abstractmethod
def current_user(self) -> T | None:
"""Returns the currently logged-in user.
:return: The currently logged-in user, or None if the user has not
logged in
"""
@abstractmethod
def get_by_username(self, username: str) -> T | None:
"""Returns the user by her username.
:return: The user by her username, or None if the user was not found.
"""
@abstractmethod
def get_pk(self, user: T) -> int:
"""Returns the primary key of the user.
:return: The primary key of the user.
"""
__user_utils: AbstractUserUtils
"""The user utilities."""
user_cls: t.Type[Model] = Model
"""The user class."""
user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class."""
def init_user_utils(utils: AbstractUserUtils) -> None:
"""Initializes the user utilities.
:param utils: The user utilities.
:return: None.
"""
global __user_utils, user_cls, user_pk_column
__user_utils = utils
user_cls = utils.cls
user_pk_column = utils.pk_column
def get_current_user_pk() -> int:
"""Returns the primary key value of the currently logged-in user.
:return: The primary key value of the currently logged-in user.
"""
return __user_utils.get_pk(get_current_user())
def has_user(username: str) -> bool:
"""Returns whether a user by the username exists.
:param username: The username.
:return: True if the user by the username exists, or False otherwise.
"""
return __user_utils.get_by_username(username) is not None
def get_user_pk(username: str) -> int:
"""Returns the primary key value of the user by the username.
:param username: The username.
:return: The primary key value of the user by the username.
"""
return __user_utils.get_pk(__user_utils.get_by_username(username))
def get_current_user() -> user_cls | None:
"""Returns the currently logged-in user. The result is cached in the
current request.
:return: The currently logged-in user.
"""
if not hasattr(g, "_accounting_user"):
setattr(g, "_accounting_user", __user_utils.current_user)
return getattr(g, "_accounting_user")

View File

@ -28,7 +28,7 @@ from babel.messages.frontend import CommandLineInterface
from opencc import OpenCC
root_dir: Path = Path(__file__).parent.parent
translation_dir: Path = root_dir / "tests" / "testsite" / "translations"
translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
domain: str = "messages"
@ -49,7 +49,7 @@ def babel_extract() -> None:
/ f"{domain}.po"
CommandLineInterface().run([
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
"-o", str(pot), str(Path("tests") / "testsite")])
"-o", str(pot), str(Path("tests") / "test_site")])
if not zh_hant.exists():
zh_hant.touch()
if not zh_hans.exists():

713
tests/test_account.py Normal file
View File

@ -0,0 +1,713 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 test for the account management.
"""
import time
import unittest
import httpx
import sqlalchemy as sa
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client, set_locale
class AccountData:
"""The account data."""
def __init__(self, base_code: str, no: int, title: str):
"""Constructs the account data.
:param base_code: The base code.
:param no: The number.
:param title: The title.
"""
self.base_code: str = base_code
"""The base code."""
self.no: int = no
"""The number."""
self.title: str = title
"""The title."""
self.code: str = f"{self.base_code}-{self.no:03d}"
"""The code."""
cash: AccountData = AccountData("1111", 1, "Cash")
"""The cash account."""
bank: AccountData = AccountData("1113", 1, "Bank")
"""The bank account."""
stock: AccountData = AccountData("1121", 1, "Stock")
"""The stock account."""
loan: AccountData = AccountData("2112", 1, "Loan")
"""The loan account."""
PREFIX: str = "/accounting/accounts"
"""The URL prefix of the currency management."""
class AccountCommandTestCase(unittest.TestCase):
"""The account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.database import db
from accounting.models import BaseAccount, Account, AccountL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-account" console command.
:return: None.
"""
from accounting.models import BaseAccount, Account, AccountL10n
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
with self.app.app_context():
bases: list[BaseAccount] = BaseAccount.query\
.filter(sa.func.char_length(BaseAccount.code) == 4).all()
accounts: list[Account] = Account.query.all()
l10n: list[AccountL10n] = AccountL10n.query.all()
self.assertEqual({x.code for x in bases},
{x.base_code for x in accounts})
self.assertEqual(len(accounts), len(bases))
self.assertEqual(len(l10n), len(bases) * 2)
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
for account in accounts:
base: BaseAccount = base_dict[account.base_code]
self.assertEqual(account.no, 1)
self.assertEqual(account.title_l10n, base.title_l10n)
self.assertEqual({x.locale: x.title for x in account.l10n},
{x.locale: x.title for x in base.l10n})
class AccountTestCase(unittest.TestCase):
"""The account test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.database import db
from accounting.models import BaseAccount, Account, AccountL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
self.client, self.csrf_token = get_client(self, self.app, "editor")
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": cash.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{cash.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": bank.base_code,
"title": bank.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{bank.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self, self.app, "nobody")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 403)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token,
"next": "/next",
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self, self.app, "viewer")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token,
"next": "/next",
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
"""Test the permission as editor.
:return: None.
"""
from accounting.models import Account
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.code}")
response = self.client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
response = self.client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/next")
def test_add(self) -> None:
"""Tests to add the currencies.
:return: None.
"""
from accounting.database import db
from accounting.models import Account
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{stock.code}"
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
# Empty base account code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Non-existing base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Unavailable base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {stock.base_code} ",
"title": f" {stock.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Success under the same base
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-002")
# Success under the same base, with order in a mess.
with self.app.app_context():
stock_2: Account = Account.find_by_code(f"{stock.base_code}-002")
stock_2.no = 66
db.session.commit()
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-067")
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code, stock.code,
f"{stock.base_code}-066",
f"{stock.base_code}-067"})
stock_account: Account = Account.find_by_code(stock.code)
self.assertEqual(stock_account.base_code, stock.base_code)
self.assertEqual(stock_account.title_l10n, stock.title)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
edit_uri: str = f"{PREFIX}/{cash.code}/edit"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_c_uri: str = f"{PREFIX}/{stock.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ",
"title": f" {cash.title}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.base_code, cash.base_code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-1")
# Empty base account code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Unavailable base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
"""Tests that the data is not modified.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
time.sleep(1)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ",
"title": f" {cash.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
self.assertEqual(cash_account.created_at, cash_account.updated_at)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
self.assertNotEqual(cash_account.created_at,
cash_account.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
:return: None.
"""
from accounting.models import Account
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self, self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username, editor_username)
self.assertEqual(cash_account.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username,
editor_username)
self.assertEqual(cash_account.updated_by.username,
editor2_username)
def test_l10n(self) -> None:
"""Tests the localization.
:return: None
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual(cash_account.l10n, [])
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
delete_uri: str = f"{PREFIX}/{cash.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{bank.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 404)
def test_reorder(self) -> None:
"""Tests to reorder the accounts under a same base account.
:return: None.
"""
from accounting.database import db
from accounting.models import Account
response: httpx.Response
for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}")
# Normal reorder
with self.app.app_context():
id_1: int = Account.find_by_code("1111-001").id
id_2: int = Account.find_by_code("1111-002").id
id_3: int = Account.find_by_code("1111-003").id
id_4: int = Account.find_by_code("1111-004").id
id_5: int = Account.find_by_code("1111-005").id
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{id_1}-no": "4",
f"{id_2}-no": "1",
f"{id_3}-no": "5",
f"{id_4}-no": "2",
f"{id_5}-no": "3"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"/next")
with self.app.app_context():
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_3).code, "1111-005")
self.assertEqual(db.session.get(Account, id_4).code, "1111-002")
self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
# Malformed orders
with self.app.app_context():
db.session.get(Account, id_1).no = 3
db.session.get(Account, id_2).no = 4
db.session.get(Account, id_3).no = 6
db.session.get(Account, id_4).no = 8
db.session.get(Account, id_5).no = 9
db.session.commit()
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{id_2}-no": "3a",
f"{id_3}-no": "5",
f"{id_4}-no": "2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"/next")
with self.app.app_context():
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_3).code, "1111-002")
self.assertEqual(db.session.get(Account, id_4).code, "1111-001")
self.assertEqual(db.session.get(Account, id_5).code, "1111-005")

View File

@ -14,7 +14,6 @@
# 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 test for the base account management.
"""
@ -25,12 +24,12 @@ from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import get_csrf_token
from testsite import create_app
from test_site import create_app
from testlib import get_client
class BaseAccountTestCase(unittest.TestCase):
"""The base account test case."""
class BaseAccountCommandTestCase(unittest.TestCase):
"""The base account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
@ -38,24 +37,22 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import BaseAccount, BaseAccountL10n
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
self.client: httpx.Client = httpx.Client(app=self.app,
base_url="https://testserver")
self.client.headers["Referer"] = "https://testserver"
self.csrf_token: str = get_csrf_token(self, self.client, "/login")
BaseAccountL10n.query.delete()
BaseAccount.query.delete()
def test_init(self) -> None:
"""Tests the "accounting-init-base" console command.
:return: None.
"""
from accounting.models import BaseAccountL10n
from accounting.models import BaseAccount
from accounting.models import BaseAccount, BaseAccountL10n
runner: FlaskCliRunner = self.app.test_cli_runner()
result: Result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
@ -69,46 +66,65 @@ class BaseAccountTestCase(unittest.TestCase):
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
list_uri: str = "/accounting/base-accounts"
class BaseAccountTestCase(unittest.TestCase):
"""The base account test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
from accounting.models import BaseAccount
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self, self.app, "nobody")
response: httpx.Response
self.__logout()
response = self.client.get(list_uri)
response = client.get("/accounting/base-accounts")
self.assertEqual(response.status_code, 403)
self.__logout()
self.__login_as("viewer")
response = self.client.get(list_uri)
self.assertEqual(response.status_code, 200)
self.__logout()
self.__login_as("editor")
response = self.client.get(list_uri)
self.assertEqual(response.status_code, 200)
self.__logout()
self.__login_as("nobody")
response = self.client.get(list_uri)
response = client.get("/accounting/base-accounts/1111")
self.assertEqual(response.status_code, 403)
def __logout(self) -> None:
"""Logs out the currently logged-in user.
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
response: httpx.Response = self.client.post(
"/logout", data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/")
client, csrf_token = get_client(self, self.app, "viewer")
response: httpx.Response
def __login_as(self, username: str) -> None:
"""Logs in as a specific user.
response = client.get("/accounting/base-accounts")
self.assertEqual(response.status_code, 200)
response = client.get("/accounting/base-accounts/1111")
self.assertEqual(response.status_code, 200)
def test_editor(self) -> None:
"""Test the permission as editor.
:param username: The username.
:return: None.
"""
response: httpx.Response = self.client.post(
"/login", data={"csrf_token": self.csrf_token,
"username": username})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/")
client, csrf_token = get_client(self, self.app, "editor")
response: httpx.Response
response = client.get("/accounting/base-accounts")
self.assertEqual(response.status_code, 200)
response = client.get("/accounting/base-accounts/1111")
self.assertEqual(response.status_code, 200)

574
tests/test_currency.py Normal file
View File

@ -0,0 +1,574 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# 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 test for the currency management.
"""
import time
import unittest
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client, set_locale
class CurrencyData:
"""The currency data."""
def __init__(self, code: str, name: str):
"""Constructs the currency data.
:param code: The code.
:param name: The name.
"""
self.code: str = code
"""The code."""
self.name: str = name
"""The name."""
zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
"""The first test currency."""
zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
"""The second test currency."""
zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
"""The third test currency."""
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
"""The fourth test currency."""
PREFIX: str = "/accounting/currencies"
"""The URL prefix of the currency management."""
class CurrencyCommandTestCase(unittest.TestCase):
"""The account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.database import db
from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-currencies" console command.
:return: None.
"""
from accounting.models import Currency, CurrencyL10n
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(
args=["accounting-init-currencies", "-u", "editor"])
self.assertEqual(result.exit_code, 0)
currencies: list[Currency] = Currency.query.all()
l10n: list[CurrencyL10n] = CurrencyL10n.query.all()
self.assertEqual(len(currencies), 2)
self.assertEqual(len(l10n), 2 * 2)
l10n_keys: set[str] = {f"{x.currency_code}-{x.locale}" for x in l10n}
for currency in currencies:
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
class CurrencyTestCase(unittest.TestCase):
"""The currency test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.database import db
from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
self.client, self.csrf_token = get_client(self, self.app, "editor")
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zza.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzb.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self, self.app, "nobody")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
client, csrf_token = get_client(self, self.app, "viewer")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
"""Test the permission as editor.
:return: None.
"""
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
response = self.client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": self.csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
def test_add(self) -> None:
"""Tests to add the currencies.
:return: None.
"""
from accounting.models import Currency
from test_site import db
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{zzc.code}"
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 400)
# Empty code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Bad code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " zzc ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zzc.code} ",
"name": f" {zzc.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Duplicated code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code, zzc.code})
zzc_currency: Currency = db.session.get(Currency, zzc.code)
self.assertEqual(zzc_currency.code, zzc.code)
self.assertEqual(zzc_currency.name_l10n, zzc.name)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
:return: None.
"""
from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}"
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
update_uri: str = f"{PREFIX}/{zza.code}/update"
detail_c_uri: str = f"{PREFIX}/{zzc.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.code, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1")
# Empty code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Bad code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": "abc/def",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Duplicated code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
"""Tests that the data is not modified.
:return: None.
"""
from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
time.sleep(1)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
self.assertEqual(zza_currency.created_at, zza_currency.updated_at)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
self.assertNotEqual(zza_currency.created_at,
zza_currency.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
:return: None.
"""
from accounting.models import Currency
from test_site import db
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self, self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor2_username)
def test_l10n(self) -> None:
"""Tests the localization.
:return: None
"""
from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual(zza_currency.l10n, [])
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
delete_uri: str = f"{PREFIX}/{zza.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zzb.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 404)

View File

@ -29,6 +29,8 @@ from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy import Column
import accounting.utils.user
bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS()
csrf: CSRFProtect = CSRFProtect()
@ -53,7 +55,6 @@ def create_app(is_testing: bool = False) -> Flask:
})
if is_testing:
app.config["TESTING"] = True
app.config["SQLALCHEMY_ECHO"] = True
babel_js.init_app(app)
csrf.init_app(app)
@ -68,7 +69,7 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth
auth.init_app(app)
class UserUtils(accounting.AbstractUserUtils[auth.User]):
class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
@property
def cls(self) -> t.Type[auth.User]:
@ -79,7 +80,7 @@ def create_app(is_testing: bool = False) -> Flask:
return auth.User.id
@property
def current_user(self) -> auth.User:
def current_user(self) -> auth.User | None:
return auth.current_user()
def get_by_username(self, username: str) -> auth.User | None:
@ -90,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask:
return user.id
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor"]
and auth.current_user().username in ["viewer", "editor", "editor2"]
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username == "editor"
and auth.current_user().username in ["editor", "editor2"]
accounting.init_app(app, user_utils=UserUtils(),
can_view_func=can_view, can_edit_func=can_edit)
@ -105,7 +106,7 @@ def init_db_command() -> None:
"""Initializes the database."""
db.create_all()
from .auth import User
for username in ["viewer", "editor", "nobody"]:
for username in ["viewer", "editor", "editor2", "nobody"]:
if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username))
db.session.commit()

View File

@ -58,7 +58,8 @@ def login() -> redirect:
:return: The redirection to the home page.
"""
if request.form.get("username") not in ["viewer", "editor", "nobody"]:
if request.form.get("username") not in ["viewer", "editor", "editor2",
"nobody"]:
return redirect(url_for("auth.login"))
session["user"] = request.form.get("username")
return redirect(url_for("home.home"))

View File

@ -29,6 +29,7 @@ First written: 2023/1/27
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
</form>

View File

@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
"POT-Creation-Date: 2023-02-06 23:25+0800\n"
"PO-Revision-Date: 2023-02-06 23:26+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -20,35 +20,41 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
#: tests/testsite/templates/base.html:23
#: tests/test_site/templates/base.html:23
msgid "en"
msgstr "zh-Hant"
#: tests/testsite/templates/base.html:43 tests/testsite/templates/home.html:24
#: tests/test_site/templates/base.html:43
#: tests/test_site/templates/home.html:24
msgid "Home"
msgstr "首頁"
#: tests/testsite/templates/base.html:68
#: tests/test_site/templates/base.html:68
msgid "Log Out"
msgstr ""
#: tests/testsite/templates/base.html:78 tests/testsite/templates/login.html:24
#: tests/test_site/templates/base.html:78
#: tests/test_site/templates/login.html:24
msgid "Log In"
msgstr "登入"
#: tests/testsite/templates/base.html:119
#: tests/test_site/templates/base.html:119
msgid "Error:"
msgstr "錯誤:"
#: tests/testsite/templates/login.html:30
#: tests/test_site/templates/login.html:30
msgid "Viewer"
msgstr "讀報表者"
#: tests/testsite/templates/login.html:31
#: tests/test_site/templates/login.html:31
msgid "Editor"
msgstr "記帳者"
#: tests/testsite/templates/login.html:32
#: tests/test_site/templates/login.html:32
msgid "Editor2"
msgstr "記帳者2"
#: tests/test_site/templates/login.html:33
msgid "Nobody"
msgstr "沒有權限者"

310
tests/test_utils.py Normal file
View File

@ -0,0 +1,310 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/3
# 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 test for the independent utilities.
"""
import unittest
from urllib.parse import quote_plus
import httpx
from flask import Flask, request
from accounting.utils.next_url import append_next, inherit_next, or_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords
from test_site import create_app, csrf
class NextUriTestCase(unittest.TestCase):
"""The test case for the next URI utilities."""
def test_next_uri(self) -> None:
"""Tests the next URI utilities.
:return: None.
"""
app: Flask = create_app(is_testing=True)
target: str = "/target"
@app.route("/test-next", methods=["GET", "POST"])
@csrf.exempt
def test_next_view() -> str:
"""The test view with the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(target),
f"{target}?next={quote_plus(current_uri)}")
next_uri: str = request.form["next"] if request.method == "POST" \
else request.args["next"]
self.assertEqual(inherit_next(target),
f"{target}?next={quote_plus(next_uri)}")
self.assertEqual(or_next(target), next_uri)
return ""
@app.route("/test-no-next", methods=["GET", "POST"])
@csrf.exempt
def test_no_next_view() -> str:
"""The test view without the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(target),
f"{target}?next={quote_plus(current_uri)}")
self.assertEqual(inherit_next(target), target)
self.assertEqual(or_next(target), target)
return ""
client: httpx.Client = httpx.Client(app=app,
base_url="https://testserver")
client.headers["Referer"] = "https://testserver"
response: httpx.Response
# With the next URI
response = client.get("/test-next?next=/next&q=abc&page-no=4")
self.assertEqual(response.status_code, 200)
response = client.post("/test-next", data={"next": "/next",
"name": "viewer"})
self.assertEqual(response.status_code, 200)
# Without the next URI
response = client.get("/test-no-next?q=abc&page-no=4")
self.assertEqual(response.status_code, 200)
response = client.post("/test-no-next", data={"name": "viewer"})
self.assertEqual(response.status_code, 200)
class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser."""
def test_default(self) -> None:
"""Tests the query keyword parser.
:return: None.
"""
self.assertEqual(parse_query_keywords("coffee"), ["coffee"])
self.assertEqual(parse_query_keywords("coffee tea"), ["coffee", "tea"])
self.assertEqual(parse_query_keywords("\"coffee\" \"tea cake\""),
["coffee", "tea cake"])
self.assertEqual(parse_query_keywords("\"coffee tea\" cheese "
"\"cake candy\" sugar"),
["coffee tea", "cheese", "cake candy", "sugar"])
def test_malformed(self) -> None:
"""Tests the malformed query.
:return: None.
"""
self.assertEqual(parse_query_keywords("coffee \"tea cake"),
["coffee", "tea cake"])
self.assertEqual(parse_query_keywords("coffee te\"a ca\"ke"),
["coffee", "te\"a", "ca\"ke"])
self.assertEqual(parse_query_keywords("coffee\" tea cake\""),
["coffee\"", "tea", "cake\""])
def test_empty(self) -> None:
"""Tests the empty query.
:return: None.
"""
self.assertEqual(parse_query_keywords(None), [])
self.assertEqual(parse_query_keywords(""), [])
class PaginationTestCase(unittest.TestCase):
"""The test case for pagination."""
class Params:
"""The testing parameters."""
def __init__(self, items: list[int], is_reversed: bool | None,
result: list[int], is_paged: bool):
"""Constructs the expected pagination.
:param items: All the items in the list.
:param is_reversed: Whether the default page is the last page.
:param result: The expected items on the page.
:param is_paged: Whether the pagination is needed.
"""
self.items: list[int] = items
self.is_reversed: bool | None = is_reversed
self.result: list[int] = result
self.is_paged: bool = is_paged
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.params = self.Params([], None, [], True)
@self.app.get("/test-pagination")
def test_pagination_view() -> str:
"""The test view with the pagination."""
pagination: Pagination
if self.params.is_reversed is not None:
pagination = Pagination[int](
self.params.items, is_reversed=self.params.is_reversed)
else:
pagination = Pagination[int](self.params.items)
self.assertEqual(pagination.is_paged, self.params.is_paged)
self.assertEqual(pagination.list, self.params.result)
return ""
self.client = httpx.Client(app=self.app, base_url="https://testserver")
self.client.headers["Referer"] = "https://testserver"
def __test_success(self, query: str, items: range,
result: range, is_paged: bool = True,
is_reversed: bool | None = None) -> None:
"""Tests the pagination.
:param query: The query string.
:param items: The original items.
:param result: The expected page content.
:param is_paged: Whether the pagination is needed.
:param is_reversed: Whether the list is reversed.
:return: None.
"""
target: str = "/test-pagination"
if query != "":
target = f"{target}?{query}"
self.params = self.Params(list(items), is_reversed,
list(result), is_paged)
response: httpx.Response = self.client.get(target)
self.assertEqual(response.status_code, 200)
def __test_malformed(self, query: str, items: range, redirect_to: str,
is_reversed: bool | None = None) -> None:
"""Tests the pagination.
:param query: The query string.
:param items: The original items.
:param redirect_to: The expected target query of the redirection.
:param is_reversed: Whether the list is reversed.
:return: None.
"""
target: str = "/test-pagination"
self.params = self.Params(list(items), is_reversed, [], True)
response: httpx.Response = self.client.get(f"{target}?{query}")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{target}?{redirect_to}")
def test_default(self) -> None:
"""Tests the default pagination.
:return: None.
"""
# The default first page
self.__test_success("", range(1, 687), range(1, 11))
# Some page in the middle
self.__test_success("page-no=37", range(1, 687), range(361, 371))
# The last page
self.__test_success("page-no=69", range(1, 687), range(681, 687))
def test_page_size(self) -> None:
"""Tests the pagination with a different page size.
:return: None.
"""
# The default page with a different page size
self.__test_success("page-size=15", range(1, 687), range(1, 16))
# Some page with a different page size
self.__test_success("page-no=37&page-size=15", range(1, 687),
range(541, 556))
# The last page with a different page size.
self.__test_success("page-no=46&page-size=15", range(1, 687),
range(676, 687))
def test_not_needed(self) -> None:
"""Tests the pagination that is not needed.
:return: None.
"""
# Empty list
self.__test_success("", range(0, 0), range(0, 0), is_paged=False)
# A list that fits in one page
self.__test_success("", range(1, 4), range(1, 4), is_paged=False)
# A large page size that fits in everything
self.__test_success("page-size=1000", range(1, 687), range(1, 687),
is_paged=False)
def test_reversed(self) -> None:
"""Tests the default page on a reversed list.
:return: None.
"""
# The default page
self.__test_success("", range(1, 687), range(681, 687),
is_reversed=True)
# The default page with a different page size
self.__test_success("page-size=15", range(1, 687), range(676, 687),
is_reversed=True)
def test_last_page(self) -> None:
"""Tests the calculation of the items on the last page.
:return: None.
"""
# The last page that fits in one page
self.__test_success("page-no=69", range(1, 691), range(681, 691))
# A danging item in the last page
self.__test_success("page-no=70", range(1, 692), range(691, 692))
def test_malformed(self) -> None:
"""Tests the malformed pagination parameters.
:return: None.
"""
# A malformed page size
self.__test_malformed("q=word&page-size=100a&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F")
# A default page size
self.__test_malformed(f"q=word&page-size={DEFAULT_PAGE_SIZE}"
"&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F")
# An invalid page size
self.__test_malformed("q=word&page-size=0&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F")
# A malformed page number
self.__test_malformed("q=word&page-size=15&page-no=37a&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F")
# A default page number
self.__test_malformed("q=word&page-size=15&page-no=1&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F")
# A default page number, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=46&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F",
is_reversed=True)
# A page number beyond the last page
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
range(1, 691),
"q=word&page-size=15&page-no=46&next=%2F")
# A page number beyond the last page, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
range(1, 691),
"q=word&page-size=15&next=%2F", is_reversed=True)
# A page number before the first page
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
range(1, 691),
"q=word&page-size=15&next=%2F")
# A page number before the first page, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
range(1, 691),
"q=word&page-size=15&page-no=1&next=%2F",
is_reversed=True)

View File

@ -17,10 +17,32 @@
"""The common test libraries.
"""
import typing as t
from html.parser import HTMLParser
from unittest import TestCase
import httpx
from flask import Flask
def get_client(test_case: TestCase, app: Flask, username: str) \
-> tuple[httpx.Client, str]:
"""Returns a user client.
:param test_case: The test case.
:param app: The Flask application.
:param username: The username.
:return: A tuple of the client and the CSRF token.
"""
client: httpx.Client = httpx.Client(app=app, base_url="https://testserver")
client.headers["Referer"] = "https://testserver"
csrf_token: str = get_csrf_token(test_case, client, "/login")
response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token,
"username": username})
test_case.assertEqual(response.status_code, 302)
test_case.assertEqual(response.headers["Location"], "/")
return client, csrf_token
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
@ -54,3 +76,21 @@ def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
parser.feed(response.text)
test_case.assertIsNotNone(parser.csrf_token)
return parser.csrf_token
def set_locale(test_case: TestCase, client: httpx.Client, csrf_token: str,
locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
"""Sets the current locale.
:param test_case: The test case.
:param client: The test client.
:param csrf_token: The CSRF token.
:param locale: The locale.
:return: None.
"""
response: httpx.Response = client.post("/locale",
data={"csrf_token": csrf_token,
"locale": locale,
"next": "/next"})
test_case.assertEqual(response.status_code, 302)
test_case.assertEqual(response.headers["Location"], "/next")