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 *.pot
*.mo *.mo
zh_Hans 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: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.base\_account.database module accounting.base\_account.converters module
---------------------------------------- ------------------------------------------
.. automodule:: accounting.base_account.database .. automodule:: accounting.base_account.converters
:members:
:undoc-members:
:show-inheritance:
accounting.base\_account.models module
--------------------------------------
.. automodule:: accounting.base_account.models
:members: :members:
:undoc-members: :undoc-members:
:show-inheritance: :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:: .. toctree::
:maxdepth: 4 :maxdepth: 4
accounting.account
accounting.base_account accounting.base_account
accounting.currency
accounting.utils accounting.utils
Submodules Submodules
---------- ----------
accounting.database module
--------------------------
.. automodule:: accounting.database
:members:
:undoc-members:
:show-inheritance:
accounting.locale module accounting.locale module
------------------------ ------------------------
@ -21,6 +31,14 @@ accounting.locale module
:undoc-members: :undoc-members:
:show-inheritance: :show-inheritance:
accounting.models module
------------------------
.. automodule:: accounting.models
:members:
:undoc-members:
:show-inheritance:
Module contents Module contents
--------------- ---------------

View File

@ -4,6 +4,14 @@ accounting.utils package
Submodules Submodules
---------- ----------
accounting.utils.next\_url module
---------------------------------
.. automodule:: accounting.utils.next_url
:members:
:undoc-members:
:show-inheritance:
accounting.utils.pagination module accounting.utils.pagination module
---------------------------------- ----------------------------------
@ -28,6 +36,30 @@ accounting.utils.query module
:undoc-members: :undoc-members:
:show-inheritance: :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 Module contents
--------------- ---------------

View File

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

View File

@ -18,55 +18,10 @@
""" """
import typing as t import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask import Flask, Blueprint from flask import Flask, Blueprint
from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model) from accounting.utils.user import AbstractUserUtils
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.
"""
def init_app(app: Flask, user_utils: 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 # The database instance must be set before loading everything
# in the application. # in the application.
from .database import set_db 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__, bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix, url_prefix=url_prefix,
@ -98,7 +55,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
locale.init_app(app, bp) locale.init_app(app, bp)
from .utils import permission 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 from . import base_account
base_account.init_app(app, bp) base_account.init_app(app, bp)
@ -106,9 +63,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import account from . import account
account.init_app(app, bp) account.init_app(app, bp)
from .utils.next_url import append_next, inherit_next, or_next from . import currency
bp.add_app_template_filter(append_next, "append_next") currency.init_app(app, bp)
bp.add_app_template_filter(inherit_next, "inherit_next")
bp.add_app_template_filter(or_next, "or_next") from .utils import next_url
next_url.init_app(bp)
app.register_blueprint(bp) app.register_blueprint(bp)

View File

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

View File

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

View File

@ -18,19 +18,21 @@
""" """
import sqlalchemy as sa import sqlalchemy as sa
from flask import request
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField from wtforms import StringField, BooleanField
from wtforms.validators import DataRequired, ValidationError 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.locale import lazy_gettext
from accounting.models import BaseAccount, Account from accounting.models import BaseAccount, Account
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class BaseAccountExists: 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: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "": if field.data == "":
@ -40,13 +42,25 @@ class BaseAccountExists:
"The base account does not exist.")) "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): class AccountForm(FlaskForm):
"""The form to create or edit an account.""" """The form to create or edit an account."""
base_code = StringField( base_code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[ validators=[
DataRequired(lazy_gettext("Please select the base account.")), DataRequired(lazy_gettext("Please select the base account.")),
BaseAccountExists()]) BaseAccountExists(),
BaseAccountAvailable()])
"""The code of the base account.""" """The code of the base account."""
title = StringField( title = StringField(
filters=[strip_text], filters=[strip_text],
@ -67,14 +81,14 @@ class AccountForm(FlaskForm):
obj.id = new_id(Account) obj.id = new_id(Account)
obj.base_code = self.base_code.data obj.base_code = self.base_code.data
if prev_base_code != self.base_code.data: if prev_base_code != self.base_code.data:
last_same_base: Account = Account.query\ max_no: int = db.session.scalars(
.filter(Account.base_code == self.base_code.data)\ sa.select(sa.func.max(Account.no))
.order_by(Account.base_code.desc()).first() .filter(Account.base_code == self.base_code.data)).one()
obj.no = 1 if last_same_base is None else last_same_base.no + 1 obj.no = 1 if max_no is None else max_no + 1
obj.title = self.title.data obj.title = self.title.data
obj.is_offset_needed = self.is_offset_needed.data obj.is_offset_needed = self.is_offset_needed.data
if is_new: 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.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk obj.updated_by_id = current_user_pk
if prev_base_code is not None \ if prev_base_code is not None \
@ -87,7 +101,7 @@ class AccountForm(FlaskForm):
:return: None :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_by_id = current_user_pk
obj.updated_at = sa.func.now() obj.updated_at = sa.func.now()
if hasattr(self, "__post_update"): 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)): for i in range(len(accounts)):
if accounts[i].no != i + 1: if accounts[i].no != i + 1:
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.next_url import inherit_next, or_next
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit 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__) bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management.""" """The view blueprint for the account management."""
@ -38,7 +38,7 @@ bp: Blueprint = Blueprint("account", __name__)
@bp.get("", endpoint="list") @bp.get("", endpoint="list")
@has_permission(can_view) @has_permission(can_view)
def list_accounts() -> str: def list_accounts() -> str:
"""Lists the base accounts. """Lists the accounts.
:return: The account list. :return: The account list.
""" """
@ -95,7 +95,8 @@ def add_account() -> redirect:
def show_account_detail(account: Account) -> str: def show_account_detail(account: Account) -> str:
"""Shows the account detail. """Shows the account detail.
:return: The account detail. :param account: The account.
:return: The detail.
""" """
return render_template("accounting/account/detail.html", obj=account) 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: def show_account_edit_form(account: Account) -> str:
"""Shows the form to edit an account. """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 form: AccountForm
if "form" in session: if "form" in session:
@ -123,6 +125,7 @@ def show_account_edit_form(account: Account) -> str:
def update_account(account: Account) -> redirect: def update_account(account: Account) -> redirect:
"""Updates an account. """Updates an account.
:param account: The account.
:return: The redirection to the account detail on success, or the account :return: The redirection to the account detail on success, or the account
edit form on error. edit form on error.
""" """
@ -136,7 +139,7 @@ def update_account(account: Account) -> redirect:
account=account))) account=account)))
with db.session.no_autoflush: with db.session.no_autoflush:
form.populate_obj(account) 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") flash(lazy_gettext("The account was not modified."), "success")
return redirect(inherit_next(url_for("accounting.account.detail", return redirect(inherit_next(url_for("accounting.account.detail",
account=account))) account=account)))
@ -152,13 +155,42 @@ def update_account(account: Account) -> redirect:
def delete_account(account: Account) -> redirect: def delete_account(account: Account) -> redirect:
"""Deletes an account. """Deletes an account.
:param account: The account.
:return: The redirection to the account list on success, or the account :return: The redirection to the account list on success, or the account
detail on error. detail on error.
""" """
for l10n in account.l10n: account.delete()
db.session.delete(l10n)
db.session.delete(account)
sort_accounts_in(account.base_code, account.id) sort_accounts_in(account.base_code, account.id)
db.session.commit() db.session.commit()
flash(lazy_gettext("The account is deleted successfully."), "success") flash(lazy_gettext("The account is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.account.list"))) 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: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application. :param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None. :return: None.
""" """
from .converters import BaseAccountConverter from .converters import BaseAccountConverter

