Compare commits
	
		
			74 Commits
		
	
	
		
			v0.1.1
			...
			356d10eb6e
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 356d10eb6e | |||
| 8dc340dbf1 | |||
| 4b5b348270 | |||
| d9585f0e53 | |||
| 5737d6cef4 | |||
| 1d61fa93d3 | |||
| b1c7bc61c4 | |||
| 708a434b5d | |||
| 8e524674a3 | |||
| 699db20308 | |||
| c3cedf714b | |||
| c67ed4471c | |||
| 2d3b9f68b8 | |||
| f82278b48a | |||
| 85480804e7 | |||
| 9e85c14431 | |||
| 31dc8fab04 | |||
| dc24af1db0 | |||
| 59795635ee | |||
| 399afe56c8 | |||
| 16e2a146db | |||
| f7ce94902f | |||
| 5cf3cb1e11 | |||
| a78057a8c3 | |||
| 0491614ae4 | |||
| fb9ff1d7ff | |||
| be10984cbb | |||
| 7b2089bdfb | |||
| be8dc21c5a | |||
| 2f8c6f6981 | |||
| cdd010427b | |||
| d78b941674 | |||
| 570c84c196 | |||
| 7873e16cc3 | |||
| 52351c52bc | |||
| 591fb4a7ab | |||
| 2a6c5de6d6 | |||
| 6b94cfb908 | |||
| eb90e83c98 | |||
| 6bf18be455 | |||
| 895bca2508 | |||
| 6af29e7df7 | |||
| 50f8f06687 | |||
| cd5b1b97fd | |||
| b7dd53d2f9 | |||
| b07b0e3be4 | |||
| e7fb2288ce | |||
| 17ba7659b6 | |||
| 2c8d5e7c8a | |||
| e2f707f696 | |||
| b5c0d0b7b3 | |||
| 7fe2bb6135 | |||
| 4d870f1dcc | |||
| 16b2eb1c93 | |||
| fd63149066 | |||
| a7a432914d | |||
| 1a44f08b90 | |||
| 3e68cfe690 | |||
| 809f2b6df3 | |||
| c286aa8b8b | |||
| 1326d9538c | |||
| b9cecf343a | |||
| 3d9e6c10da | |||
| 5090e59bb1 | |||
| 62697fb782 | |||
| 8c462e7b2c | |||
| 90a8229db9 | |||
| 8be44ccf5f | |||
| 511328a0bd | |||
| 0d8cf85ec0 | |||
| 6e212f0e33 | |||
| 2fbe137243 | |||
| f4e2c21ece | |||
| fff07a2552 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -37,4 +37,4 @@ excludes
 | 
			
		||||
*.pot
 | 
			
		||||
*.mo
 | 
			
		||||
zh_Hans
 | 
			
		||||
node_modules
 | 
			
		||||
test_temp.py
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										53
									
								
								docs/source/accounting.account.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.account.rst
									
									
									
									
									
										Normal 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:
 | 
			
		||||
@@ -12,18 +12,10 @@ accounting.base\_account.commands module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.base\_account.database module
 | 
			
		||||
----------------------------------------
 | 
			
		||||
accounting.base\_account.converters module
 | 
			
		||||
------------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.base_account.database
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.base\_account.models module
 | 
			
		||||
--------------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.base_account.models
 | 
			
		||||
.. automodule:: accounting.base_account.converters
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										53
									
								
								docs/source/accounting.currency.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								docs/source/accounting.currency.rst
									
									
									
									
									
										Normal 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:
 | 
			
		||||
@@ -7,12 +7,22 @@ Subpackages
 | 
			
		||||
.. toctree::
 | 
			
		||||
   :maxdepth: 4
 | 
			
		||||
 | 
			
		||||
   accounting.account
 | 
			
		||||
   accounting.base_account
 | 
			
		||||
   accounting.currency
 | 
			
		||||
   accounting.utils
 | 
			
		||||
 | 
			
		||||
Submodules
 | 
			
		||||
----------
 | 
			
		||||
 | 
			
		||||
accounting.database module
 | 
			
		||||
--------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.database
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.locale module
 | 
			
		||||
------------------------
 | 
			
		||||
 | 
			
		||||
@@ -21,6 +31,14 @@ accounting.locale module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.models module
 | 
			
		||||
------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.models
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
Module contents
 | 
			
		||||
---------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,14 @@ accounting.utils package
 | 
			
		||||
Submodules
 | 
			
		||||
----------
 | 
			
		||||
 | 
			
		||||
accounting.utils.next\_url module
 | 
			
		||||
---------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.next_url
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.pagination module
 | 
			
		||||
----------------------------------
 | 
			
		||||
 | 
			
		||||
@@ -28,6 +36,30 @@ accounting.utils.query module
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.random\_id module
 | 
			
		||||
----------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.random_id
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.strip\_text module
 | 
			
		||||
-----------------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.strip_text
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
accounting.utils.user module
 | 
			
		||||
----------------------------
 | 
			
		||||
 | 
			
		||||
.. automodule:: accounting.utils.user
 | 
			
		||||
   :members:
 | 
			
		||||
   :undoc-members:
 | 
			
		||||
   :show-inheritance:
 | 
			
		||||
 | 
			
		||||
Module contents
 | 
			
		||||
---------------
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@
 | 
			
		||||
 | 
			
		||||
[metadata]
 | 
			
		||||
name = mia-accounting-flask
 | 
			
		||||
version = 0.1.1
 | 
			
		||||
version = 0.2.0
 | 
			
		||||
author = imacat
 | 
			
		||||
author_email = imacat@mail.imacat.idv.tw
 | 
			
		||||
description = The Mia! Accounting Flask project.
 | 
			
		||||
 
 | 
			
		||||
@@ -20,7 +20,6 @@
 | 
			
		||||
import typing as t
 | 
			
		||||
 | 
			
		||||
from flask import Flask, Blueprint
 | 
			
		||||
from flask_sqlalchemy.model import Model
 | 
			
		||||
 | 
			
		||||
from accounting.utils.user import AbstractUserUtils
 | 
			
		||||
 | 
			
		||||
@@ -56,7 +55,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
			
		||||
    locale.init_app(app, bp)
 | 
			
		||||
 | 
			
		||||
    from .utils import permission
 | 
			
		||||
    permission.init_app(app, can_view_func, can_edit_func)
 | 
			
		||||
    permission.init_app(bp, can_view_func, can_edit_func)
 | 
			
		||||
 | 
			
		||||
    from . import base_account
 | 
			
		||||
    base_account.init_app(app, bp)
 | 
			
		||||
@@ -64,9 +63,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
 | 
			
		||||
    from . import account
 | 
			
		||||
    account.init_app(app, bp)
 | 
			
		||||
 | 
			
		||||
    from .utils.next_url import append_next, inherit_next, or_next
 | 
			
		||||
    bp.add_app_template_filter(append_next, "append_next")
 | 
			
		||||
    bp.add_app_template_filter(inherit_next, "inherit_next")
 | 
			
		||||
    bp.add_app_template_filter(or_next, "or_next")
 | 
			
		||||
    from . import currency
 | 
			
		||||
    currency.init_app(app, bp)
 | 
			
		||||
 | 
			
		||||
    from .utils import next_url
 | 
			
		||||
    next_url.init_app(bp)
 | 
			
		||||
 | 
			
		||||
    app.register_blueprint(bp)
 | 
			
		||||
 
 | 
			
		||||
@@ -23,8 +23,8 @@ from flask import Flask, Blueprint
 | 
			
		||||
def init_app(app: Flask, bp: Blueprint) -> None:
 | 
			
		||||
    """Initialize the application.
 | 
			
		||||
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :param app: The Flask application.
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    from .converters import AccountConverter
 | 
			
		||||
 
 | 
			
		||||
@@ -32,7 +32,7 @@ from accounting.utils.user import get_current_user_pk
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseAccountExists:
 | 
			
		||||
    """The validator to check if the base account code exists."""
 | 
			
		||||
    """The validator to check if the base account exists."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data == "":
 | 
			
		||||