View File

@ -46,7 +46,8 @@ def list_accounts() -> str:
def show_account_detail(account: BaseAccount) -> str: def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail. """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) 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 flask_sqlalchemy import SQLAlchemy
from accounting import AbstractUserUtils db: SQLAlchemy = SQLAlchemy()
db: SQLAlchemy
"""The database instance.""" """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. """Sets the database instance.
:param new_db: The database instance. :param new_db: The database instance.
:param new_user_utils: The user utilities.
:return: None. :return: None.
""" """
global db, user_utils global db
db = new_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) 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: def lazy_gettext(string, **variables) -> LazyString:
"""A replacement of the Babel lazy_gettext() function.. """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: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initializes the application. """Initializes the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application. :param app: The Flask application.
:param bp: The blueprint of the accounting application.
:return: None. :return: None.
""" """
bp.add_url_rule("/_jstrans.js", "babel_catalog", bp.add_url_rule("/_jstrans.js", "babel_catalog",

View File

@ -25,10 +25,8 @@ from flask import current_app
from flask_babel import get_locale from flask_babel import get_locale
from sqlalchemy import text from sqlalchemy import text
from accounting.database import db, user_utils from accounting.database import db
from accounting.utils.user import user_cls, user_pk_column
user_cls: db.Model = user_utils.cls
user_pk_column: db.Column = user_utils.pk_column
class BaseAccount(db.Model): class BaseAccount(db.Model):
@ -66,12 +64,22 @@ class BaseAccount(db.Model):
return l10n.title return l10n.title
return self.title_l10n 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): class BaseAccountL10n(db.Model):
"""A localized base account title.""" """A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n" __tablename__ = "accounting_base_accounts_l10n"
"""The table name.""" """The table name."""
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code, account_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code,
onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) nullable=False, primary_key=True)
"""The code of the account.""" """The code of the account."""
@ -87,9 +95,11 @@ class Account(db.Model):
"""An account.""" """An account."""
__tablename__ = "accounting_accounts" __tablename__ = "accounting_accounts"
"""The table name.""" """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.""" """The account ID."""
base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code, base_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False) nullable=False)
"""The code of the base account.""" """The code of the base account."""
@ -104,7 +114,9 @@ class Account(db.Model):
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now()) server_default=db.func.now())
"""The time of creation.""" """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) nullable=False)
"""The ID of the creator.""" """The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id) 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, updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """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) nullable=False)
"""The ID of the updator.""" """The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) 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", l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False) lazy=False)
"""The localized titles.""" """The localized titles."""
db.UniqueConstraint(base_code, no)
__CASH = "1111-001" __CASH = "1111-001"
"""The code of the cash account,""" """The code of the cash account,"""
@ -182,16 +195,14 @@ class Account(db.Model):
if l10n.locale == current_locale: if l10n.locale == current_locale:
l10n.title = value l10n.title = value
return return
self.l10n.append(AccountL10n( self.l10n.append(AccountL10n(locale=current_locale, title=value))
locale=current_locale, title=value))
@classmethod @classmethod
def find_by_code(cls, code: str) -> t.Self | None: 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. :param code: The code.
:return: The accounting account, or None if this account does not :return: The account, or None if this account does not exist.
exist.
""" """
m = re.match("^([1-9]{4})-([0-9]{3})$", code) m = re.match("^([1-9]{4})-([0-9]{3})$", code)
if m is None: if m is None:
@ -288,8 +299,21 @@ class Account(db.Model):
""" """
return cls.find_by_code(cls.__NET_CHANGE) 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: def delete(self) -> None:
"""Deletes this accounting account. """Deletes this account.
:return: None. :return: None.
""" """
@ -301,10 +325,128 @@ class Account(db.Model):
class AccountL10n(db.Model): class AccountL10n(db.Model):
"""A localized account title.""" """A localized account title."""
__tablename__ = "accounting_accounts_l10n" __tablename__ = "accounting_accounts_l10n"
account_id = db.Column(db.Integer, db.ForeignKey(Account.id, """The table name."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) nullable=False, primary_key=True)
"""The account ID."""
account = db.relationship(Account, back_populates="l10n") account = db.relationship(Account, back_populates="l10n")
"""The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
title = db.Column(db.String, nullable=False) 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 * First written: 2023/2/1
*/ */
.clickable { .accounting-clickable {
cursor: pointer; cursor: pointer;
} }
.btn-group .btn .search-input { .btn-group .btn .accounting-search-input {
min-height: calc(1em + .5rem + 2px); min-height: calc(1em + .5rem + 2px);
padding: 0 0.5rem; padding: 0 0.5rem;
} }
.btn-group .btn .search-label button { .btn-group .btn .accounting-search-label button {
border: none; border: none;
background-color: transparent; background-color: transparent;
color: inherit; color: inherit;
padding-right: 0; padding-right: 0;
} }
/** The account management */ /** The card layout */
.account { .accounting-card {
padding: 2em 1.5em; padding: 2em 1.5em;
margin: 1em; margin: 1em;
background-color: #E9ECEF; background-color: #E9ECEF;
border-radius: 0.3em; 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); 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-size: 1.8rem;
font-weight: bolder; font-weight: bolder;
} }
.account .account-code { .accounting-card-code {
font-size: 1.4rem; font-size: 1.4rem;
color: #373b3e; color: #373b3e;
} }
.list-base-selector {
/** The option selector */
.accounting-selector-list {
height: 20rem; height: 20rem;
overflow-y: scroll; overflow-y: scroll;
} }
/* The Material Design text field (floating form control in Bootstrap) */ /* The Material Design text field (floating form control in Bootstrap) */
.material-text-field { .accounting-material-text-field {
position: relative; position: relative;
min-height: calc(3.5rem + 2px); min-height: calc(3.5rem + 2px);
padding-top: 1.625rem; padding-top: 1.625rem;
} }
.material-text-field > .form-label { .accounting-material-text-field > .form-label {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -71,27 +73,27 @@
transform-origin: 0 0; transform-origin: 0 0;
transition: opacity .1s ease-in-out,transform .1s ease-in-out; 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; opacity: 0.65;
transform: scale(.85) translateY(-.5rem) translateX(.15rem); transform: scale(.85) translateY(-.5rem) translateX(.15rem);
} }
/* The Material Design floating action buttons */ /* The Material Design floating action buttons */
.material-fab { .accounting-material-fab {
position: fixed; position: fixed;
right: 2rem; right: 2rem;
bottom: 1rem; bottom: 1rem;
z-index: 10; z-index: 10;
flex-direction: column-reverse; flex-direction: column-reverse;
} }
.material-fab .btn { .accounting-material-fab .btn {
border-radius: 50%; border-radius: 50%;
transform: scale(1.5); 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); 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; display: block;
margin-top: 2.5rem; 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); 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. // Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
initializeBaseAccountSelector() initializeBaseAccountSelector();
document.getElementById("account-base-code") document.getElementById("accounting-base-code")
.onchange = validateBase; .onchange = validateBase;
document.getElementById("account-title") document.getElementById("accounting-title")
.onchange = validateTitle; .onchange = validateTitle;
document.getElementById("account-form") document.getElementById("accounting-form")
.onsubmit = validateForm; .onsubmit = validateForm;
}); });
@ -38,25 +38,25 @@ document.addEventListener("DOMContentLoaded", function () {
* @private * @private
*/ */
function initializeBaseAccountSelector() { function initializeBaseAccountSelector() {
const selector = document.getElementById("select-base-modal"); const selector = document.getElementById("accounting-base-selector-model");
const base = document.getElementById("account-base"); const base = document.getElementById("accounting-base");
const baseCode = document.getElementById("account-base-code"); const baseCode = document.getElementById("accounting-base-code");
const baseContent = document.getElementById("account-base-content"); const baseContent = document.getElementById("accounting-base-content");
const options = Array.from(document.getElementsByClassName("list-group-item-base")); const options = Array.from(document.getElementsByClassName("accounting-base-option"));
const btnClear = document.getElementById("btn-clear-base"); const btnClear = document.getElementById("accounting-btn-clear-base");
selector.addEventListener("show.bs.modal", function () { selector.addEventListener("show.bs.modal", function () {
base.classList.add("not-empty"); base.classList.add("accounting-not-empty");
options.forEach(function (item) { options.forEach(function (item) {
item.classList.remove("active"); 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) { if (selected !== null) {
selected.classList.add("active"); selected.classList.add("active");
} }
}); });
selector.addEventListener("hidden.bs.modal", function () { selector.addEventListener("hidden.bs.modal", function () {
if (baseCode.value === "") { if (baseCode.value === "") {
base.classList.remove("not-empty"); base.classList.remove("accounting-not-empty");
} }
}); });
options.forEach(function (option) { options.forEach(function (option) {
@ -79,6 +79,54 @@ function initializeBaseAccountSelector() {
validateBase(); validateBase();
bootstrap.Modal.getInstance(selector).hide(); 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 * @private
*/ */
function validateBase() { function validateBase() {
const field = document.getElementById("account-base-code"); const field = document.getElementById("accounting-base-code");
const error = document.getElementById("account-base-code-error"); const error = document.getElementById("accounting-base-code-error");
const displayField = document.getElementById("account-base"); const displayField = document.getElementById("accounting-base");
field.value = field.value.trim(); field.value = field.value.trim();
if (field.value === "") { if (field.value === "") {
displayField.classList.add("is-invalid"); displayField.classList.add("is-invalid");
@ -122,8 +170,8 @@ function validateBase() {
* @private * @private
*/ */
function validateTitle() { function validateTitle() {
const field = document.getElementById("account-title"); const field = document.getElementById("accounting-title");
const error = document.getElementById("account-title-error"); const error = document.getElementById("accounting-title-error");
field.value = field.value.trim(); field.value = field.value.trim();
if (field.value === "") { if (field.value === "") {
field.classList.add("is-invalid"); 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 %} {% block content %}
<div class="btn-group mb-3"> <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> <i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }} {{ A_("Back") }}
</a> </a>
{% if can_edit_accounting() %} {% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}"> <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> <i class="fa-solid fa-gear"></i>
{{ A_("Settings") }} {{ A_("Settings") }}
</a> </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> <i class="fa-solid fa-trash"></i>
{{ A_("Delete") }} {{ A_("Delete") }}
</button> </button>
{% endif %} {% endif %}
</div> </div>
{% if can_edit_accounting() %} {% if accounting_can_edit() %}
<div class="d-md-none material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}"> <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> <i class="fa-solid fa-pen-to-square"></i>
</a> </a>
</div> </div>
{% endif %} {% endif %}
{% if can_edit_accounting() %} {% if accounting_can_edit() %}
<form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post"> <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %} {% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}"> <input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %} {% 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-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -76,9 +82,9 @@ First written: 2023/1/31
</form> </form>
{% endif %} {% endif %}
<div class="account col-sm-6"> <div class="accounting-card col-sm-6">
<div class="account-title">{{ obj.title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_offset_needed %} {% if obj.is_offset_needed %}
<div> <div>
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span> <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 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 %} {% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}

View File

@ -34,16 +34,16 @@ First written: 2023/2/1
</a> </a>
</div> </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 }} {{ form.csrf_token }}
{% if "next" in request.args %} {% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}"> <input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %} {% endif %}
<div class="form-floating mb-3"> <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 }}"> <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="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"> <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 id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label> <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
<div id="account-base-content"> <div id="accounting-base-content">
{% if form.base_code.data %} {% if form.base_code.data %}
{% if form.base_code.errors %} {% if form.base_code.errors %}
{{ A_("(Unknown)") }} {{ A_("(Unknown)") }}
@ -53,18 +53,18 @@ First written: 2023/2/1
{% endif %} {% endif %}
</div> </div>
</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>
<div class="form-floating mb-3"> <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"> <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="account-title">{{ A_("Title") }}</label> <label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
<div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div> <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div> </div>
<div class="form-check form-switch mb-3"> <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 %}> <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="account-is-offset-needed"> <label class="form-check-label" for="accounting-is-offset-needed">
{{ A_("The entries in the account need offsets.") }} {{ A_("The entries in the account need offsets.") }}
</label> </label>
</div> </div>
@ -76,43 +76,44 @@ First written: 2023/2/1
</button> </button>
</div> </div>
<div class="d-md-none material-fab"> <div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit"> <button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i> <i class="fa-solid fa-floppy-disk"></i>
</button> </button>
</div> </div>
</form> </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-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="input-group mb-2"> <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"> <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="select-base-query"> <label class="input-group-text" for="accounting-base-selector-query">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }} {{ A_("Search") }}
</label> </label>
</div> </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 %} {% 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 }} {{ base }}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
{% if form.base_code.data %} {% 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 %} {% 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 %} {% endif %}
</div> </div>
</div> </div>

View File

@ -21,20 +21,20 @@ First written: 2023/1/30
#} #}
{% extends "accounting/base.html" %} {% 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 %} {% block content %}
<div class="btn-group mb-2"> <div class="btn-group mb-2">
{% if can_edit_accounting() %} {% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}"> <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-user-plus"></i> <i class="fa-solid fa-plus"></i>
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search"> <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"> <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="search-input" class="search-label"> <label for="accounting-search" class="accounting-search-label">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }} {{ A_("Search") }}
@ -43,9 +43,9 @@ First written: 2023/1/30
</form> </form>
</div> </div>
{% if can_edit_accounting() %} {% if accounting_can_edit() %}
<div class="d-md-none material-fab"> <div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}"> <a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
</a> </a>
</div> </div>
@ -56,7 +56,7 @@ First written: 2023/1/30
<div class="list-group"> <div class="list-group">
{% for item in list %} {% 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 }} {{ item }}
{% if item.is_offset_needed %} {% if item.is_offset_needed %}
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span> <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 %} {% block content %}
<div class="btn-group mb-3"> <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> <i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }} {{ A_("Back") }}
</a> </a>
</div> </div>
<div class="account col-sm-6"> <div class="accounting-card col-sm-6">
<div class="account-title">{{ obj.title }}</div> <div class="accounting-card-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div> <div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %} {% if obj.accounts %}
<div> <div>
{% for account in obj.accounts %} {% 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 }} {{ account }}
</a> </a>
{% endfor %} {% endfor %}

View File

@ -21,14 +21,14 @@ First written: 2023/1/26
#} #}
{% extends "accounting/base.html" %} {% 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 %} {% block content %}
<div class="btn-group mb-2"> <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"> <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"> <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="search-input" class="search-label"> <label for="accounting-search" class="accounting-search-label">
<button type="submit"> <button type="submit">
<i class="fa-solid fa-magnifying-glass"></i> <i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }} {{ A_("Search") }}
@ -42,7 +42,7 @@ First written: 2023/1/26
<div class="list-group"> <div class="list-group">
{% for item in list %} {% 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 }} {{ item }}
</a> </a>
{% endfor %} {% 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) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/26 First written: 2023/1/26
#} #}
{% if can_view_accounting() %} {% if accounting_can_view() %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown"> <span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear"></i>
@ -38,6 +38,12 @@ First written: 2023/1/26
{{ A_("Base Accounts") }} {{ A_("Base Accounts") }}
</a> </a>
</li> </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> </ul>
</li> </li>
{% endif %} {% endif %}

View File

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

View File

@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-01 19:51+0800\n" "POT-Creation-Date: 2023-02-07 16:22+0800\n"
"PO-Revision-Date: 2023-02-01 19:52+0800\n" "PO-Revision-Date: 2023-02-07 18:04+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -19,35 +19,94 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\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." msgid "The base account does not exist."
msgstr "沒有這個基本科目。" 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 #: src/accounting/static/js/account-form.js:110
msgid "Please select the base account." msgid "Please select the base account."
msgstr "請選擇基本科目。" msgstr "請選擇基本科目。"
#: src/accounting/account/forms.py:53 #: src/accounting/account/forms.py:67
msgid "Please fill in the title" msgid "Please fill in the title"
msgstr "請填上標題。" 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 #: src/accounting/account/views.py:88
msgid "The account is added successfully" msgid "The account is added successfully"
msgstr "科目加好了。" msgstr "科目加好了。"
#: src/accounting/account/views.py:140 #: src/accounting/account/views.py:143
msgid "The account was not modified." msgid "The account was not modified."
msgstr "科目未異動。" msgstr "科目未異動。"
#: src/accounting/account/views.py:145 #: src/accounting/account/views.py:148
msgid "The account is updated successfully." msgid "The account is updated successfully."
msgstr "科目存好了。" msgstr "科目存好了。"
#: src/accounting/account/views.py:163 #: src/accounting/account/views.py:165
msgid "The account is deleted successfully." msgid "The account is deleted successfully."
msgstr "科目刪掉了" 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 #: src/accounting/static/js/account-form.js:130
msgid "Please fill in the title." msgid "Please fill in the title."
msgstr "請填上標題。" msgstr "請填上標題。"
@ -58,45 +117,53 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/account/detail.html:31 #: src/accounting/templates/accounting/account/detail.html:31
#: src/accounting/templates/accounting/account/include/form.html:33 #: 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/base-account/detail.html:31
#: src/accounting/templates/accounting/currency/detail.html:31
#: src/accounting/templates/accounting/currency/include/form.html:33
msgid "Back" msgid "Back"
msgstr "回上頁" msgstr "回上頁"
#: src/accounting/templates/accounting/account/detail.html:36 #: src/accounting/templates/accounting/account/detail.html:36
#: src/accounting/templates/accounting/currency/detail.html:36
msgid "Settings" msgid "Settings"
msgstr "設定" 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" msgid "Delete"
msgstr "刪除" msgstr "刪除"
#: src/accounting/templates/accounting/account/detail.html:63 #: src/accounting/templates/accounting/account/detail.html:69
msgid "Delete Account Confirmation" msgid "Delete Account Confirmation"
msgstr "科目刪除確認" 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?" msgid "Do you really want to delete this account?"
msgstr "你確定要刪掉這個科目嗎?" 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/account/include/form.html:111
#: src/accounting/templates/accounting/currency/detail.html:72
msgid "Cancel" msgid "Cancel"
msgstr "取消" 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" msgid "Confirm"
msgstr "確定" msgstr "確定"
#: src/accounting/templates/accounting/account/detail.html:84 #: src/accounting/templates/accounting/account/detail.html:94
#: src/accounting/templates/accounting/account/list.html:62 #: src/accounting/templates/accounting/currency/detail.html:85
msgid "Offset needed"
msgstr "逐筆核銷"
#: src/accounting/templates/accounting/account/detail.html:88
msgid "Created" msgid "Created"
msgstr "建檔" 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" msgid "Updated"
msgstr "更新" msgstr "更新"
@ -105,25 +172,47 @@ msgstr "更新"
msgid "%(account)s Settings" msgid "%(account)s Settings"
msgstr "%(account)s設定" 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 #: src/accounting/templates/accounting/account/list.html:24
msgid "Account Management" msgid "Account Management"
msgstr "科目管理" msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32 #: src/accounting/templates/accounting/account/list.html:32
#: src/accounting/templates/accounting/currency/list.html:32
msgid "New" msgid "New"
msgstr "新增" msgstr "新增"
#: src/accounting/templates/accounting/account/include/form.html:98 #: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40 #: src/accounting/templates/accounting/account/list.html:40
#: src/accounting/templates/accounting/base-account/list.html:34 #: src/accounting/templates/accounting/base-account/list.html:34
#: src/accounting/templates/accounting/currency/list.html:40
msgid "Search" msgid "Search"
msgstr "搜尋" msgstr "搜尋"
#: src/accounting/templates/accounting/account/list.html:68 #: 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/base-account/list.html:51
#: src/accounting/templates/accounting/currency/list.html:57
msgid "There is no data." msgid "There is no data."
msgstr "沒有資料。" 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 #: src/accounting/templates/accounting/account/include/form.html:45
msgid "Base account" msgid "Base account"
msgstr "基本科目" msgstr "基本科目"
@ -140,10 +229,6 @@ msgstr "標題"
msgid "The entries in the account need offsets." msgid "The entries in the account need offsets."
msgstr "帳目要逐筆核銷。" msgstr "帳目要逐筆核銷。"
#: src/accounting/templates/accounting/account/include/form.html:75
msgid "Save"
msgstr "儲存"
#: src/accounting/templates/accounting/account/include/form.html:90 #: src/accounting/templates/accounting/account/include/form.html:90
msgid "Select Base Account" msgid "Select Base Account"
msgstr "選擇基本科目" msgstr "選擇基本科目"
@ -157,6 +242,35 @@ msgstr "清除"
msgid "Base Account Managements" msgid "Base Account Managements"
msgstr "基本科目管理" 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 #: src/accounting/templates/accounting/include/nav.html:26
msgid "Accounting" msgid "Accounting"
msgstr "記帳" msgstr "記帳"
@ -169,11 +283,17 @@ msgstr "科目"
msgid "Base Accounts" msgid "Base Accounts"
msgstr "基本科目" msgstr "基本科目"
#: src/accounting/utils/pagination.py:146 #: src/accounting/templates/accounting/include/nav.html:44
msgid "Previous" msgid "Currencies"
msgstr "前一頁" 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" msgid "Next"
msgstr "下一頁" 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, \ from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
urlunparse urlunparse
from flask import request from flask import request, Blueprint
def append_next(uri: str) -> str: 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: list[str] = list(uri_p)
parts[4] = urlencode(params) parts[4] = urlencode(params)
return urlunparse(parts) 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 ParseResult
from flask import request from flask import request
from werkzeug.routing import RequestRedirect
from accounting.locale import gettext from accounting.locale import gettext, pgettext
class PageLink: class Link:
"""A link in the pagination.""" """A link."""
def __init__(self, text: str, uri: str | None = None, def __init__(self, text: str, uri: str | None = None,
is_current: bool = False, is_for_mobile: bool = False): is_current: bool = False, is_for_mobile: bool = False):
@ -52,15 +53,20 @@ class PageLink:
"""Whether the link should be shown on mobile screens.""" """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") T = t.TypeVar("T")
class Pagination(t.Generic[T]): class Pagination(t.Generic[T]):
"""The pagination utilities""" """The pagination utility."""
AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200]
"""The available page sizes."""
DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
def __init__(self, items: list[T], is_reversed: bool = False): def __init__(self, items: list[T], is_reversed: bool = False):
"""Constructs the pagination. """Constructs the pagination.
@ -68,130 +74,186 @@ class Pagination(t.Generic[T]):
:param items: The items. :param items: The items.
:param is_reversed: True if the default page is the last page, or False :param is_reversed: True if the default page is the last page, or False
otherwise. otherwise.
:raise Redirection: When the pagination parameters are malformed.
""" """
self.__items: list[T] = items pagination: AbstractPagination[T] = EmptyPagination[T]() \
"""All the items.""" if len(items) == 0 \
self.__is_reversed: bool = is_reversed else NonEmptyPagination[T](items, is_reversed)
"""Whether the default page is the last page.""" self.is_paged: bool = pagination.is_paged
self.page_size: int = int(request.args.get("page-size", """Whether there should be pagination."""
self.DEFAULT_PAGE_SIZE)) self.list: list[T] = pagination.list
"""The number of items in a page.""" """The items shown in the list"""
self.__total_pages: int = 0 if len(items) == 0 \ self.pages: list[Link] = pagination.pages
else int((len(items) - 1) / self.page_size) + 1 """The pages."""
"""The total number of pages.""" self.page_size: int = pagination.page_size
self.is_needed: bool = self.__total_pages > 1 """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.""" """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] = [] self.list: list[T] = []
"""The items shown in the list""" """The items shown in the list"""
if self.__total_pages > 0: self.pages: list[Link] = []
self.__set_list() """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 \ self.__current_uri: str = request.full_path if request.query_string \
else request.path else request.path
"""The current URI.""" """The current URI."""
self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \ self.__is_reversed: bool = is_reversed
= self.__get_base_uri_params() """Whether the default page is the last page."""
"""The base URI parameters.""" self.page_size = self.__get_page_size()
self.page_links: list[PageLink] = self.__get_page_links() self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1
"""The pagination links.""" """The total number of pages."""
self.page_sizes: list[PageLink] = self.__get_page_sizes() self.is_paged = self.__total_pages > 1
"""The links to switch the number of items in a page.""" self.__default_page_no: int = self.__total_pages \
if self.__is_reversed else 1
def __set_list(self) -> None: """The default page number."""
"""Sets the items to show in the list. self.__page_no: int = self.__get_page_no()
"""The current page number."""
:return: None. lower_bound: int = (self.__page_no - 1) * self.page_size
"""
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
upper_bound: int = lower_bound + self.page_size upper_bound: int = lower_bound + self.page_size
if upper_bound > len(self.__items): if upper_bound > len(items):
upper_bound = len(self.__items) upper_bound = len(items)
self.list = self.__items[lower_bound:upper_bound] 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]]]: def __get_page_size(self) -> int:
"""Returns the base URI and its parameters, with the "page-no" and """Returns the page size.
"page-size" parameters removed.
: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) if "page-size" not in request.args:
params: list[tuple[str, str]] = parse_qsl(uri_p.query) return DEFAULT_PAGE_SIZE
params = [x for x in params if x[0] not in ["page-no", "page-size"]] try:
parts: list[str] = list(uri_p) page_size: int = int(request.args["page-size"])
return parts, params 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. """Returns the page links in the pagination navigation.
:return: 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 [] return []
uri: str | None uri: str | None
links: list[PageLink] = [] links: list[Link] = []
# The previous page. # The previous page.
uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1) uri = None if self.__page_no == 1 \
links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True)) else self.__uri_page(self.__page_no - 1)
links.append(Link(pgettext("Pagination|", "Previous"), uri,
is_for_mobile=True))
# The first page. # The first page.
if self.page_no > 1: if self.__page_no > 1:
links.append(PageLink("1", self.__uri_page(1))) links.append(Link("1", self.__uri_page(1)))
# The eclipse of the previous pages. # The eclipse of the previous pages.
if self.page_no - 3 == 2: if self.__page_no - 3 == 2:
links.append(PageLink(str(self.page_no - 3), links.append(Link(str(self.__page_no - 3),
self.__uri_page(self.page_no - 3))) self.__uri_page(self.__page_no - 3)))
elif self.page_no - 3 > 2: elif self.__page_no - 3 > 2:
links.append(PageLink("")) links.append(Link(""))
# The previous two pages. # The previous two pages.
if self.page_no - 2 > 1: if self.__page_no - 2 > 1:
links.append(PageLink(str(self.page_no - 2), links.append(Link(str(self.__page_no - 2),
self.__uri_page(self.page_no - 2))) self.__uri_page(self.__page_no - 2)))
if self.page_no - 1 > 1: if self.__page_no - 1 > 1:
links.append(PageLink(str(self.page_no - 1), links.append(Link(str(self.__page_no - 1),
self.__uri_page(self.page_no - 1))) self.__uri_page(self.__page_no - 1)))
# The current page. # The current page.
links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no), links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no),
is_current=True)) is_current=True))
# The next two pages. # The next two pages.
if self.page_no + 1 < self.__total_pages: if self.__page_no + 1 < self.__total_pages:
links.append(PageLink(str(self.page_no + 1), links.append(Link(str(self.__page_no + 1),
self.__uri_page(self.page_no + 1))) self.__uri_page(self.__page_no + 1)))
if self.page_no + 2 < self.__total_pages: if self.__page_no + 2 < self.__total_pages:
links.append(PageLink(str(self.page_no + 2), links.append(Link(str(self.__page_no + 2),
self.__uri_page(self.page_no + 2))) self.__uri_page(self.__page_no + 2)))
# The eclipse of the next pages. # The eclipse of the next pages.
if self.page_no + 3 == self.__total_pages - 1: if self.__page_no + 3 == self.__total_pages - 1:
links.append(PageLink(str(self.page_no + 3), links.append(Link(str(self.__page_no + 3),
self.__uri_page(self.page_no + 3))) self.__uri_page(self.__page_no + 3)))
elif self.page_no + 3 < self.__total_pages - 1: elif self.__page_no + 3 < self.__total_pages - 1:
links.append(PageLink("")) links.append(Link(""))
# The last page. # The last page.
if self.page_no < self.__total_pages: if self.__page_no < self.__total_pages:
links.append(PageLink(str(self.__total_pages), links.append(Link(str(self.__total_pages),
self.__uri_page(self.__total_pages))) self.__uri_page(self.__total_pages)))
# The next page. # The next page.
uri = None if self.page_no == self.__total_pages \ uri = None if self.__page_no == self.__total_pages \
else self.__uri_page(self.page_no + 1) else self.__uri_page(self.__page_no + 1)
links.append(PageLink(gettext("Next"), uri, is_for_mobile=True)) links.append(Link(pgettext("Pagination|", "Next"), uri,
is_for_mobile=True))
return links return links
@ -201,21 +263,22 @@ class Pagination(t.Generic[T]):
:param page_no: The page number. :param page_no: The page number.
:return: The URI of the page. :return: The URI of the page.
""" """
params: list[tuple[str, str]] = [] if page_no == self.__page_no:
if page_no != self.__default_page_no: return self.__current_uri
params.append(("page-no", str(page_no))) if page_no == self.__default_page_no:
if self.page_size != self.DEFAULT_PAGE_SIZE: return self.__uri_set("page-no", None)
params.append(("page-size", str(self.page_size))) return self.__uri_set("page-no", str(page_no))
return self.__uri_set_params(params)
def __get_page_sizes(self) -> list[PageLink]: def __get_page_size_options(self) -> list[Link]:
"""Returns the available page sizes. """Returns the page size options.
:return: The available page sizes. :return: The page size options.
""" """
return [PageLink(str(x), self.__uri_size(x), if not self.is_paged:
return []
return [Link(str(x), self.__uri_size(x),
is_current=x == self.page_size) is_current=x == self.page_size)
for x in self.AVAILABLE_PAGE_SIZES] for x in self.PAGE_SIZE_OPTIONS]
def __uri_size(self, page_size: int) -> str: def __uri_size(self, page_size: int) -> str:
"""Returns the URI of a page size. """Returns the URI of a page size.
@ -225,16 +288,34 @@ class Pagination(t.Generic[T]):
""" """
if page_size == self.page_size: if page_size == self.page_size:
return self.__current_uri 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: def __uri_set(self, name: str, value: str | None) -> str:
"""Returns the URI with the query parameters set. """Raises current URI with a parameter set.
:param params: The query parameters. :param name: The name of the parameter.
:return: The URI with the query parameters set. :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() uri_p: ParseResult = urlparse(self.__current_uri)
cur_params.extend(params) params: list[tuple[str, str]] = parse_qsl(uri_p.query)
parts: list[str] = self.__base_uri_params[0].copy()
parts[4] = urlencode(cur_params) # 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) return urlunparse(parts)

View File

@ -21,7 +21,9 @@ This module should not import any other module from the application.
""" """
import typing as t 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: def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
@ -75,17 +77,22 @@ def can_view() -> bool:
def can_edit() -> bool: def can_edit() -> bool:
"""Returns whether the current user can edit the account data. """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 :return: True if the current user can edit the accounting data, or False
otherwise. otherwise.
""" """
if get_current_user() is None:
return False
return __can_edit_func() 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: can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initializes the application. """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 :param can_view_func: A callback that returns whether the current user can
view the accounting data. view the accounting data.
:param can_edit_func: A callback that returns whether the current user can :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 __can_view_func = can_view_func
if can_edit_func is not None: if can_edit_func is not None:
__can_edit_func = can_edit_func __can_edit_func = can_edit_func
app.jinja_env.globals["can_view_accounting"] = __can_view_func bp.add_app_template_global(can_view, "accounting_can_view")
app.jinja_env.globals["can_edit_accounting"] = __can_edit_func 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 == "": if q == "":
return [] return []
keywords: list[str] = [] keywords: list[str] = []
while q is not None: while True:
m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q) m: re.Match
if m.group(1) is not None: m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
if m is not None:
keywords.append(m.group(1)) keywords.append(m.group(1))
else: q = m.group(2)
keywords.append(m.group(2)) continue
q = m.group(3) 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 return keywords

View File

@ -32,6 +32,6 @@ def new_id(cls: t.Type):
:return: The generated new random ID. :return: The generated new random ID.
""" """
while True: while True:
new: int = 100000000 + randbelow(900000000) obj_id: int = 100000000 + randbelow(900000000)
if db.session.get(cls, new) is None: if db.session.get(cls, obj_id) is None:
return new 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 from opencc import OpenCC
root_dir: Path = Path(__file__).parent.parent 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" domain: str = "messages"
@ -49,7 +49,7 @@ def babel_extract() -> None:
/ f"{domain}.po" / f"{domain}.po"
CommandLineInterface().run([ CommandLineInterface().run([
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_", "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(): if not zh_hant.exists():
zh_hant.touch() zh_hant.touch()
if not zh_hans.exists(): 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. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The test for the base account management. """The test for the base account management.
""" """
@ -25,12 +24,12 @@ from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from testlib import get_csrf_token from test_site import create_app
from testsite import create_app from testlib import get_client
class BaseAccountTestCase(unittest.TestCase): class BaseAccountCommandTestCase(unittest.TestCase):
"""The base account test case.""" """The base account console command test case."""
def setUp(self) -> None: def setUp(self) -> None:
"""Sets up the test. """Sets up the test.
@ -38,24 +37,22 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import BaseAccount, BaseAccountL10n
self.app: Flask = create_app(is_testing=True) self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
result: Result = runner.invoke(args="init-db") result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
self.client: httpx.Client = httpx.Client(app=self.app, BaseAccountL10n.query.delete()
base_url="https://testserver") BaseAccount.query.delete()
self.client.headers["Referer"] = "https://testserver"
self.csrf_token: str = get_csrf_token(self, self.client, "/login")
def test_init(self) -> None: def test_init(self) -> None:
"""Tests the "accounting-init-base" console command. """Tests the "accounting-init-base" console command.
:return: None. :return: None.
""" """
from accounting.models import BaseAccountL10n from accounting.models import BaseAccount, BaseAccountL10n
from accounting.models import BaseAccount
runner: FlaskCliRunner = self.app.test_cli_runner() runner: FlaskCliRunner = self.app.test_cli_runner()
result: Result = runner.invoke(args="accounting-init-base") result: Result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0) 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)
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 response: httpx.Response
self.__logout() response = client.get("/accounting/base-accounts")
response = self.client.get(list_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.__logout() response = client.get("/accounting/base-accounts/1111")
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)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def __logout(self) -> None: def test_viewer(self) -> None:
"""Logs out the currently logged-in user. """Test the permission as viewer.
:return: None. :return: None.
""" """
response: httpx.Response = self.client.post( client, csrf_token = get_client(self, self.app, "viewer")
"/logout", data={"csrf_token": self.csrf_token}) response: httpx.Response
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/")
def __login_as(self, username: str) -> None: response = client.get("/accounting/base-accounts")
"""Logs in as a specific user. 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. :return: None.
""" """
response: httpx.Response = self.client.post( client, csrf_token = get_client(self, self.app, "editor")
"/login", data={"csrf_token": self.csrf_token, response: httpx.Response
"username": username})
self.assertEqual(response.status_code, 302) response = client.get("/accounting/base-accounts")
self.assertEqual(response.headers["Location"], "/") 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 flask_wtf import CSRFProtect
from sqlalchemy import Column from sqlalchemy import Column
import accounting.utils.user
bp: Blueprint = Blueprint("home", __name__) bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS() babel_js: BabelJS = BabelJS()
csrf: CSRFProtect = CSRFProtect() csrf: CSRFProtect = CSRFProtect()
@ -53,7 +55,6 @@ def create_app(is_testing: bool = False) -> Flask:
}) })
if is_testing: if is_testing:
app.config["TESTING"] = True app.config["TESTING"] = True
app.config["SQLALCHEMY_ECHO"] = True
babel_js.init_app(app) babel_js.init_app(app)
csrf.init_app(app) csrf.init_app(app)
@ -68,7 +69,7 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth from . import auth
auth.init_app(app) auth.init_app(app)
class UserUtils(accounting.AbstractUserUtils[auth.User]): class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
@property @property
def cls(self) -> t.Type[auth.User]: def cls(self) -> t.Type[auth.User]:
@ -79,7 +80,7 @@ def create_app(is_testing: bool = False) -> Flask:
return auth.User.id return auth.User.id
@property @property
def current_user(self) -> auth.User: def current_user(self) -> auth.User | None:
return auth.current_user() return auth.current_user()
def get_by_username(self, username: str) -> auth.User | None: 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 return user.id
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \ 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 \ 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(), accounting.init_app(app, user_utils=UserUtils(),
can_view_func=can_view, can_edit_func=can_edit) can_view_func=can_view, can_edit_func=can_edit)
@ -105,7 +106,7 @@ def init_db_command() -> None:
"""Initializes the database.""" """Initializes the database."""
db.create_all() db.create_all()
from .auth import User 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: if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username)) db.session.add(User(username=username))
db.session.commit() db.session.commit()