@@ -42,13 +42,25 @@ class BaseAccountExists:
 | 
			
		||||
                "The base account does not exist."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseAccountAvailable:
 | 
			
		||||
    """The validator to check if the base account is available."""
 | 
			
		||||
 | 
			
		||||
    def __call__(self, form: FlaskForm, field: StringField) -> None:
 | 
			
		||||
        if field.data == "":
 | 
			
		||||
            return
 | 
			
		||||
        if len(field.data) != 4:
 | 
			
		||||
            raise ValidationError(lazy_gettext(
 | 
			
		||||
                "The base account is not available."))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AccountForm(FlaskForm):
 | 
			
		||||
    """The form to create or edit an account."""
 | 
			
		||||
    base_code = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
        validators=[
 | 
			
		||||
            DataRequired(lazy_gettext("Please select the base account.")),
 | 
			
		||||
            BaseAccountExists()])
 | 
			
		||||
            BaseAccountExists(),
 | 
			
		||||
            BaseAccountAvailable()])
 | 
			
		||||
    """The code of the base account."""
 | 
			
		||||
    title = StringField(
 | 
			
		||||
        filters=[strip_text],
 | 
			
		||||
 
 | 
			
		||||
@@ -38,7 +38,7 @@ bp: Blueprint = Blueprint("account", __name__)
 | 
			
		||||
@bp.get("", endpoint="list")
 | 
			
		||||
@has_permission(can_view)
 | 
			
		||||
def list_accounts() -> str:
 | 
			
		||||
    """Lists the base accounts.
 | 
			
		||||
    """Lists the accounts.
 | 
			
		||||
 | 
			
		||||
    :return: The account list.
 | 
			
		||||
    """
 | 
			
		||||
@@ -139,7 +139,7 @@ def update_account(account: Account) -> redirect:
 | 
			
		||||
                                             account=account)))
 | 
			
		||||
    with db.session.no_autoflush:
 | 
			
		||||
        form.populate_obj(account)
 | 
			
		||||
    if not db.session.is_modified(account):
 | 
			
		||||
    if not account.is_modified:
 | 
			
		||||
        flash(lazy_gettext("The account was not modified."), "success")
 | 
			
		||||
        return redirect(inherit_next(url_for("accounting.account.detail",
 | 
			
		||||
                                             account=account)))
 | 
			
		||||
@@ -159,9 +159,7 @@ def delete_account(account: Account) -> redirect:
 | 
			
		||||
    :return: The redirection to the account list on success, or the account
 | 
			
		||||
        detail on error.
 | 
			
		||||
    """
 | 
			
		||||
    for l10n in account.l10n:
 | 
			
		||||
        db.session.delete(l10n)
 | 
			
		||||
    db.session.delete(account)
 | 
			
		||||
    account.delete()
 | 
			
		||||
    sort_accounts_in(account.base_code, account.id)
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    flash(lazy_gettext("The account is deleted successfully."), "success")
 | 
			
		||||
 
 | 
			
		||||
@@ -23,8 +23,8 @@ from flask import Flask, Blueprint
 | 
			
		||||
def init_app(app: Flask, bp: Blueprint) -> None:
 | 
			
		||||
    """Initialize the application.
 | 
			
		||||
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :param app: The Flask application.
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    from .converters import BaseAccountConverter
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										38
									
								
								src/accounting/currency/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/accounting/currency/__init__.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
							
								
								
									
										78
									
								
								src/accounting/currency/commands.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								src/accounting/currency/commands.py
									
									
									
									
									
										Normal 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.")
 | 
			
		||||
							
								
								
									
										48
									
								
								src/accounting/currency/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/accounting/currency/converters.py
									
									
									
									
									
										Normal 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
 | 
			
		||||
							
								
								
									
										93
									
								
								src/accounting/currency/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/accounting/currency/forms.py
									
									
									
									
									
										Normal 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()
 | 
			
		||||
							
								
								
									
										44
									
								
								src/accounting/currency/query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/accounting/currency/query.py
									
									
									
									
									
										Normal 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()
 | 
			
		||||
							
								
								
									
										178
									
								
								src/accounting/currency/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								src/accounting/currency/views.py
									
									
									
									
									
										Normal 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}
 | 
			
		||||
@@ -25,7 +25,7 @@ time.
 | 
			
		||||
 | 
			
		||||
from flask_sqlalchemy import SQLAlchemy
 | 
			
		||||
 | 
			
		||||
db: SQLAlchemy
 | 
			
		||||
db: SQLAlchemy = SQLAlchemy()
 | 
			
		||||
"""The database instance."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -39,6 +39,17 @@ def gettext(string, **variables) -> str:
 | 
			
		||||
    return domain.gettext(string, **variables)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def pgettext(context, string, **variables) -> str:
 | 
			
		||||
    """A replacement of the Babel gettext() function..
 | 
			
		||||
 | 
			
		||||
    :param context: The context.
 | 
			
		||||
    :param string: The message to translate.
 | 
			
		||||
    :param variables: The variable substitution.
 | 
			
		||||
    :return: The translated message.
 | 
			
		||||
    """
 | 
			
		||||
    return domain.pgettext(context, string, **variables)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def lazy_gettext(string, **variables) -> LazyString:
 | 
			
		||||
    """A replacement of the Babel lazy_gettext() function..
 | 
			
		||||
 | 
			
		||||
@@ -105,8 +116,8 @@ def __babel_js_catalog_view() -> Response:
 | 
			
		||||
def init_app(app: Flask, bp: Blueprint) -> None:
 | 
			
		||||
    """Initializes the application.
 | 
			
		||||
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :param app: The Flask application.
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    bp.add_url_rule("/_jstrans.js", "babel_catalog",
 | 
			
		||||
 
 | 
			
		||||
@@ -64,6 +64,14 @@ class BaseAccount(db.Model):
 | 
			
		||||
                return l10n.title
 | 
			
		||||
        return self.title_l10n
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def query_values(self) -> list[str]:
 | 
			
		||||
        """Returns the values to be queried.
 | 
			
		||||
 | 
			
		||||
        :return: The values to be queried.
 | 
			
		||||
        """
 | 
			
		||||
        return [self.code, self.title_l10n] + [x.title for x in self.l10n]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseAccountL10n(db.Model):
 | 
			
		||||
    """A localized base account title."""
 | 
			
		||||
@@ -187,16 +195,14 @@ class Account(db.Model):
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
                l10n.title = value
 | 
			
		||||
                return
 | 
			
		||||
        self.l10n.append(AccountL10n(
 | 
			
		||||
            locale=current_locale, title=value))
 | 
			
		||||
        self.l10n.append(AccountL10n(locale=current_locale, title=value))
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def find_by_code(cls, code: str) -> t.Self | None:
 | 
			
		||||
        """Finds an accounting account by its code.
 | 
			
		||||
        """Finds an account by its code.
 | 
			
		||||
 | 
			
		||||
        :param code: The code.
 | 
			
		||||
        :return: The accounting account, or None if this account does not
 | 
			
		||||
            exist.
 | 
			
		||||
        :return: The account, or None if this account does not exist.
 | 
			
		||||
        """
 | 
			
		||||
        m = re.match("^([1-9]{4})-([0-9]{3})$", code)
 | 
			
		||||
        if m is None:
 | 
			
		||||
@@ -293,8 +299,21 @@ class Account(db.Model):
 | 
			
		||||
        """
 | 
			
		||||
        return cls.find_by_code(cls.__NET_CHANGE)
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_modified(self) -> bool:
 | 
			
		||||
        """Returns whether a product account was modified.
 | 
			
		||||
 | 
			
		||||
        :return: True if modified, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        if db.session.is_modified(self):
 | 
			
		||||
            return True
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if db.session.is_modified(l10n):
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> None:
 | 
			
		||||
        """Deletes this accounting account.
 | 
			
		||||
        """Deletes this account.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
@@ -306,11 +325,128 @@ class Account(db.Model):
 | 
			
		||||
class AccountL10n(db.Model):
 | 
			
		||||
    """A localized account title."""
 | 
			
		||||
    __tablename__ = "accounting_accounts_l10n"
 | 
			
		||||
    """The table name."""
 | 
			
		||||
    account_id = db.Column(db.Integer,
 | 
			
		||||
                           db.ForeignKey(Account.id, onupdate="CASCADE",
 | 
			
		||||
                                         ondelete="CASCADE"),
 | 
			
		||||
                           nullable=False, primary_key=True)
 | 
			
		||||
    """The account ID."""
 | 
			
		||||
    account = db.relationship(Account, back_populates="l10n")
 | 
			
		||||
    """The account."""
 | 
			
		||||
    locale = db.Column(db.String, nullable=False, primary_key=True)
 | 
			
		||||
    """The locale."""
 | 
			
		||||
    title = db.Column(db.String, nullable=False)
 | 
			
		||||
    db.UniqueConstraint(account_id, locale)
 | 
			
		||||
    """The localized title."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Currency(db.Model):
 | 
			
		||||
    """A currency."""
 | 
			
		||||
    __tablename__ = "accounting_currencies"
 | 
			
		||||
    """The table name."""
 | 
			
		||||
    code = db.Column(db.String, nullable=False, primary_key=True)
 | 
			
		||||
    """The code."""
 | 
			
		||||
    name_l10n = db.Column("name", db.String, nullable=False)
 | 
			
		||||
    """The name."""
 | 
			
		||||
    created_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
			
		||||
                           server_default=db.func.now())
 | 
			
		||||
    """The time of creation."""
 | 
			
		||||
    created_by_id = db.Column(db.Integer,
 | 
			
		||||
                              db.ForeignKey(user_pk_column,
 | 
			
		||||
                                            onupdate="CASCADE"),
 | 
			
		||||
                              nullable=False)
 | 
			
		||||
    """The ID of the creator."""
 | 
			
		||||
    created_by = db.relationship(user_cls, foreign_keys=created_by_id)
 | 
			
		||||
    """The creator."""
 | 
			
		||||
    updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
 | 
			
		||||
                           server_default=db.func.now())
 | 
			
		||||
    """The time of last update."""
 | 
			
		||||
    updated_by_id = db.Column(db.Integer,
 | 
			
		||||
                              db.ForeignKey(user_pk_column,
 | 
			
		||||
                                            onupdate="CASCADE"),
 | 
			
		||||
                              nullable=False)
 | 
			
		||||
    """The ID of the updator."""
 | 
			
		||||
    updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
 | 
			
		||||
    """The updator."""
 | 
			
		||||
    l10n = db.relationship("CurrencyL10n", back_populates="currency",
 | 
			
		||||
                           lazy=False)
 | 
			
		||||
    """The localized names."""
 | 
			
		||||
 | 
			
		||||
    def __str__(self) -> str:
 | 
			
		||||
        """Returns the string representation of the currency.
 | 
			
		||||
 | 
			
		||||
        :return: The string representation of the currency.
 | 
			
		||||
        """
 | 
			
		||||
        return F"{self.name} ({self.code})"
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        """Returns the name in the current locale.
 | 
			
		||||
 | 
			
		||||
        :return: The name in the current locale.
 | 
			
		||||
        """
 | 
			
		||||
        current_locale = str(get_locale())
 | 
			
		||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
			
		||||
            return self.name_l10n
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
                return l10n.name
 | 
			
		||||
        return self.name_l10n
 | 
			
		||||
 | 
			
		||||
    @name.setter
 | 
			
		||||
    def name(self, value: str) -> None:
 | 
			
		||||
        """Sets the name in the current locale.
 | 
			
		||||
 | 
			
		||||
        :param value: The new name.
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        if self.name_l10n is None:
 | 
			
		||||
            self.name_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
        current_locale = str(get_locale())
 | 
			
		||||
        if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
 | 
			
		||||
            self.name_l10n = value
 | 
			
		||||
            return
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if l10n.locale == current_locale:
 | 
			
		||||
                l10n.name = value
 | 
			
		||||
                return
 | 
			
		||||
        self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def is_modified(self) -> bool:
 | 
			
		||||
        """Returns whether a product account was modified.
 | 
			
		||||
 | 
			
		||||
        :return: True if modified, or False otherwise.
 | 
			
		||||
        """
 | 
			
		||||
        if db.session.is_modified(self):
 | 
			
		||||
            return True
 | 
			
		||||
        for l10n in self.l10n:
 | 
			
		||||
            if db.session.is_modified(l10n):
 | 
			
		||||
                return True
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> None:
 | 
			
		||||
        """Deletes the currency.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
 | 
			
		||||
        cls: t.Type[t.Self] = self.__class__
 | 
			
		||||
        cls.query.filter(cls.code == self.code).delete()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CurrencyL10n(db.Model):
 | 
			
		||||
    """A localized currency name."""
 | 
			
		||||
    __tablename__ = "accounting_currencies_l10n"
 | 
			
		||||
    """The table name."""
 | 
			
		||||
    currency_code = db.Column(db.String,
 | 
			
		||||
                              db.ForeignKey(Currency.code, onupdate="CASCADE",
 | 
			
		||||
                                            ondelete="CASCADE"),
 | 
			
		||||
                              nullable=False, primary_key=True)
 | 
			
		||||
    """The currency code."""
 | 
			
		||||
    currency = db.relationship(Currency, back_populates="l10n")
 | 
			
		||||
    """The currency."""
 | 
			
		||||
    locale = db.Column(db.String, nullable=False, primary_key=True)
 | 
			
		||||
    """The locale."""
 | 
			
		||||
    name = db.Column(db.String, nullable=False)
 | 
			
		||||
    """The localized name."""
 | 
			
		||||
 
 | 
			
		||||