View File

@ -58,7 +58,8 @@ def login() -> redirect:
:return: The redirection to the home page. :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")) return redirect(url_for("auth.login"))
session["user"] = request.form.get("username") session["user"] = request.form.get("username")
return redirect(url_for("home.home")) 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() }}"> <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="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="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> <button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
</form> </form>

View File

@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-01-28 13:42+0800\n" "POT-Creation-Date: 2023-02-06 23:25+0800\n"
"PO-Revision-Date: 2023-01-28 13:42+0800\n" "PO-Revision-Date: 2023-02-06 23:26+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -20,35 +20,41 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n" "Generated-By: Babel 2.11.0\n"
#: tests/testsite/templates/base.html:23 #: tests/test_site/templates/base.html:23
msgid "en" msgid "en"
msgstr "zh-Hant" 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" msgid "Home"
msgstr "首頁" msgstr "首頁"
#: tests/testsite/templates/base.html:68 #: tests/test_site/templates/base.html:68
msgid "Log Out" msgid "Log Out"
msgstr "" 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" msgid "Log In"
msgstr "登入" msgstr "登入"
#: tests/testsite/templates/base.html:119 #: tests/test_site/templates/base.html:119
msgid "Error:" msgid "Error:"
msgstr "錯誤:" msgstr "錯誤:"
#: tests/testsite/templates/login.html:30 #: tests/test_site/templates/login.html:30
msgid "Viewer" msgid "Viewer"
msgstr "讀報表者" msgstr "讀報表者"
#: tests/testsite/templates/login.html:31 #: tests/test_site/templates/login.html:31
msgid "Editor" msgid "Editor"
msgstr "記帳者" 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" msgid "Nobody"
msgstr "沒有權限者" 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. """The common test libraries.
""" """
import typing as t
from html.parser import HTMLParser from html.parser import HTMLParser
from unittest import TestCase from unittest import TestCase
import httpx 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: 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) parser.feed(response.text)
test_case.assertIsNotNone(parser.csrf_token) test_case.assertIsNotNone(parser.csrf_token)
return 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")