@@ -21,48 +21,50 @@
 | 
			
		||||
 * First written: 2023/2/1
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
.clickable {
 | 
			
		||||
.accounting-clickable {
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
.btn-group .btn .search-input {
 | 
			
		||||
.btn-group .btn .accounting-search-input {
 | 
			
		||||
    min-height: calc(1em + .5rem + 2px);
 | 
			
		||||
    padding: 0 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
.btn-group .btn .search-label button {
 | 
			
		||||
.btn-group .btn .accounting-search-label button {
 | 
			
		||||
    border: none;
 | 
			
		||||
    background-color: transparent;
 | 
			
		||||
    color: inherit;
 | 
			
		||||
    padding-right: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** The account management */
 | 
			
		||||
.account {
 | 
			
		||||
/** The card layout */
 | 
			
		||||
.accounting-card {
 | 
			
		||||
    padding: 2em 1.5em;
 | 
			
		||||
    margin: 1em;
 | 
			
		||||
    background-color: #E9ECEF;
 | 
			
		||||
    border-radius: 0.3em;
 | 
			
		||||
    box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
 | 
			
		||||
}
 | 
			
		||||
.account .account-title {
 | 
			
		||||
.accounting-card-title {
 | 
			
		||||
    font-size: 1.8rem;
 | 
			
		||||
    font-weight: bolder;
 | 
			
		||||
}
 | 
			
		||||
.account .account-code {
 | 
			
		||||
.accounting-card-code {
 | 
			
		||||
    font-size: 1.4rem;
 | 
			
		||||
    color: #373b3e;
 | 
			
		||||
}
 | 
			
		||||
.list-base-selector {
 | 
			
		||||
 | 
			
		||||
/** The option selector */
 | 
			
		||||
.accounting-selector-list {
 | 
			
		||||
    height: 20rem;
 | 
			
		||||
    overflow-y: scroll;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The Material Design text field (floating form control in Bootstrap) */
 | 
			
		||||
.material-text-field {
 | 
			
		||||
.accounting-material-text-field {
 | 
			
		||||
    position: relative;
 | 
			
		||||
    min-height: calc(3.5rem + 2px);
 | 
			
		||||
    padding-top: 1.625rem;
 | 
			
		||||
}
 | 
			
		||||
.material-text-field > .form-label {
 | 
			
		||||
.accounting-material-text-field > .form-label {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 0;
 | 
			
		||||
    left: 0;
 | 
			
		||||
@@ -71,27 +73,27 @@
 | 
			
		||||
    transform-origin: 0 0;
 | 
			
		||||
    transition: opacity .1s ease-in-out,transform .1s ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
.material-text-field.not-empty > .form-label {
 | 
			
		||||
.accounting-material-text-field.accounting-not-empty > .form-label {
 | 
			
		||||
    opacity: 0.65;
 | 
			
		||||
    transform: scale(.85) translateY(-.5rem) translateX(.15rem);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* The Material Design floating action buttons */
 | 
			
		||||
.material-fab {
 | 
			
		||||
.accounting-material-fab {
 | 
			
		||||
    position: fixed;
 | 
			
		||||
    right: 2rem;
 | 
			
		||||
    bottom: 1rem;
 | 
			
		||||
    z-index: 10;
 | 
			
		||||
    flex-direction: column-reverse;
 | 
			
		||||
}
 | 
			
		||||
.material-fab .btn {
 | 
			
		||||
.accounting-material-fab .btn {
 | 
			
		||||
    border-radius: 50%;
 | 
			
		||||
    transform: scale(1.5);
 | 
			
		||||
    box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
 | 
			
		||||
    display: block;
 | 
			
		||||
    margin-top: 2.5rem;
 | 
			
		||||
}
 | 
			
		||||
.material-fab .btn:hover, .material-fab .btn:focus {
 | 
			
		||||
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
 | 
			
		||||
    box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -24,11 +24,11 @@
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
    initializeBaseAccountSelector();
 | 
			
		||||
    document.getElementById("account-base-code")
 | 
			
		||||
    document.getElementById("accounting-base-code")
 | 
			
		||||
        .onchange = validateBase;
 | 
			
		||||
    document.getElementById("account-title")
 | 
			
		||||
    document.getElementById("accounting-title")
 | 
			
		||||
        .onchange = validateTitle;
 | 
			
		||||
    document.getElementById("account-form")
 | 
			
		||||
    document.getElementById("accounting-form")
 | 
			
		||||
        .onsubmit = validateForm;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -38,25 +38,25 @@ document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function initializeBaseAccountSelector() {
 | 
			
		||||
    const selector = document.getElementById("select-base-modal");
 | 
			
		||||
    const base = document.getElementById("account-base");
 | 
			
		||||
    const baseCode = document.getElementById("account-base-code");
 | 
			
		||||
    const baseContent = document.getElementById("account-base-content");
 | 
			
		||||
    const options = Array.from(document.getElementsByClassName("list-group-item-base"));
 | 
			
		||||
    const btnClear = document.getElementById("btn-clear-base");
 | 
			
		||||
    const selector = document.getElementById("accounting-base-selector-model");
 | 
			
		||||
    const base = document.getElementById("accounting-base");
 | 
			
		||||
    const baseCode = document.getElementById("accounting-base-code");
 | 
			
		||||
    const baseContent = document.getElementById("accounting-base-content");
 | 
			
		||||
    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
			
		||||
    const btnClear = document.getElementById("accounting-btn-clear-base");
 | 
			
		||||
    selector.addEventListener("show.bs.modal", function () {
 | 
			
		||||
        base.classList.add("not-empty");
 | 
			
		||||
        base.classList.add("accounting-not-empty");
 | 
			
		||||
        options.forEach(function (item) {
 | 
			
		||||
            item.classList.remove("active");
 | 
			
		||||
        });
 | 
			
		||||
        const selected = document.getElementById("list-group-item-base-" + baseCode.value);
 | 
			
		||||
        const selected = document.getElementById("accounting-base-option-" + baseCode.value);
 | 
			
		||||
        if (selected !== null) {
 | 
			
		||||
            selected.classList.add("active");
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    selector.addEventListener("hidden.bs.modal", function () {
 | 
			
		||||
        if (baseCode.value === "") {
 | 
			
		||||
            base.classList.remove("not-empty");
 | 
			
		||||
            base.classList.remove("accounting-not-empty");
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    options.forEach(function (option) {
 | 
			
		||||
@@ -79,6 +79,54 @@ function initializeBaseAccountSelector() {
 | 
			
		||||
        validateBase();
 | 
			
		||||
        bootstrap.Modal.getInstance(selector).hide();
 | 
			
		||||
    }
 | 
			
		||||
    initializeBaseAccountQuery();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Initializes the query on the base account options.
 | 
			
		||||
 *
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function initializeBaseAccountQuery() {
 | 
			
		||||
    const query = document.getElementById("accounting-base-selector-query");
 | 
			
		||||
    const optionList = document.getElementById("accounting-base-option-list");
 | 
			
		||||
    const options = Array.from(document.getElementsByClassName("accounting-base-option"));
 | 
			
		||||
    const queryNoResult = document.getElementById("accounting-base-option-no-result");
 | 
			
		||||
    query.addEventListener("input", function () {
 | 
			
		||||
        console.log(query.value);
 | 
			
		||||
        if (query.value === "") {
 | 
			
		||||
            options.forEach(function (option) {
 | 
			
		||||
                option.classList.remove("d-none");
 | 
			
		||||
            });
 | 
			
		||||
            optionList.classList.remove("d-none");
 | 
			
		||||
            queryNoResult.classList.add("d-none");
 | 
			
		||||
            return
 | 
			
		||||
        }
 | 
			
		||||
        let hasAnyMatched = false;
 | 
			
		||||
        options.forEach(function (option) {
 | 
			
		||||
            const queryValues = JSON.parse(option.dataset.queryValues);
 | 
			
		||||
            let isMatched = false;
 | 
			
		||||
            for (let i = 0; i < queryValues.length; i++) {
 | 
			
		||||
                if (queryValues[i].includes(query.value)) {
 | 
			
		||||
                    isMatched = true;
 | 
			
		||||
                    break;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (isMatched) {
 | 
			
		||||
                option.classList.remove("d-none");
 | 
			
		||||
                hasAnyMatched = true;
 | 
			
		||||
            } else {
 | 
			
		||||
                option.classList.add("d-none");
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        if (!hasAnyMatched) {
 | 
			
		||||
            optionList.classList.add("d-none");
 | 
			
		||||
            queryNoResult.classList.remove("d-none");
 | 
			
		||||
        } else {
 | 
			
		||||
            optionList.classList.remove("d-none");
 | 
			
		||||
            queryNoResult.classList.add("d-none");
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
@@ -101,9 +149,9 @@ function validateForm() {
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateBase() {
 | 
			
		||||
    const field = document.getElementById("account-base-code");
 | 
			
		||||
    const error = document.getElementById("account-base-code-error");
 | 
			
		||||
    const displayField = document.getElementById("account-base");
 | 
			
		||||
    const field = document.getElementById("accounting-base-code");
 | 
			
		||||
    const error = document.getElementById("accounting-base-code-error");
 | 
			
		||||
    const displayField = document.getElementById("accounting-base");
 | 
			
		||||
    field.value = field.value.trim();
 | 
			
		||||
    if (field.value === "") {
 | 
			
		||||
        displayField.classList.add("is-invalid");
 | 
			
		||||
@@ -122,8 +170,8 @@ function validateBase() {
 | 
			
		||||
 * @private
 | 
			
		||||
 */
 | 
			
		||||
function validateTitle() {
 | 
			
		||||
    const field = document.getElementById("account-title");
 | 
			
		||||
    const error = document.getElementById("account-title-error");
 | 
			
		||||
    const field = document.getElementById("accounting-title");
 | 
			
		||||
    const error = document.getElementById("accounting-title-error");
 | 
			
		||||
    field.value = field.value.trim();
 | 
			
		||||
    if (field.value === "") {
 | 
			
		||||
        field.classList.add("is-invalid");
 | 
			
		||||
 
 | 
			
		||||
@@ -23,13 +23,13 @@
 | 
			
		||||
 | 
			
		||||
// Initializes the page JavaScript.
 | 
			
		||||
document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
    const list = document.getElementById("account-order-list");
 | 
			
		||||
    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("account-order-" + accounts[i].dataset.id + "-no");
 | 
			
		||||
                const code = document.getElementById("account-order-" + accounts[i].dataset.id + "-code");
 | 
			
		||||
                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);
 | 
			
		||||
            }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										174
									
								
								src/accounting/static/js/currency-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								src/accounting/static/js/currency-form.js
									
									
									
									
									
										Normal 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;
 | 
			
		||||
}
 | 
			
		||||
@@ -26,47 +26,47 @@ First written: 2023/1/31
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
  </a>
 | 
			
		||||
  {% if can_edit_accounting() %}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-gear"></i>
 | 
			
		||||
      {{ A_("Settings") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|append_next }}">
 | 
			
		||||
  <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 can_edit_accounting() %}
 | 
			
		||||
    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
 | 
			
		||||
      <i class="fa-solid fa-trash"></i>
 | 
			
		||||
      {{ A_("Delete") }}
 | 
			
		||||
    </button>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% if can_edit_accounting() %}
 | 
			
		||||
  <div class="d-md-none material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
 | 
			
		||||
      <i class="fa-solid fa-pen-to-square"></i>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
{% if can_edit_accounting() %}
 | 
			
		||||
  <form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
 | 
			
		||||
    <input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
 | 
			
		||||
    <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
    {% if "next" in request.args %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
 | 
			
		||||
    <div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
 | 
			
		||||
      <div class="modal-dialog">
 | 
			
		||||
        <div class="modal-content">
 | 
			
		||||
          <div class="modal-header">
 | 
			
		||||
            <h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
 | 
			
		||||
            <h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
 | 
			
		||||
            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="modal-body">
 | 
			
		||||
@@ -82,9 +82,9 @@ First written: 2023/1/31
 | 
			
		||||
  </form>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<div class="account col-sm-6">
 | 
			
		||||
  <div class="account-title">{{ obj.title }}</div>
 | 
			
		||||
  <div class="account-code">{{ obj.code }}</div>
 | 
			
		||||
<div class="accounting-card col-sm-6">
 | 
			
		||||
  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
			
		||||
  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
			
		||||
  {% if obj.is_offset_needed %}
 | 
			
		||||
    <div>
 | 
			
		||||
      <span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,6 @@ First written: 2023/2/1
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|inherit_next }}{% endblock %}
 | 
			
		||||
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|accounting_inherit_next }}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}
 | 
			
		||||
 
 | 
			
		||||
@@ -34,16 +34,16 @@ First written: 2023/2/1
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<form id="account-form" action="{% block action_url %}{% endblock %}" method="post">
 | 
			
		||||
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
 | 
			
		||||
  {{ form.csrf_token }}
 | 
			
		||||
  {% if "next" in request.args %}
 | 
			
		||||
    <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <input id="account-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
 | 
			
		||||
    <div id="account-base" class="form-control clickable material-text-field {% if form.base_code.data %} not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#select-base-modal">
 | 
			
		||||
      <label id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label>
 | 
			
		||||
      <div id="account-base-content">
 | 
			
		||||
    <input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
 | 
			
		||||
    <div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-model">
 | 
			
		||||
      <label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
 | 
			
		||||
      <div id="accounting-base-content">
 | 
			
		||||
        {% if form.base_code.data %}
 | 
			
		||||
          {% if form.base_code.errors %}
 | 
			
		||||
            {{ A_("(Unknown)") }}
 | 
			
		||||
@@ -53,18 +53,18 @@ First written: 2023/2/1
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div id="account-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
 | 
			
		||||
    <div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="form-floating mb-3">
 | 
			
		||||
    <input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
 | 
			
		||||
    <label class="form-label" for="account-title">{{ A_("Title") }}</label>
 | 
			
		||||
    <div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
 | 
			
		||||
    <input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
 | 
			
		||||
    <label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
 | 
			
		||||
    <div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="form-check form-switch mb-3">
 | 
			
		||||
    <input id="account-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
 | 
			
		||||
    <label class="form-check-label" for="account-is-offset-needed">
 | 
			
		||||
    <input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
 | 
			
		||||
    <label class="form-check-label" for="accounting-is-offset-needed">
 | 
			
		||||
      {{ A_("The entries in the account need offsets.") }}
 | 
			
		||||
    </label>
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -76,43 +76,44 @@ First written: 2023/2/1
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
 | 
			
		||||
  <div class="d-md-none material-fab">
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <button class="btn btn-primary" type="submit">
 | 
			
		||||
      <i class="fa-solid fa-floppy-disk"></i>
 | 
			
		||||
    </button>
 | 
			
		||||
  </div>
 | 
			
		||||
</form>
 | 
			
		||||
 | 
			
		||||
<div class="modal fade" id="select-base-modal" tabindex="-1" aria-labelledby="select-base-model-label" aria-hidden="true">
 | 
			
		||||
<div class="modal fade" id="accounting-base-selector-model" tabindex="-1" aria-labelledby="accounting-base-selector-model-label" aria-hidden="true">
 | 
			
		||||
  <div class="modal-dialog">
 | 
			
		||||
    <div class="modal-content">
 | 
			
		||||
      <div class="modal-header">
 | 
			
		||||
        <h1 class="modal-title fs-5" id="base-selector-model-label">{{ A_("Select Base Account") }}</h1>
 | 
			
		||||
        <h1 class="modal-title fs-5" id="accounting-base-selector-model-label">{{ A_("Select Base Account") }}</h1>
 | 
			
		||||
        <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-body">
 | 
			
		||||
        <div class="input-group mb-2">
 | 
			
		||||
          <input id="select-base-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
 | 
			
		||||
          <label class="input-group-text" for="select-base-query">
 | 
			
		||||
          <input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
 | 
			
		||||
          <label class="input-group-text" for="accounting-base-selector-query">
 | 
			
		||||
            <i class="fa-solid fa-magnifying-glass"></i>
 | 
			
		||||
            {{ A_("Search") }}
 | 
			
		||||
          </label>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <ul class="list-group list-base-selector">
 | 
			
		||||
        <ul id="accounting-base-option-list" class="list-group accounting-selector-list">
 | 
			
		||||
          {% for base in form.base_options %}
 | 
			
		||||
          <li id="list-group-item-base-{{ base.code }}" class="list-group-item list-group-item-base clickable" data-code="{{ base.code }}" data-content="{{ base }}">
 | 
			
		||||
          <li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
 | 
			
		||||
            {{ base }}
 | 
			
		||||
          </li>
 | 
			
		||||
          {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        <p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="modal-footer">
 | 
			
		||||
        <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
 | 
			
		||||
        {% if form.base_code.data %}
 | 
			
		||||
          <button id="btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
 | 
			
		||||
          <button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
 | 
			
		||||
        {% else %}
 | 
			
		||||
          <button id="btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
 | 
			
		||||
          <button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -21,20 +21,20 @@ First written: 2023/1/30
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Account Management") }}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-2">
 | 
			
		||||
  {% if can_edit_accounting() %}
 | 
			
		||||
    <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
 | 
			
		||||
      <i class="fa-solid fa-user-plus"></i>
 | 
			
		||||
  {% if accounting_can_edit() %}
 | 
			
		||||
    <a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
			
		||||
      <i class="fa-solid fa-plus"></i>
 | 
			
		||||
      {{ A_("New") }}
 | 
			
		||||
    </a>
 | 
			
		||||
  {% endif %}
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
 | 
			
		||||
    <input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
			
		||||
    <label for="search-input" class="search-label">
 | 
			
		||||
    <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
			
		||||
    <label for="accounting-search" class="accounting-search-label">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
        <i class="fa-solid fa-magnifying-glass"></i>
 | 
			
		||||
        {{ A_("Search") }}
 | 
			
		||||
@@ -43,9 +43,9 @@ First written: 2023/1/30
 | 
			
		||||
  </form>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% if can_edit_accounting() %}
 | 
			
		||||
  <div class="d-md-none material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}">
 | 
			
		||||
{% if accounting_can_edit() %}
 | 
			
		||||
  <div class="d-md-none accounting-material-fab">
 | 
			
		||||
    <a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
 | 
			
		||||
      <i class="fa-solid fa-plus"></i>
 | 
			
		||||
    </a>
 | 
			
		||||
  </div>
 | 
			
		||||
@@ -56,7 +56,7 @@ First written: 2023/1/30
 | 
			
		||||
 | 
			
		||||
  <div class="list-group">
 | 
			
		||||
  {% for item in list %}
 | 
			
		||||
    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|append_next }}">
 | 
			
		||||
    <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
 | 
			
		||||
      {{ item }}
 | 
			
		||||
      {% if item.is_offset_needed %}
 | 
			
		||||
        <span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
 | 
			
		||||
 
 | 
			
		||||
@@ -31,24 +31,24 @@ First written: 2023/2/2
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
{% if base.accounts|length > 1 and can_edit_accounting() %}
 | 
			
		||||
{% if base.accounts|length > 1 and accounting_can_edit() %}
 | 
			
		||||
  <form action="{{ url_for("accounting.account.sort", base=base) }}" 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 %}
 | 
			
		||||
      <input type="hidden" name="next" value="{{ request.args["next"] }}">
 | 
			
		||||
    {% endif %}
 | 
			
		||||
    <ul id="account-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
 | 
			
		||||
    <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="account-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
 | 
			
		||||
          <input id="accounting-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
 | 
			
		||||
          <div>
 | 
			
		||||
            <span id="account-order-{{ account.id }}-code">{{ account.code }}</span>
 | 
			
		||||
            <span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
 | 
			
		||||
            {{ account.title }}
 | 
			
		||||
          </div>
 | 
			
		||||
          <i class="fa-solid fa-bars"></i>
 | 
			
		||||
@@ -63,7 +63,7 @@ First written: 2023/2/2
 | 
			
		||||
      </button>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="d-md-none material-fab">
 | 
			
		||||
    <div class="d-md-none accounting-material-fab">
 | 
			
		||||
      <button class="btn btn-primary" type="submit">
 | 
			
		||||
        <i class="fa-solid fa-floppy-disk"></i>
 | 
			
		||||
      </button>
 | 
			
		||||
 
 | 
			
		||||
@@ -26,19 +26,19 @@ First written: 2023/2/1
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-3">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
 | 
			
		||||
  <a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
 | 
			
		||||
    <i class="fa-solid fa-circle-chevron-left"></i>
 | 
			
		||||
    {{ A_("Back") }}
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="account col-sm-6">
 | 
			
		||||
  <div class="account-title">{{ obj.title }}</div>
 | 
			
		||||
  <div class="account-code">{{ obj.code }}</div>
 | 
			
		||||
<div class="accounting-card col-sm-6">
 | 
			
		||||
  <div class="accounting-card-title">{{ obj.title }}</div>
 | 
			
		||||
  <div class="accounting-card-code">{{ obj.code }}</div>
 | 
			
		||||
  {% if obj.accounts %}
 | 
			
		||||
    <div>
 | 
			
		||||
    {% for account in obj.accounts %}
 | 
			
		||||
      <a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|append_next }}">
 | 
			
		||||
      <a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
 | 
			
		||||
        {{ account }}
 | 
			
		||||
      </a>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
@@ -21,14 +21,14 @@ First written: 2023/1/26
 | 
			
		||||
#}
 | 
			
		||||
{% extends "accounting/base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block header %}{% block title %}{{ A_("Base Account Managements") }}{% endblock %}{% endblock %}
 | 
			
		||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
 | 
			
		||||
<div class="btn-group mb-2">
 | 
			
		||||
  <form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
 | 
			
		||||
    <input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
			
		||||
    <label for="search-input" class="search-label">
 | 
			
		||||
    <input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
 | 
			
		||||
    <label for="accounting-search" class="accounting-search-label">
 | 
			
		||||
      <button type="submit">
 | 
			
		||||
        <i class="fa-solid fa-magnifying-glass"></i>
 | 
			
		||||
        {{ A_("Search") }}
 | 
			
		||||
@@ -42,7 +42,7 @@ First written: 2023/1/26
 | 
			
		||||
 | 
			
		||||
  <div class="list-group">
 | 
			
		||||
    {% for item in list %}
 | 
			
		||||
      <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|append_next }}">
 | 
			
		||||
      <a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}">
 | 
			
		||||
        {{ item }}
 | 
			
		||||
      </a>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										28
									
								
								src/accounting/templates/accounting/currency/create.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								src/accounting/templates/accounting/currency/create.html
									
									
									
									
									
										Normal 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 %}
 | 
			
		||||
							
								
								
									
										90
									
								
								src/accounting/templates/accounting/currency/detail.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								src/accounting/templates/accounting/currency/detail.html
									
									
									
									
									
										Normal 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 %}
 | 
			
		||||
							
								
								
									
										30
									
								
								src/accounting/templates/accounting/currency/edit.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								src/accounting/templates/accounting/currency/edit.html
									
									
									
									
									
										Normal 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 %}
 | 
			
		||||
@@ -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 %}
 | 
			
		||||
							
								
								
									
										68
									
								
								src/accounting/templates/accounting/currency/list.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								src/accounting/templates/accounting/currency/list.html
									
									
									
									
									
										Normal 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 %}
 | 
			
		||||
@@ -19,7 +19,7 @@ nav.html: The navigation menu for the accounting application.
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/1/26
 | 
			
		||||
#}
 | 
			
		||||
{% if can_view_accounting() %}
 | 
			
		||||
{% if accounting_can_view() %}
 | 
			
		||||
  <li class="nav-item dropdown">
 | 
			
		||||
    <span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
 | 
			
		||||
      <i class="fa-solid fa-gear"></i>
 | 
			
		||||
@@ -38,6 +38,12 @@ First written: 2023/1/26
 | 
			
		||||
          {{ A_("Base Accounts") }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </li>
 | 
			
		||||
      <li>
 | 
			
		||||
        <a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
 | 
			
		||||
          <i class="fa-solid fa-money-bill-wave"></i>
 | 
			
		||||
          {{ A_("Currencies") }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </li>
 | 
			
		||||
    </ul>
 | 
			
		||||
  </li>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,10 +19,10 @@ pagination.html: The pagination navigation bar.
 | 
			
		||||
Author: imacat@mail.imacat.idv.tw (imacat)
 | 
			
		||||
First written: 2023/1/26
 | 
			
		||||
#}
 | 
			
		||||
{% if pagination.is_needed %}
 | 
			
		||||
{% if pagination.is_paged %}
 | 
			
		||||
  <nav aria-label="Page navigation">
 | 
			
		||||
    <ul class="pagination">
 | 
			
		||||
      {% for link in pagination.page_links %}
 | 
			
		||||
      {% for link in pagination.pages %}
 | 
			
		||||
        {% if link.uri is none %}
 | 
			
		||||
          <li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
 | 
			
		||||
            <span class="page-link">
 | 
			
		||||
@@ -42,7 +42,7 @@ First written: 2023/1/26
 | 
			
		||||
          {{ pagination.page_size }}
 | 
			
		||||
        </div>
 | 
			
		||||
        <ul class="dropdown-menu">
 | 
			
		||||
          {% for link in pagination.page_sizes %}
 | 
			
		||||
          {% for link in pagination.page_size_options %}
 | 
			
		||||
            <li>
 | 
			
		||||
              <a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
 | 
			
		||||
                {{ link.text }}
 | 
			
		||||
 
 | 
			
		||||
@@ -8,8 +8,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
			
		||||
"POT-Creation-Date: 2023-02-03 10:15+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-02-03 10:16+0800\n"
 | 
			
		||||
"POT-Creation-Date: 2023-02-07 16:22+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-02-07 18:04+0800\n"
 | 
			
		||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
"Language: zh_Hant\n"
 | 
			
		||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
@@ -23,17 +23,21 @@ msgstr ""
 | 
			
		||||
msgid "The base account does not exist."
 | 
			
		||||
msgstr "沒有這個基本科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/forms.py:50
 | 
			
		||||
#: src/accounting/account/forms.py:52
 | 
			
		||||
msgid "The base account is not available."
 | 
			
		||||
msgstr "不能選這個基本科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/forms.py:61
 | 
			
		||||
#: src/accounting/static/js/account-form.js:110
 | 
			
		||||
msgid "Please select the base account."
 | 
			
		||||
msgstr "請選擇基本科目。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/forms.py:55
 | 
			
		||||
#: src/accounting/account/forms.py:67
 | 
			
		||||
msgid "Please fill in the title"
 | 
			
		||||
msgstr "請填上標題。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/query.py:50
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:88
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:90
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:62
 | 
			
		||||
msgid "Offset needed"
 | 
			
		||||
msgstr "逐筆核銷"
 | 
			
		||||
@@ -50,18 +54,59 @@ msgstr "科目未異動。"
 | 
			
		||||
msgid "The account is updated successfully."
 | 
			
		||||
msgstr "科目存好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/views.py:167
 | 
			
		||||
#: src/accounting/account/views.py:165
 | 
			
		||||
msgid "The account is deleted successfully."
 | 
			
		||||
msgstr "科目刪掉了"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/views.py:194
 | 
			
		||||
#: src/accounting/account/views.py:192
 | 
			
		||||
msgid "The order was not modified."
 | 
			
		||||
msgstr "順序未異動。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/account/views.py:197
 | 
			
		||||
#: src/accounting/account/views.py:195
 | 
			
		||||
msgid "The order is updated successfully."
 | 
			
		||||
msgstr "順序存好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/forms.py:47
 | 
			
		||||
#: src/accounting/static/js/currency-form.js:136
 | 
			
		||||
msgid "Code conflicts with another currency."
 | 
			
		||||
msgstr "代碼與其它貨幣重複。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/forms.py:52
 | 
			
		||||
#: src/accounting/static/js/currency-form.js:92
 | 
			
		||||
msgid "Please fill in the code."
 | 
			
		||||
msgstr "請填上代碼。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/forms.py:54
 | 
			
		||||
#: src/accounting/static/js/currency-form.js:103
 | 
			
		||||
msgid "Code can only be composed of 3 upper-cased letters."
 | 
			
		||||
msgstr "代碼限為三個大寫英文字母。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/forms.py:57
 | 
			
		||||
#: src/accounting/static/js/currency-form.js:98
 | 
			
		||||
msgid "This code is not available."
 | 
			
		||||
msgstr "不能用這個代碼。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/forms.py:63
 | 
			
		||||
#: src/accounting/static/js/currency-form.js:168
 | 
			
		||||
msgid "Please fill in the name."
 | 
			
		||||
msgstr "請填上名稱。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/views.py:90
 | 
			
		||||
msgid "The currency is added successfully"
 | 
			
		||||
msgstr "貨幣加好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/views.py:146
 | 
			
		||||
msgid "The currency was not modified."
 | 
			
		||||
msgstr "貨幣未異動。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/views.py:151
 | 
			
		||||
msgid "The currency is updated successfully."
 | 
			
		||||
msgstr "貨幣存好了。"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/currency/views.py:167
 | 
			
		||||
msgid "The currency is deleted successfully."
 | 
			
		||||
msgstr "貨幣刪掉了"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/static/js/account-form.js:130
 | 
			
		||||
msgid "Please fill in the title."
 | 
			
		||||
msgstr "請填上標題。"
 | 
			
		||||
@@ -74,43 +119,51 @@ msgstr "新增科目"
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:33
 | 
			
		||||
#: src/accounting/templates/accounting/account/order.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/base-account/detail.html:31
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:31
 | 
			
		||||
#: src/accounting/templates/accounting/currency/include/form.html:33
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "回上頁"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:36
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:36
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr "設定"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:40
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:41
 | 
			
		||||
msgid "Order"
 | 
			
		||||
msgstr "次序"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:44
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:46
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:42
 | 
			
		||||
msgid "Delete"
 | 
			
		||||
msgstr "刪除"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:67
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:69
 | 
			
		||||
msgid "Delete Account Confirmation"
 | 
			
		||||
msgstr "科目刪除確認"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:71
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:73
 | 
			
		||||
msgid "Do you really want to delete this account?"
 | 
			
		||||
msgstr "你確定要刪掉這個科目嗎?"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:74
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:76
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:111
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:72
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "取消"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:75
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:77
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:73
 | 
			
		||||
msgid "Confirm"
 | 
			
		||||
msgstr "確定"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:92
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:94
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:85
 | 
			
		||||
msgid "Created"
 | 
			
		||||
msgstr "建檔"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:93
 | 
			
		||||
#: src/accounting/templates/accounting/account/detail.html:95
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:86
 | 
			
		||||
msgid "Updated"
 | 
			
		||||
msgstr "更新"
 | 
			
		||||
 | 
			
		||||
@@ -119,22 +172,33 @@ msgstr "更新"
 | 
			
		||||
msgid "%(account)s Settings"
 | 
			
		||||
msgstr "%(account)s設定"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:24
 | 
			
		||||
#: src/accounting/templates/accounting/base-account/list.html:24
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:24
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Search Result for \"%(query)s\""
 | 
			
		||||
msgstr "「%(query)s」搜尋結果"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:24
 | 
			
		||||
msgid "Account Management"
 | 
			
		||||
msgstr "科目管理"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:32
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:32
 | 
			
		||||
msgid "New"
 | 
			
		||||
msgstr "新增"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/include/form.html:98
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:40
 | 
			
		||||
#: src/accounting/templates/accounting/base-account/list.html:34
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:40
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr "搜尋"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/account/list.html:68
 | 
			
		||||
#: src/accounting/templates/accounting/account/order.html:81
 | 
			
		||||
#: src/accounting/templates/accounting/base-account/list.html:51
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:57
 | 
			
		||||
msgid "There is no data."
 | 
			
		||||
msgstr "沒有資料。"
 | 
			
		||||
 | 
			
		||||
@@ -144,7 +208,8 @@ 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:61
 | 
			
		||||
#: src/accounting/templates/accounting/account/order.html:62
 | 
			
		||||
#: src/accounting/templates/accounting/currency/include/form.html:57
 | 
			
		||||
msgid "Save"
 | 
			
		||||
msgstr "儲存"
 | 
			
		||||
 | 
			
		||||
@@ -177,6 +242,35 @@ msgstr "清除"
 | 
			
		||||
msgid "Base Account Managements"
 | 
			
		||||
msgstr "基本科目管理"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/create.html:24
 | 
			
		||||
msgid "Add a New Currency"
 | 
			
		||||
msgstr "新增貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:65
 | 
			
		||||
msgid "Delete Currency Confirmation"
 | 
			
		||||
msgstr "貨幣刪除確認"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/detail.html:69
 | 
			
		||||
msgid "Do you really want to delete this currency?"
 | 
			
		||||
msgstr "你確定要刪掉這個貨幣嗎?"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/edit.html:24
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "%(currency)s Settings"
 | 
			
		||||
msgstr "%(currency)s設定"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/list.html:24
 | 
			
		||||
msgid "Currency Management"
 | 
			
		||||
msgstr "貨幣管理"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/include/form.html:44
 | 
			
		||||
msgid "Code"
 | 
			
		||||
msgstr "代碼"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/currency/include/form.html:50
 | 
			
		||||
msgid "Name"
 | 
			
		||||
msgstr "名稱"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:26
 | 
			
		||||
msgid "Accounting"
 | 
			
		||||
msgstr "記帳"
 | 
			
		||||
@@ -189,11 +283,17 @@ msgstr "科目"
 | 
			
		||||
msgid "Base Accounts"
 | 
			
		||||
msgstr "基本科目"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/pagination.py:146
 | 
			
		||||
msgid "Previous"
 | 
			
		||||
msgstr "前一頁"
 | 
			
		||||
#: src/accounting/templates/accounting/include/nav.html:44
 | 
			
		||||
msgid "Currencies"
 | 
			
		||||
msgstr "貨幣"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/pagination.py:194
 | 
			
		||||
#: src/accounting/utils/pagination.py:206
 | 
			
		||||
msgctxt "Pagination|"
 | 
			
		||||
msgid "Previous"
 | 
			
		||||
msgstr "上一頁"
 | 
			
		||||
 | 
			
		||||
#: src/accounting/utils/pagination.py:255
 | 
			
		||||
msgctxt "Pagination|"
 | 
			
		||||
msgid "Next"
 | 
			
		||||
msgstr "下一頁"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -22,7 +22,7 @@ This module should not import any other module from the application.
 | 
			
		||||
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
 | 
			
		||||
    urlunparse
 | 
			
		||||
 | 
			
		||||
from flask import request
 | 
			
		||||
from flask import request, Blueprint
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def append_next(uri: str) -> str:
 | 
			
		||||
@@ -73,3 +73,14 @@ def __set_next(uri: str, next_uri: str) -> str:
 | 
			
		||||
    parts: list[str] = list(uri_p)
 | 
			
		||||
    parts[4] = urlencode(params)
 | 
			
		||||
    return urlunparse(parts)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(bp: Blueprint) -> None:
 | 
			
		||||
    """Initializes the application.
 | 
			
		||||
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    bp.add_app_template_filter(append_next, "accounting_append_next")
 | 
			
		||||
    bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
 | 
			
		||||
    bp.add_app_template_filter(or_next, "accounting_or_next")
 | 
			
		||||
 
 | 
			
		||||
@@ -24,12 +24,13 @@ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
 | 
			
		||||
    ParseResult
 | 
			
		||||
 | 
			
		||||
from flask import request
 | 
			
		||||
from werkzeug.routing import RequestRedirect
 | 
			
		||||
 | 
			
		||||
from accounting.locale import gettext
 | 
			
		||||
from accounting.locale import gettext, pgettext
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PageLink:
 | 
			
		||||
    """A link in the pagination."""
 | 
			
		||||
class Link:
 | 
			
		||||
    """A link."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, text: str, uri: str | None = None,
 | 
			
		||||
                 is_current: bool = False, is_for_mobile: bool = False):
 | 
			
		||||
@@ -52,15 +53,20 @@ class PageLink:
 | 
			
		||||
        """Whether the link should be shown on mobile screens."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Redirection(RequestRedirect):
 | 
			
		||||
    """The redirection."""
 | 
			
		||||
    code = 302
 | 
			
		||||
    """The HTTP code."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
DEFAULT_PAGE_SIZE: int = 10
 | 
			
		||||
"""The default page size."""
 | 
			
		||||
 | 
			
		||||
T = t.TypeVar("T")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Pagination(t.Generic[T]):
 | 
			
		||||
    """The pagination utilities"""
 | 
			
		||||
    AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200]
 | 
			
		||||
    """The available page sizes."""
 | 
			
		||||
    DEFAULT_PAGE_SIZE: int = 10
 | 
			
		||||
    """The default page size."""
 | 
			
		||||
    """The pagination utility."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, items: list[T], is_reversed: bool = False):
 | 
			
		||||
        """Constructs the pagination.
 | 
			
		||||
@@ -68,130 +74,186 @@ class Pagination(t.Generic[T]):
 | 
			
		||||
        :param items: The items.
 | 
			
		||||
        :param is_reversed: True if the default page is the last page, or False
 | 
			
		||||
            otherwise.
 | 
			
		||||
        :raise Redirection: When the pagination parameters are malformed.
 | 
			
		||||
        """
 | 
			
		||||
        self.__items: list[T] = items
 | 
			
		||||
        """All the items."""
 | 
			
		||||
        self.__is_reversed: bool = is_reversed
 | 
			
		||||
        """Whether the default page is the last page."""
 | 
			
		||||
        self.page_size: int = int(request.args.get("page-size",
 | 
			
		||||
                                                   self.DEFAULT_PAGE_SIZE))
 | 
			
		||||
        """The number of items in a page."""
 | 
			
		||||
        self.__total_pages: int = 0 if len(items) == 0 \
 | 
			
		||||
            else int((len(items) - 1) / self.page_size) + 1
 | 
			
		||||
        """The total number of pages."""
 | 
			
		||||
        self.is_needed: bool = self.__total_pages > 1
 | 
			
		||||
        pagination: AbstractPagination[T] = EmptyPagination[T]() \
 | 
			
		||||
            if len(items) == 0 \
 | 
			
		||||
            else NonEmptyPagination[T](items, is_reversed)
 | 
			
		||||
        self.is_paged: bool = pagination.is_paged
 | 
			
		||||
        """Whether there should be pagination."""
 | 
			
		||||
        self.list: list[T] = pagination.list
 | 
			
		||||
        """The items shown in the list"""
 | 
			
		||||
        self.pages: list[Link] = pagination.pages
 | 
			
		||||
        """The pages."""
 | 
			
		||||
        self.page_size: int = pagination.page_size
 | 
			
		||||
        """The number of items in a page."""
 | 
			
		||||
        self.page_size_options: list[Link] = pagination.page_size_options
 | 
			
		||||
        """The options to the number of items in a page."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AbstractPagination(t.Generic[T]):
 | 
			
		||||
    """An abstract pagination."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        """Constructs an empty pagination."""
 | 
			
		||||
        self.page_size: int = DEFAULT_PAGE_SIZE
 | 
			
		||||
        """The number of items in a page."""
 | 
			
		||||
        self.is_paged: bool = False
 | 
			
		||||
        """Whether there should be pagination."""
 | 
			
		||||
        self.__default_page_no: int = 0
 | 
			
		||||
        """The default page number."""
 | 
			
		||||
        self.page_no: int = 0
 | 
			
		||||
        """The current page number."""
 | 
			
		||||
        self.list: list[T] = []
 | 
			
		||||
        """The items shown in the list"""
 | 
			
		||||
        if self.__total_pages > 0:
 | 
			
		||||
            self.__set_list()
 | 
			
		||||
        self.pages: list[Link] = []
 | 
			
		||||
        """The pages."""
 | 
			
		||||
        self.page_size_options: list[Link] = []
 | 
			
		||||
        """The options to the number of items in a page."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EmptyPagination(AbstractPagination[T]):
 | 
			
		||||
    """The pagination from empty data."""
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NonEmptyPagination(AbstractPagination[T]):
 | 
			
		||||
    """The pagination with real data."""
 | 
			
		||||
    PAGE_SIZE_OPTIONS: list[int] = [10, 100, 200]
 | 
			
		||||
    """The page size options."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, items: list[T], is_reversed: bool = False):
 | 
			
		||||
        """Constructs the pagination.
 | 
			
		||||
 | 
			
		||||
        :param items: The items.
 | 
			
		||||
        :param is_reversed: True if the default page is the last page, or False
 | 
			
		||||
            otherwise.
 | 
			
		||||
        :raise Redirection: When the pagination parameters are malformed.
 | 
			
		||||
        """
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.__current_uri: str = request.full_path if request.query_string \
 | 
			
		||||
            else request.path
 | 
			
		||||
        """The current URI."""
 | 
			
		||||
        self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \
 | 
			
		||||
            = self.__get_base_uri_params()
 | 
			
		||||
        """The base URI parameters."""
 | 
			
		||||
        self.page_links: list[PageLink] = self.__get_page_links()
 | 
			
		||||
        """The pagination links."""
 | 
			
		||||
        self.page_sizes: list[PageLink] = self.__get_page_sizes()
 | 
			
		||||
        """The links to switch the number of items in a page."""
 | 
			
		||||
 | 
			
		||||
    def __set_list(self) -> None:
 | 
			
		||||
        """Sets the items to show in the list.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        self.__default_page_no = self.__total_pages if self.__is_reversed \
 | 
			
		||||
            else 1
 | 
			
		||||
        self.page_no = int(request.args.get("page-no",
 | 
			
		||||
                                            self.__default_page_no))
 | 
			
		||||
        if self.page_no < 1:
 | 
			
		||||
            self.page_no = 1
 | 
			
		||||
        if self.page_no > self.__total_pages:
 | 
			
		||||
            self.page_no = self.__total_pages
 | 
			
		||||
        lower_bound: int = (self.page_no - 1) * self.page_size
 | 
			
		||||
        self.__is_reversed: bool = is_reversed
 | 
			
		||||
        """Whether the default page is the last page."""
 | 
			
		||||
        self.page_size = self.__get_page_size()
 | 
			
		||||
        self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1
 | 
			
		||||
        """The total number of pages."""
 | 
			
		||||
        self.is_paged = self.__total_pages > 1
 | 
			
		||||
        self.__default_page_no: int = self.__total_pages \
 | 
			
		||||
            if self.__is_reversed else 1
 | 
			
		||||
        """The default page number."""
 | 
			
		||||
        self.__page_no: int = self.__get_page_no()
 | 
			
		||||
        """The current page number."""
 | 
			
		||||
        lower_bound: int = (self.__page_no - 1) * self.page_size
 | 
			
		||||
        upper_bound: int = lower_bound + self.page_size
 | 
			
		||||
        if upper_bound > len(self.__items):
 | 
			
		||||
            upper_bound = len(self.__items)
 | 
			
		||||
        self.list = self.__items[lower_bound:upper_bound]
 | 
			
		||||
        if upper_bound > len(items):
 | 
			
		||||
            upper_bound = len(items)
 | 
			
		||||
        self.list = items[lower_bound:upper_bound]
 | 
			
		||||
        self.pages = self.__get_pages()
 | 
			
		||||
        self.page_size_options = self.__get_page_size_options()
 | 
			
		||||
 | 
			
		||||
    def __get_base_uri_params(self) -> tuple[list[str], list[tuple[str, str]]]:
 | 
			
		||||
        """Returns the base URI and its parameters, with the "page-no" and
 | 
			
		||||
        "page-size" parameters removed.
 | 
			
		||||
    def __get_page_size(self) -> int:
 | 
			
		||||
        """Returns the page size.
 | 
			
		||||
 | 
			
		||||
        :return: The URI parts and the cleaned-up query parameters.
 | 
			
		||||
        :return: The page size.
 | 
			
		||||
        :raise Redirection: When the page size is malformed.
 | 
			
		||||
        """
 | 
			
		||||
        uri_p: ParseResult = urlparse(self.__current_uri)
 | 
			
		||||
        params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
			
		||||
        params = [x for x in params if x[0] not in ["page-no", "page-size"]]
 | 
			
		||||
        parts: list[str] = list(uri_p)
 | 
			
		||||
        return parts, params
 | 
			
		||||
        if "page-size" not in request.args:
 | 
			
		||||
            return DEFAULT_PAGE_SIZE
 | 
			
		||||
        try:
 | 
			
		||||
            page_size: int = int(request.args["page-size"])
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            raise Redirection(self.__uri_set("page-size", None))
 | 
			
		||||
        if page_size == DEFAULT_PAGE_SIZE or page_size < 1:
 | 
			
		||||
            raise Redirection(self.__uri_set("page-size", None))
 | 
			
		||||
        return page_size
 | 
			
		||||
 | 
			
		||||
    def __get_page_links(self) -> list[PageLink]:
 | 
			
		||||
    def __get_page_no(self) -> int:
 | 
			
		||||
        """Returns the page number.
 | 
			
		||||
 | 
			
		||||
        :return: The page number.
 | 
			
		||||
        :raise Redirection: When the page number is malformed.
 | 
			
		||||
        """
 | 
			
		||||
        if "page-no" not in request.args:
 | 
			
		||||
            return self.__default_page_no
 | 
			
		||||
        try:
 | 
			
		||||
            page_no: int = int(request.args["page-no"])
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            raise Redirection(self.__uri_set("page-no", None))
 | 
			
		||||
        if page_no == self.__default_page_no:
 | 
			
		||||
            raise Redirection(self.__uri_set("page-no", None))
 | 
			
		||||
        if page_no < 1:
 | 
			
		||||
            if not self.__is_reversed:
 | 
			
		||||
                raise Redirection(self.__uri_set("page-no", None))
 | 
			
		||||
            raise Redirection(self.__uri_set("page-no", "1"))
 | 
			
		||||
        if page_no > self.__total_pages:
 | 
			
		||||
            if self.__is_reversed:
 | 
			
		||||
                raise Redirection(self.__uri_set("page-no", None))
 | 
			
		||||
            raise Redirection(self.__uri_set("page-no",
 | 
			
		||||
                                             str(self.__total_pages)))
 | 
			
		||||
        return page_no
 | 
			
		||||
 | 
			
		||||
    def __get_pages(self) -> list[Link]:
 | 
			
		||||
        """Returns the page links in the pagination navigation.
 | 
			
		||||
 | 
			
		||||
        :return: The page links in the pagination navigation.
 | 
			
		||||
        """
 | 
			
		||||
        if self.__total_pages < 2:
 | 
			
		||||
        if not self.is_paged:
 | 
			
		||||
            return []
 | 
			
		||||
        uri: str | None
 | 
			
		||||
        links: list[PageLink] = []
 | 
			
		||||
        links: list[Link] = []
 | 
			
		||||
 | 
			
		||||
        # The previous page.
 | 
			
		||||
        uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1)
 | 
			
		||||
        links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True))
 | 
			
		||||
        uri = None if self.__page_no == 1 \
 | 
			
		||||
            else self.__uri_page(self.__page_no - 1)
 | 
			
		||||
        links.append(Link(pgettext("Pagination|", "Previous"), uri,
 | 
			
		||||
                          is_for_mobile=True))
 | 
			
		||||
 | 
			
		||||
        # The first page.
 | 
			
		||||
        if self.page_no > 1:
 | 
			
		||||
            links.append(PageLink("1", self.__uri_page(1)))
 | 
			
		||||
        if self.__page_no > 1:
 | 
			
		||||
            links.append(Link("1", self.__uri_page(1)))
 | 
			
		||||
 | 
			
		||||
        # The eclipse of the previous pages.
 | 
			
		||||
        if self.page_no - 3 == 2:
 | 
			
		||||
            links.append(PageLink(str(self.page_no - 3),
 | 
			
		||||
                                  self.__uri_page(self.page_no - 3)))
 | 
			
		||||
        elif self.page_no - 3 > 2:
 | 
			
		||||
            links.append(PageLink("…"))
 | 
			
		||||
        if self.__page_no - 3 == 2:
 | 
			
		||||
            links.append(Link(str(self.__page_no - 3),
 | 
			
		||||
                              self.__uri_page(self.__page_no - 3)))
 | 
			
		||||
        elif self.__page_no - 3 > 2:
 | 
			
		||||
            links.append(Link("…"))
 | 
			
		||||
 | 
			
		||||
        # The previous two pages.
 | 
			
		||||
        if self.page_no - 2 > 1:
 | 
			
		||||
            links.append(PageLink(str(self.page_no - 2),
 | 
			
		||||
                                  self.__uri_page(self.page_no - 2)))
 | 
			
		||||
        if self.page_no - 1 > 1:
 | 
			
		||||
            links.append(PageLink(str(self.page_no - 1),
 | 
			
		||||
                                  self.__uri_page(self.page_no - 1)))
 | 
			
		||||
        if self.__page_no - 2 > 1:
 | 
			
		||||
            links.append(Link(str(self.__page_no - 2),
 | 
			
		||||
                              self.__uri_page(self.__page_no - 2)))
 | 
			
		||||
        if self.__page_no - 1 > 1:
 | 
			
		||||
            links.append(Link(str(self.__page_no - 1),
 | 
			
		||||
                              self.__uri_page(self.__page_no - 1)))
 | 
			
		||||
 | 
			
		||||
        # The current page.
 | 
			
		||||
        links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no),
 | 
			
		||||
        links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no),
 | 
			
		||||
                          is_current=True))
 | 
			
		||||
 | 
			
		||||
        # The next two pages.
 | 
			
		||||
        if self.page_no + 1 < self.__total_pages:
 | 
			
		||||
            links.append(PageLink(str(self.page_no + 1),
 | 
			
		||||
                                  self.__uri_page(self.page_no + 1)))
 | 
			
		||||
        if self.page_no + 2 < self.__total_pages:
 | 
			
		||||
            links.append(PageLink(str(self.page_no + 2),
 | 
			
		||||
                                  self.__uri_page(self.page_no + 2)))
 | 
			
		||||
        if self.__page_no + 1 < self.__total_pages:
 | 
			
		||||
            links.append(Link(str(self.__page_no + 1),
 | 
			
		||||
                              self.__uri_page(self.__page_no + 1)))
 | 
			
		||||
        if self.__page_no + 2 < self.__total_pages:
 | 
			
		||||
            links.append(Link(str(self.__page_no + 2),
 | 
			
		||||
                              self.__uri_page(self.__page_no + 2)))
 | 
			
		||||
 | 
			
		||||
        # The eclipse of the next pages.
 | 
			
		||||
        if self.page_no + 3 == self.__total_pages - 1:
 | 
			
		||||
            links.append(PageLink(str(self.page_no + 3),
 | 
			
		||||
                                  self.__uri_page(self.page_no + 3)))
 | 
			
		||||
        elif self.page_no + 3 < self.__total_pages - 1:
 | 
			
		||||
            links.append(PageLink("…"))
 | 
			
		||||
        if self.__page_no + 3 == self.__total_pages - 1:
 | 
			
		||||
            links.append(Link(str(self.__page_no + 3),
 | 
			
		||||
                              self.__uri_page(self.__page_no + 3)))
 | 
			
		||||
        elif self.__page_no + 3 < self.__total_pages - 1:
 | 
			
		||||
            links.append(Link("…"))
 | 
			
		||||
 | 
			
		||||
        # The last page.
 | 
			
		||||
        if self.page_no < self.__total_pages:
 | 
			
		||||
            links.append(PageLink(str(self.__total_pages),
 | 
			
		||||
        if self.__page_no < self.__total_pages:
 | 
			
		||||
            links.append(Link(str(self.__total_pages),
 | 
			
		||||
                              self.__uri_page(self.__total_pages)))
 | 
			
		||||
 | 
			
		||||
        # The next page.
 | 
			
		||||
        uri = None if self.page_no == self.__total_pages \
 | 
			
		||||
            else self.__uri_page(self.page_no + 1)
 | 
			
		||||
        links.append(PageLink(gettext("Next"), uri, is_for_mobile=True))
 | 
			
		||||
        uri = None if self.__page_no == self.__total_pages \
 | 
			
		||||
            else self.__uri_page(self.__page_no + 1)
 | 
			
		||||
        links.append(Link(pgettext("Pagination|", "Next"), uri,
 | 
			
		||||
                          is_for_mobile=True))
 | 
			
		||||
 | 
			
		||||
        return links
 | 
			
		||||
 | 
			
		||||
@@ -201,21 +263,22 @@ class Pagination(t.Generic[T]):
 | 
			
		||||
        :param page_no: The page number.
 | 
			
		||||
        :return: The URI of the page.
 | 
			
		||||
        """
 | 
			
		||||
        params: list[tuple[str, str]] = []
 | 
			
		||||
        if page_no != self.__default_page_no:
 | 
			
		||||
            params.append(("page-no", str(page_no)))
 | 
			
		||||
        if self.page_size != self.DEFAULT_PAGE_SIZE:
 | 
			
		||||
            params.append(("page-size", str(self.page_size)))
 | 
			
		||||
        return self.__uri_set_params(params)
 | 
			
		||||
        if page_no == self.__page_no:
 | 
			
		||||
            return self.__current_uri
 | 
			
		||||
        if page_no == self.__default_page_no:
 | 
			
		||||
            return self.__uri_set("page-no", None)
 | 
			
		||||
        return self.__uri_set("page-no", str(page_no))
 | 
			
		||||
 | 
			
		||||
    def __get_page_sizes(self) -> list[PageLink]:
 | 
			
		||||
        """Returns the available page sizes.
 | 
			
		||||
    def __get_page_size_options(self) -> list[Link]:
 | 
			
		||||
        """Returns the page size options.
 | 
			
		||||
 | 
			
		||||
        :return: The available page sizes.
 | 
			
		||||
        :return: The page size options.
 | 
			
		||||
        """
 | 
			
		||||
        return [PageLink(str(x), self.__uri_size(x),
 | 
			
		||||
        if not self.is_paged:
 | 
			
		||||
            return []
 | 
			
		||||
        return [Link(str(x), self.__uri_size(x),
 | 
			
		||||
                     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:
 | 
			
		||||
        """Returns the URI of a page size.
 | 
			
		||||
@@ -225,16 +288,34 @@ class Pagination(t.Generic[T]):
 | 
			
		||||
        """
 | 
			
		||||
        if page_size == self.page_size:
 | 
			
		||||
            return self.__current_uri
 | 
			
		||||
        return self.__uri_set_params([("page-size", str(page_size))])
 | 
			
		||||
        if page_size == DEFAULT_PAGE_SIZE:
 | 
			
		||||
            return self.__uri_set("page-size", None)
 | 
			
		||||
        return self.__uri_set("page-size", str(page_size))
 | 
			
		||||
 | 
			
		||||
    def __uri_set_params(self, params: list[tuple[str, str]]) -> str:
 | 
			
		||||
        """Returns the URI with the query parameters set.
 | 
			
		||||
    def __uri_set(self, name: str, value: str | None) -> str:
 | 
			
		||||
        """Raises current URI with a parameter set.
 | 
			
		||||
 | 
			
		||||
        :param params: The query parameters.
 | 
			
		||||
        :return: The URI with the query parameters set.
 | 
			
		||||
        :param name: The name of the parameter.
 | 
			
		||||
        :param value: The value, or None to remove the parameter.
 | 
			
		||||
        :return: The URI with the parameter set.
 | 
			
		||||
        """
 | 
			
		||||
        cur_params: list[tuple[str, str]] = self.__base_uri_params[1].copy()
 | 
			
		||||
        cur_params.extend(params)
 | 
			
		||||
        parts: list[str] = self.__base_uri_params[0].copy()
 | 
			
		||||
        parts[4] = urlencode(cur_params)
 | 
			
		||||
        uri_p: ParseResult = urlparse(self.__current_uri)
 | 
			
		||||
        params: list[tuple[str, str]] = parse_qsl(uri_p.query)
 | 
			
		||||
 | 
			
		||||
        # Try to keep the position of the parameter.
 | 
			
		||||
        i: int = 0
 | 
			
		||||
        is_found: bool = False
 | 
			
		||||
        while i < len(params):
 | 
			
		||||
            if params[i][0] == name:
 | 
			
		||||
                if is_found or value is None:
 | 
			
		||||
                    params = params[:i] + params[i + 1:]
 | 
			
		||||
                    continue
 | 
			
		||||
                params[i] = (name, value)
 | 
			
		||||
                is_found = True
 | 
			
		||||
            i = i + 1
 | 
			
		||||
        if not is_found and value is not None:
 | 
			
		||||
            params.append((name, value))
 | 
			
		||||
 | 
			
		||||
        parts: list[str] = list(uri_p)
 | 
			
		||||
        parts[4] = urlencode(params)
 | 
			
		||||
        return urlunparse(parts)
 | 
			
		||||
 
 | 
			
		||||
@@ -21,7 +21,9 @@ This module should not import any other module from the application.
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
 | 
			
		||||
from flask import Flask, abort
 | 
			
		||||
from flask import abort, Blueprint
 | 
			
		||||
 | 
			
		||||
from accounting.utils.user import get_current_user
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
 | 
			
		||||
@@ -75,17 +77,22 @@ def can_view() -> bool:
 | 
			
		||||
def can_edit() -> bool:
 | 
			
		||||
    """Returns whether the current user can edit the account data.
 | 
			
		||||
 | 
			
		||||
    The user has to log in.
 | 
			
		||||
 | 
			
		||||
    :return: True if the current user can edit the accounting data, or False
 | 
			
		||||
        otherwise.
 | 
			
		||||
    """
 | 
			
		||||
    if get_current_user() is None:
 | 
			
		||||
        return False
 | 
			
		||||
    return __can_edit_func()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
 | 
			
		||||
def init_app(bp: Blueprint,
 | 
			
		||||
             can_view_func: t.Callable[[], bool] | None = None,
 | 
			
		||||
             can_edit_func: t.Callable[[], bool] | None = None) -> None:
 | 
			
		||||
    """Initializes the application.
 | 
			
		||||
 | 
			
		||||
    :param app: The Flask application.
 | 
			
		||||
    :param bp: The blueprint of the accounting application.
 | 
			
		||||
    :param can_view_func: A callback that returns whether the current user can
 | 
			
		||||
        view the accounting data.
 | 
			
		||||
    :param can_edit_func: A callback that returns whether the current user can
 | 
			
		||||
@@ -97,5 +104,5 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
 | 
			
		||||
        __can_view_func = can_view_func
 | 
			
		||||
    if can_edit_func is not None:
 | 
			
		||||
        __can_edit_func = can_edit_func
 | 
			
		||||
    app.jinja_env.globals["can_view_accounting"] = __can_view_func
 | 
			
		||||
    app.jinja_env.globals["can_edit_accounting"] = __can_edit_func
 | 
			
		||||
    bp.add_app_template_global(can_view, "accounting_can_view")
 | 
			
		||||
    bp.add_app_template_global(can_edit, "accounting_can_edit")
 | 
			
		||||
 
 | 
			
		||||
@@ -34,11 +34,22 @@ def parse_query_keywords(q: str | None) -> list[str]:
 | 
			
		||||
    if q == "":
 | 
			
		||||
        return []
 | 
			
		||||
    keywords: list[str] = []
 | 
			
		||||
    while q is not None:
 | 
			
		||||
        m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q)
 | 
			
		||||
        if m.group(1) is not None:
 | 
			
		||||
    while True:
 | 
			
		||||
        m: re.Match
 | 
			
		||||
        m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
 | 
			
		||||
        if m is not None:
 | 
			
		||||
            keywords.append(m.group(1))
 | 
			
		||||
        else:
 | 
			
		||||
            keywords.append(m.group(2))
 | 
			
		||||
        q = m.group(3)
 | 
			
		||||
            q = m.group(2)
 | 
			
		||||
            continue
 | 
			
		||||
        m = re.match(r"\"([^\"]+)\"?$", q)
 | 
			
		||||
        if m is not None:
 | 
			
		||||
            keywords.append(m.group(1))
 | 
			
		||||
            break
 | 
			
		||||
        m = re.match(r"(\S+)\s+(.+)$", q)
 | 
			
		||||
        if m is not None:
 | 
			
		||||
            keywords.append(m.group(1))
 | 
			
		||||
            q = m.group(2)
 | 
			
		||||
            continue
 | 
			
		||||
        keywords.append(q)
 | 
			
		||||
        break
 | 
			
		||||
    return keywords
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,7 @@ 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)
 | 
			
		||||
@@ -49,10 +50,11 @@ class AbstractUserUtils(t.Generic[T], ABC):
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
    def current_user(self) -> T:
 | 
			
		||||
        """Returns the current user.
 | 
			
		||||
    def current_user(self) -> T | None:
 | 
			
		||||
        """Returns the currently logged-in user.
 | 
			
		||||
 | 
			
		||||
        :return: The current user.
 | 
			
		||||
        :return: The currently logged-in user, or None if the user has not
 | 
			
		||||
            logged in
 | 
			
		||||
        """
 | 
			
		||||
 | 
			
		||||
    @abstractmethod
 | 
			
		||||
@@ -72,9 +74,9 @@ class AbstractUserUtils(t.Generic[T], ABC):
 | 
			
		||||
 | 
			
		||||
__user_utils: AbstractUserUtils
 | 
			
		||||
"""The user utilities."""
 | 
			
		||||
user_cls: t.Type[Model]
 | 
			
		||||
user_cls: t.Type[Model] = Model
 | 
			
		||||
"""The user class."""
 | 
			
		||||
user_pk_column: sa.Column
 | 
			
		||||
user_pk_column: sa.Column = sa.Column(sa.Integer)
 | 
			
		||||
"""The primary key column of the user class."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@@ -95,7 +97,7 @@ def get_current_user_pk() -> int:
 | 
			
		||||
 | 
			
		||||
    :return: The primary key value of the currently logged-in user.
 | 
			
		||||
    """
 | 
			
		||||
    return __user_utils.get_pk(__user_utils.current_user)
 | 
			
		||||
    return __user_utils.get_pk(get_current_user())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_user(username: str) -> bool:
 | 
			
		||||
@@ -114,3 +116,14 @@ def get_user_pk(username: str) -> int:
 | 
			
		||||
    :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")
 | 
			
		||||
 
 | 
			
		||||
@@ -14,10 +14,10 @@
 | 
			
		||||
#  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
 | 
			
		||||
@@ -26,8 +26,40 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from testlib import UserClient, get_user_client
 | 
			
		||||
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):
 | 
			
		||||
@@ -109,26 +141,24 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
            Account.query.delete()
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        editor: UserClient = get_user_client(self, self.app, "editor")
 | 
			
		||||
        self.client: httpx.Client = editor.client
 | 
			
		||||
        self.csrf_token: str = editor.csrf_token
 | 
			
		||||
        self.client, self.csrf_token = get_client(self, self.app, "editor")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1111",
 | 
			
		||||
                                          "title": "1111 title"})
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": cash.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1111-001")
 | 
			
		||||
                         f"{PREFIX}/{cash.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1112",
 | 
			
		||||
                                          "title": "1112 title"})
 | 
			
		||||
                                          "base_code": bank.base_code,
 | 
			
		||||
                                          "title": bank.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1112-001")
 | 
			
		||||
                         f"{PREFIX}/{bank.code}")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
@@ -136,47 +166,47 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        client, csrf_token = get_client(self, self.app, "nobody")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
        nobody: UserClient = get_user_client(self, self.app, "nobody")
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.get("/accounting/accounts")
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.get("/accounting/accounts/1111-001")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.get("/accounting/accounts/create")
 | 
			
		||||
        response = client.get(f"{PREFIX}/create")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.post("/accounting/accounts/store",
 | 
			
		||||
                                      data={"csrf_token": nobody.csrf_token,
 | 
			
		||||
                                            "base_code": "1113",
 | 
			
		||||
                                            "title": "1113 title"})
 | 
			
		||||
        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 = nobody.client.get("/accounting/accounts/1111-001/edit")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.post("/accounting/accounts/1111-001/update",
 | 
			
		||||
                                      data={"csrf_token": nobody.csrf_token,
 | 
			
		||||
                                            "base_code": "1111",
 | 
			
		||||
                                            "title": "1111 title #2"})
 | 
			
		||||
        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 = nobody.client.post("/accounting/accounts/1111-001/delete",
 | 
			
		||||
                                      data={"csrf_token": nobody.csrf_token})
 | 
			
		||||
        response = client.post(f"{PREFIX}/{cash.code}/delete",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.get("/accounting/accounts/bases/1111")
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account_id: int = Account.find_by_code("1112-001").id
 | 
			
		||||
            bank_id: int = Account.find_by_code(bank.code).id
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.post("/accounting/accounts/bases/1112",
 | 
			
		||||
                                      data={"csrf_token": nobody.csrf_token,
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "next": "/next",
 | 
			
		||||
                                            f"{account_id}-no": "5"})
 | 
			
		||||
                                     f"{bank_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
@@ -185,47 +215,47 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        client, csrf_token = get_client(self, self.app, "viewer")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
        viewer: UserClient = get_user_client(self, self.app, "viewer")
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.get("/accounting/accounts")
 | 
			
		||||
        response = client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.get("/accounting/accounts/1111-001")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.get("/accounting/accounts/create")
 | 
			
		||||
        response = client.get(f"{PREFIX}/create")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.post("/accounting/accounts/store",
 | 
			
		||||
                                      data={"csrf_token": viewer.csrf_token,
 | 
			
		||||
                                            "base_code": "1113",
 | 
			
		||||
                                            "title": "1113 title"})
 | 
			
		||||
        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 = viewer.client.get("/accounting/accounts/1111-001/edit")
 | 
			
		||||
        response = client.get(f"{PREFIX}/{cash.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.post("/accounting/accounts/1111-001/update",
 | 
			
		||||
                                      data={"csrf_token": viewer.csrf_token,
 | 
			
		||||
                                            "base_code": "1111",
 | 
			
		||||
                                            "title": "1111 title #2"})
 | 
			
		||||
        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 = viewer.client.post("/accounting/accounts/1111-001/delete",
 | 
			
		||||
                                      data={"csrf_token": viewer.csrf_token})
 | 
			
		||||
        response = client.post(f"{PREFIX}/{cash.code}/delete",
 | 
			
		||||
                               data={"csrf_token": csrf_token})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.get("/accounting/accounts/bases/1111")
 | 
			
		||||
        response = client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account_id: int = Account.find_by_code("1112-001").id
 | 
			
		||||
            bank_id: int = Account.find_by_code(bank.code).id
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.post("/accounting/accounts/bases/1112",
 | 
			
		||||
                                      data={"csrf_token": viewer.csrf_token,
 | 
			
		||||
        response = client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
			
		||||
                               data={"csrf_token": csrf_token,
 | 
			
		||||
                                     "next": "/next",
 | 
			
		||||
                                            f"{account_id}-no": "5"})
 | 
			
		||||
                                     f"{bank_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
@@ -236,107 +266,382 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        from accounting.models import Account
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get("/accounting/accounts")
 | 
			
		||||
        response = self.client.get(PREFIX)
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get("/accounting/accounts/1111-001")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{cash.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get("/accounting/accounts/create")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/create")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1113",
 | 
			
		||||
                                          "title": "1113 title"})
 | 
			
		||||
                                          "base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1113-001")
 | 
			
		||||
                         f"{PREFIX}/{stock.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.get("/accounting/accounts/1111-001/edit")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/{cash.code}/edit")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/1111-001/update",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/{cash.code}/update",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1111",
 | 
			
		||||
                                          "title": "1111 title #2"})
 | 
			
		||||
                                          "base_code": cash.base_code,
 | 
			
		||||
                                          "title": f"{cash.title}-2"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1111-001")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/1111-001/delete",
 | 
			
		||||
        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"],
 | 
			
		||||
                         "/accounting/accounts")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], PREFIX)
 | 
			
		||||
 | 
			
		||||
        response = self.client.get("/accounting/accounts/bases/1111")
 | 
			
		||||
        response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            account_id: int = Account.find_by_code("1112-001").id
 | 
			
		||||
            bank_id: int = Account.find_by_code(bank.code).id
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/bases/1112",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": "/next",
 | 
			
		||||
                                          f"{account_id}-no": "5"})
 | 
			
		||||
                                          f"{bank_id}-no": "5"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"], "/next")
 | 
			
		||||
 | 
			
		||||
    def test_change_base(self) -> None:
 | 
			
		||||
        """Tests to change the base account.
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1111",
 | 
			
		||||
                                          "title": "Title #1"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1111-002")
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual({x.code for x in Account.query.all()},
 | 
			
		||||
                             {cash.code, bank.code})
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "base_code": "1111",
 | 
			
		||||
                                          "title": "Title #1"})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1111-003")
 | 
			
		||||
        # Missing CSRF token
 | 
			
		||||
        response = self.client.post(store_uri,
 | 
			
		||||
                                    data={"base_code": stock.base_code,
 | 
			
		||||
                                          "title": stock.title})
 | 
			
		||||
        self.assertEqual(response.status_code, 400)
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
        # 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": "1112",
 | 
			
		||||
                                          "title": "Title #1"})
 | 
			
		||||
                                          "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"],
 | 
			
		||||
                         "/accounting/accounts/1112-002")
 | 
			
		||||
                         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():
 | 
			
		||||
            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("1112-001").id
 | 
			
		||||
            id_5: int = Account.find_by_code("1112-002").id
 | 
			
		||||
            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"})
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/1111-002/update",
 | 
			
		||||
            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": "1112",
 | 
			
		||||
                                          "title": "Account #1"})
 | 
			
		||||
                                          "base_code": f" {cash.base_code} ",
 | 
			
		||||
                                          "title": f" {cash.title}-1 "})
 | 
			
		||||
        self.assertEqual(response.status_code, 302)
 | 
			
		||||
        self.assertEqual(response.headers["Location"],
 | 
			
		||||
                         "/accounting/accounts/1112-003")
 | 
			
		||||
        self.assertEqual(response.headers["Location"], detail_uri)
 | 
			
		||||
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_1).code, "1111-001")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_2).code, "1112-003")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_4).code, "1112-001")
 | 
			
		||||
            self.assertEqual(db.session.get(Account, id_5).code, "1112-002")
 | 
			
		||||
            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.
 | 
			
		||||
@@ -348,13 +653,13 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        for i in range(2, 6):
 | 
			
		||||
            response = self.client.post("/accounting/accounts/store",
 | 
			
		||||
            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"/accounting/accounts/1111-00{i}")
 | 
			
		||||
                             f"{PREFIX}/1111-00{i}")
 | 
			
		||||
 | 
			
		||||
        # Normal reorder
 | 
			
		||||
        with self.app.app_context():
 | 
			
		||||
@@ -364,7 +669,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
            id_4: int = Account.find_by_code("1111-004").id
 | 
			
		||||
            id_5: int = Account.find_by_code("1111-005").id
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/bases/1111",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/1111",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": "/next",
 | 
			
		||||
                                          f"{id_1}-no": "4",
 | 
			
		||||
@@ -391,7 +696,7 @@ class AccountTestCase(unittest.TestCase):
 | 
			
		||||
            db.session.get(Account, id_5).no = 9
 | 
			
		||||
            db.session.commit()
 | 
			
		||||
 | 
			
		||||
        response = self.client.post("/accounting/accounts/bases/1111",
 | 
			
		||||
        response = self.client.post(f"{PREFIX}/bases/1111",
 | 
			
		||||
                                    data={"csrf_token": self.csrf_token,
 | 
			
		||||
                                          "next": "/next",
 | 
			
		||||
                                          f"{id_2}-no": "3a",
 | 
			
		||||
 
 | 
			
		||||
@@ -14,7 +14,6 @@
 | 
			
		||||
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | 
			
		||||
#  See the License for the specific language governing permissions and
 | 
			
		||||
#  limitations under the License.
 | 
			
		||||
 | 
			
		||||
"""The test for the base account management.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
@@ -25,8 +24,8 @@ from click.testing import Result
 | 
			
		||||
from flask import Flask
 | 
			
		||||
from flask.testing import FlaskCliRunner
 | 
			
		||||
 | 
			
		||||
from testlib import UserClient, get_user_client
 | 
			
		||||
from test_site import create_app
 | 
			
		||||
from testlib import get_client
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class BaseAccountCommandTestCase(unittest.TestCase):
 | 
			
		||||
@@ -88,22 +87,18 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
			
		||||
                result = runner.invoke(args="accounting-init-base")
 | 
			
		||||
                self.assertEqual(result.exit_code, 0)
 | 
			
		||||
 | 
			
		||||
        self.viewer: UserClient = get_user_client(self, self.app, "viewer")
 | 
			
		||||
        self.editor: UserClient = get_user_client(self, self.app, "editor")
 | 
			
		||||
        self.nobody: UserClient = get_user_client(self, self.app, "nobody")
 | 
			
		||||
 | 
			
		||||
    def test_nobody(self) -> None:
 | 
			
		||||
        """Test the permission as nobody.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self, self.app, "nobody")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
        nobody: UserClient = get_user_client(self, self.app, "nobody")
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.get("/accounting/base-accounts")
 | 
			
		||||
        response = client.get("/accounting/base-accounts")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
        response = nobody.client.get("/accounting/base-accounts/1111")
 | 
			
		||||
        response = client.get("/accounting/base-accounts/1111")
 | 
			
		||||
        self.assertEqual(response.status_code, 403)
 | 
			
		||||
 | 
			
		||||
    def test_viewer(self) -> None:
 | 
			
		||||
@@ -111,13 +106,13 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self, self.app, "viewer")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
        viewer: UserClient = get_user_client(self, self.app, "viewer")
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.get("/accounting/base-accounts")
 | 
			
		||||
        response = client.get("/accounting/base-accounts")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = viewer.client.get("/accounting/base-accounts/1111")
 | 
			
		||||
        response = client.get("/accounting/base-accounts/1111")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
    def test_editor(self) -> None:
 | 
			
		||||
@@ -125,11 +120,11 @@ class BaseAccountTestCase(unittest.TestCase):
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        client, csrf_token = get_client(self, self.app, "editor")
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
        editor: UserClient = get_user_client(self, self.app, "editor")
 | 
			
		||||
 | 
			
		||||
        response = editor.client.get("/accounting/base-accounts")
 | 
			
		||||
        response = client.get("/accounting/base-accounts")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 | 
			
		||||
        response = editor.client.get("/accounting/base-accounts/1111")
 | 
			
		||||
        response = client.get("/accounting/base-accounts/1111")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										595
									
								
								tests/test_currency.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										595
									
								
								tests/test_currency.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,595 @@
 | 
			
		||||
# 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_api_exists(self) -> None:
 | 
			
		||||
        """Tests the API to check if a code exists.
 | 
			
		||||
 | 
			
		||||
        :return: None.
 | 
			
		||||
        """
 | 
			
		||||
        response: httpx.Response
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"/accounting/api/currencies/exists-code?q={zza.code}")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        data = response.json()
 | 
			
		||||
        self.assertEqual(set(data.keys()), {"exists"})
 | 
			
		||||
        self.assertTrue(data["exists"])
 | 
			
		||||
 | 
			
		||||
        response = self.client.get(
 | 
			
		||||
            f"/accounting/api/currencies/exists-code?q={zza.code}-1")
 | 
			
		||||
        self.assertEqual(response.status_code, 200)
 | 
			
		||||
        data = response.json()
 | 
			
		||||
        self.assertEqual(set(data.keys()), {"exists"})
 | 
			
		||||
        self.assertFalse(data["exists"])
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
@@ -80,7 +80,7 @@ def create_app(is_testing: bool = False) -> Flask:
 | 
			
		||||
            return auth.User.id
 | 
			
		||||
 | 
			
		||||
        @property
 | 
			
		||||
        def current_user(self) -> auth.User:
 | 
			
		||||
        def current_user(self) -> auth.User | None:
 | 
			
		||||
            return auth.current_user()
 | 
			
		||||
 | 
			
		||||
        def get_by_username(self, username: str) -> auth.User | None:
 | 
			
		||||
@@ -91,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask:
 | 
			
		||||
            return user.id
 | 
			
		||||
 | 
			
		||||
    can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
			
		||||
        and auth.current_user().username in ["viewer", "editor"]
 | 
			
		||||
        and auth.current_user().username in ["viewer", "editor", "editor2"]
 | 
			
		||||
    can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
 | 
			
		||||
        and auth.current_user().username == "editor"
 | 
			
		||||
        and auth.current_user().username in ["editor", "editor2"]
 | 
			
		||||
    accounting.init_app(app, user_utils=UserUtils(),
 | 
			
		||||
                        can_view_func=can_view, can_edit_func=can_edit)
 | 
			
		||||
 | 
			
		||||
@@ -106,7 +106,7 @@ def init_db_command() -> None:
 | 
			
		||||
    """Initializes the database."""
 | 
			
		||||
    db.create_all()
 | 
			
		||||
    from .auth import User
 | 
			
		||||
    for username in ["viewer", "editor", "nobody"]:
 | 
			
		||||
    for username in ["viewer", "editor", "editor2", "nobody"]:
 | 
			
		||||
        if User.query.filter(User.username == username).first() is None:
 | 
			
		||||
            db.session.add(User(username=username))
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
 
 | 
			
		||||
@@ -58,7 +58,8 @@ def login() -> redirect:
 | 
			
		||||
 | 
			
		||||
    :return: The redirection to the home page.
 | 
			
		||||
    """
 | 
			
		||||
    if request.form.get("username") not in ["viewer", "editor", "nobody"]:
 | 
			
		||||
    if request.form.get("username") not in ["viewer", "editor", "editor2",
 | 
			
		||||
                                            "nobody"]:
 | 
			
		||||
        return redirect(url_for("auth.login"))
 | 
			
		||||
    session["user"] = request.form.get("username")
 | 
			
		||||
    return redirect(url_for("home.home"))
 | 
			
		||||
 
 | 
			
		||||
@@ -29,6 +29,7 @@ First written: 2023/1/27
 | 
			
		||||
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
 | 
			
		||||
  <button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
 | 
			
		||||
  <button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
 | 
			
		||||
  <button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
 | 
			
		||||
  <button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
 | 
			
		||||
</form>
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
 | 
			
		||||
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
 | 
			
		||||
"POT-Creation-Date: 2023-02-06 23:25+0800\n"
 | 
			
		||||
"PO-Revision-Date: 2023-02-06 23:26+0800\n"
 | 
			
		||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
"Language: zh_Hant\n"
 | 
			
		||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
 | 
			
		||||
@@ -51,6 +51,10 @@ msgid "Editor"
 | 
			
		||||
msgstr "記帳者"
 | 
			
		||||
 | 
			
		||||
#: tests/test_site/templates/login.html:32
 | 
			
		||||
msgid "Editor2"
 | 
			
		||||
msgstr "記帳者2"
 | 
			
		||||
 | 
			
		||||
#: tests/test_site/templates/login.html:33
 | 
			
		||||
msgid "Nobody"
 | 
			
		||||
msgstr "沒有權限者"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										310
									
								
								tests/test_utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										310
									
								
								tests/test_utils.py
									
									
									
									
									
										Normal 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)
 | 
			
		||||
@@ -17,6 +17,7 @@
 | 
			
		||||
"""The common test libraries.
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
import typing as t
 | 
			
		||||
from html.parser import HTMLParser
 | 
			
		||||
from unittest import TestCase
 | 
			
		||||
 | 
			
		||||
@@ -24,27 +25,14 @@ import httpx
 | 
			
		||||
from flask import Flask
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class UserClient:
 | 
			
		||||
    """A user client."""
 | 
			
		||||
 | 
			
		||||
    def __init__(self, client: httpx.Client, csrf_token: str):
 | 
			
		||||
        """Constructs a user client.
 | 
			
		||||
 | 
			
		||||
        :param client: The client.
 | 
			
		||||
        :param csrf_token: The CSRF token.
 | 
			
		||||
        """
 | 
			
		||||
        self.client: httpx.Client = client
 | 
			
		||||
        self.csrf_token: str = csrf_token
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_user_client(test_case: TestCase, app: Flask, username: str) \
 | 
			
		||||
        -> UserClient:
 | 
			
		||||
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: The user client.
 | 
			
		||||
    :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"
 | 
			
		||||
@@ -54,7 +42,7 @@ def get_user_client(test_case: TestCase, app: Flask, username: str) \
 | 
			
		||||
                                                 "username": username})
 | 
			
		||||
    test_case.assertEqual(response.status_code, 302)
 | 
			
		||||
    test_case.assertEqual(response.headers["Location"], "/")
 | 
			
		||||
    return UserClient(client, csrf_token)
 | 
			
		||||
    return client, csrf_token
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
 | 
			
		||||
@@ -88,3 +76,21 @@ def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
 | 
			
		||||
    parser.feed(response.text)
 | 
			
		||||
    test_case.assertIsNotNone(parser.csrf_token)
 | 
			
		||||
    return parser.csrf_token
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def set_locale(test_case: TestCase, client: httpx.Client, csrf_token: str,
 | 
			
		||||
               locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
 | 
			
		||||
    """Sets the current locale.
 | 
			
		||||
 | 
			
		||||
    :param test_case: The test case.
 | 
			
		||||
    :param client: The test client.
 | 
			
		||||
    :param csrf_token: The CSRF token.
 | 
			
		||||
    :param locale: The locale.
 | 
			
		||||
    :return: None.
 | 
			
		||||
    """
 | 
			
		||||
    response: httpx.Response = client.post("/locale",
 | 
			
		||||
                                           data={"csrf_token": csrf_token,
 | 
			
		||||
                                                 "locale": locale,
 | 
			
		||||
                                                 "next": "/next"})
 | 
			
		||||
    test_case.assertEqual(response.status_code, 302)
 | 
			
		||||
    test_case.assertEqual(response.headers["Location"], "/next")
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user