Compare commits
101 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 | |||
975b00bce9 | |||
d648538fbb | |||
dde9c38bb8 | |||
fecf33baa8 | |||
cea2a44226 | |||
b5d87d2387 | |||
784e7bde49 | |||
60280f415d | |||
f32d268494 | |||
1c1be87f3e | |||
589da0c1c6 | |||
8363ce6602 | |||
6a83f95c9f | |||
7dc754174c | |||
5238168b2d | |||
eeb05b8616 | |||
9920377266 | |||
9f9c40c30e | |||
d368c5e062 | |||
4aed2f6ba7 | |||
6876fdf75e | |||
d9624c7be6 | |||
8364025668 | |||
dd3690dd6a | |||
3312c835fd | |||
fce9d04896 | |||
c68786f78a | |||
581e803707 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -37,4 +37,4 @@ excludes
|
|||||||
*.pot
|
*.pot
|
||||||
*.mo
|
*.mo
|
||||||
zh_Hans
|
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:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.base\_account.database module
|
accounting.base\_account.converters module
|
||||||
----------------------------------------
|
------------------------------------------
|
||||||
|
|
||||||
.. automodule:: accounting.base_account.database
|
.. automodule:: accounting.base_account.converters
|
||||||
:members:
|
|
||||||
:undoc-members:
|
|
||||||
:show-inheritance:
|
|
||||||
|
|
||||||
accounting.base\_account.models module
|
|
||||||
--------------------------------------
|
|
||||||
|
|
||||||
.. automodule:: accounting.base_account.models
|
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
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::
|
.. toctree::
|
||||||
:maxdepth: 4
|
:maxdepth: 4
|
||||||
|
|
||||||
|
accounting.account
|
||||||
accounting.base_account
|
accounting.base_account
|
||||||
|
accounting.currency
|
||||||
accounting.utils
|
accounting.utils
|
||||||
|
|
||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
accounting.database module
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.database
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.locale module
|
accounting.locale module
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
@ -21,6 +31,14 @@ accounting.locale module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.models module
|
||||||
|
------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.models
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
Module contents
|
Module contents
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
@ -4,6 +4,14 @@ accounting.utils package
|
|||||||
Submodules
|
Submodules
|
||||||
----------
|
----------
|
||||||
|
|
||||||
|
accounting.utils.next\_url module
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.next_url
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
accounting.utils.pagination module
|
accounting.utils.pagination module
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
||||||
@ -28,6 +36,30 @@ accounting.utils.query module
|
|||||||
:undoc-members:
|
:undoc-members:
|
||||||
:show-inheritance:
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.random\_id module
|
||||||
|
----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.random_id
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.strip\_text module
|
||||||
|
-----------------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.strip_text
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
|
accounting.utils.user module
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
.. automodule:: accounting.utils.user
|
||||||
|
:members:
|
||||||
|
:undoc-members:
|
||||||
|
:show-inheritance:
|
||||||
|
|
||||||
Module contents
|
Module contents
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
name = mia-accounting-flask
|
name = mia-accounting-flask
|
||||||
version = 0.1.0
|
version = 0.2.0
|
||||||
author = imacat
|
author = imacat
|
||||||
author_email = imacat@mail.imacat.idv.tw
|
author_email = imacat@mail.imacat.idv.tw
|
||||||
description = The Mia! Accounting Flask project.
|
description = The Mia! Accounting Flask project.
|
||||||
@ -36,7 +36,7 @@ classifiers =
|
|||||||
[options]
|
[options]
|
||||||
package_dir =
|
package_dir =
|
||||||
= src
|
= src
|
||||||
python_requires = >=3.10
|
python_requires = >=3.11
|
||||||
install_requires =
|
install_requires =
|
||||||
flask
|
flask
|
||||||
Flask-SQLAlchemy
|
Flask-SQLAlchemy
|
||||||
|
@ -18,55 +18,10 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
import typing as t
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
from flask_sqlalchemy.model import Model
|
|
||||||
|
|
||||||
T = t.TypeVar("T", bound=Model)
|
from accounting.utils.user import AbstractUserUtils
|
||||||
|
|
||||||
|
|
||||||
class AbstractUserUtils(t.Generic[T], ABC):
|
|
||||||
"""The abstract user utilities."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def cls(self) -> t.Type[T]:
|
|
||||||
"""Returns the user class.
|
|
||||||
|
|
||||||
:return: The user class.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def pk_column(self) -> sa.Column:
|
|
||||||
"""Returns the primary key column.
|
|
||||||
|
|
||||||
:return: The primary key column.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def current_user(self) -> T:
|
|
||||||
"""Returns the current user.
|
|
||||||
|
|
||||||
:return: The current user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_by_username(self, username: str) -> T | None:
|
|
||||||
"""Returns the user by her username.
|
|
||||||
|
|
||||||
:return: The user by her username, or None if the user was not found.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_pk(self, user: T) -> int:
|
|
||||||
"""Returns the primary key of the user.
|
|
||||||
|
|
||||||
:return: The primary key of the user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
|
def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||||
@ -87,7 +42,9 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
# The database instance must be set before loading everything
|
# The database instance must be set before loading everything
|
||||||
# in the application.
|
# in the application.
|
||||||
from .database import set_db
|
from .database import set_db
|
||||||
set_db(app.extensions["sqlalchemy"], user_utils)
|
set_db(app.extensions["sqlalchemy"])
|
||||||
|
from .utils.user import init_user_utils
|
||||||
|
init_user_utils(user_utils)
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("accounting", __name__,
|
bp: Blueprint = Blueprint("accounting", __name__,
|
||||||
url_prefix=url_prefix,
|
url_prefix=url_prefix,
|
||||||
@ -98,7 +55,7 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
locale.init_app(app, bp)
|
locale.init_app(app, bp)
|
||||||
|
|
||||||
from .utils import permission
|
from .utils import permission
|
||||||
permission.init_app(app, can_view_func, can_edit_func)
|
permission.init_app(bp, can_view_func, can_edit_func)
|
||||||
|
|
||||||
from . import base_account
|
from . import base_account
|
||||||
base_account.init_app(app, bp)
|
base_account.init_app(app, bp)
|
||||||
@ -106,9 +63,10 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
from . import account
|
from . import account
|
||||||
account.init_app(app, bp)
|
account.init_app(app, bp)
|
||||||
|
|
||||||
from .utils.next_url import append_next, inherit_next, or_next
|
from . import currency
|
||||||
bp.add_app_template_filter(append_next, "append_next")
|
currency.init_app(app, bp)
|
||||||
bp.add_app_template_filter(inherit_next, "inherit_next")
|
|
||||||
bp.add_app_template_filter(or_next, "or_next")
|
from .utils import next_url
|
||||||
|
next_url.init_app(bp)
|
||||||
|
|
||||||
app.register_blueprint(bp)
|
app.register_blueprint(bp)
|
||||||
|
@ -23,8 +23,8 @@ from flask import Flask, Blueprint
|
|||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param bp: The blueprint of the accounting application.
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from .converters import AccountConverter
|
from .converters import AccountConverter
|
||||||
|
@ -24,8 +24,9 @@ from secrets import randbelow
|
|||||||
import click
|
import click
|
||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
from accounting.database import db, user_utils
|
from accounting.database import db
|
||||||
from accounting.models import BaseAccount, Account, AccountL10n
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
|
from accounting.utils.user import has_user, get_user_pk
|
||||||
|
|
||||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||||
"""The format of the account data, as a list of (ID, base account code, number,
|
"""The format of the account data, as a list of (ID, base account code, number,
|
||||||
@ -45,8 +46,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
|||||||
value = value.strip()
|
value = value.strip()
|
||||||
if value == "":
|
if value == "":
|
||||||
raise click.BadParameter("Username empty.")
|
raise click.BadParameter("Username empty.")
|
||||||
user: user_utils.cls | None = user_utils.get_by_username(value)
|
if not has_user(value):
|
||||||
if user is None:
|
|
||||||
raise click.BadParameter(f"User {value} does not exist.")
|
raise click.BadParameter(f"User {value} does not exist.")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@ -58,7 +58,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
|||||||
@with_appcontext
|
@with_appcontext
|
||||||
def init_accounts_command(username: str) -> None:
|
def init_accounts_command(username: str) -> None:
|
||||||
"""Initializes the accounts."""
|
"""Initializes the accounts."""
|
||||||
creator_pk: int = user_utils.get_pk(user_utils.get_by_username(username))
|
creator_pk: int = get_user_pk(username)
|
||||||
|
|
||||||
bases: list[BaseAccount] = BaseAccount.query\
|
bases: list[BaseAccount] = BaseAccount.query\
|
||||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||||
|
@ -18,19 +18,21 @@
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, BooleanField
|
from wtforms import StringField, BooleanField
|
||||||
from wtforms.validators import DataRequired, ValidationError
|
from wtforms.validators import DataRequired, ValidationError
|
||||||
|
|
||||||
from accounting.database import db, user_utils
|
from accounting.database import db
|
||||||
from accounting.locale import lazy_gettext
|
from accounting.locale import lazy_gettext
|
||||||
from accounting.models import BaseAccount, Account
|
from accounting.models import BaseAccount, Account
|
||||||
from accounting.utils.random_id import new_id
|
from accounting.utils.random_id import new_id
|
||||||
from accounting.utils.strip_text import strip_text
|
from accounting.utils.strip_text import strip_text
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountExists:
|
class BaseAccountExists:
|
||||||
"""The validator to check if the base account code exists."""
|
"""The validator to check if the base account exists."""
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
if field.data == "":
|
if field.data == "":
|
||||||
@ -40,13 +42,25 @@ class BaseAccountExists:
|
|||||||
"The base account does not exist."))
|
"The base account does not exist."))
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAccountAvailable:
|
||||||
|
"""The validator to check if the base account is available."""
|
||||||
|
|
||||||
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
|
if field.data == "":
|
||||||
|
return
|
||||||
|
if len(field.data) != 4:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"The base account is not available."))
|
||||||
|
|
||||||
|
|
||||||
class AccountForm(FlaskForm):
|
class AccountForm(FlaskForm):
|
||||||
"""The form to create or edit an account."""
|
"""The form to create or edit an account."""
|
||||||
base_code = StringField(
|
base_code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[
|
validators=[
|
||||||
DataRequired(lazy_gettext("Please select the base account.")),
|
DataRequired(lazy_gettext("Please select the base account.")),
|
||||||
BaseAccountExists()])
|
BaseAccountExists(),
|
||||||
|
BaseAccountAvailable()])
|
||||||
"""The code of the base account."""
|
"""The code of the base account."""
|
||||||
title = StringField(
|
title = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
@ -67,14 +81,14 @@ class AccountForm(FlaskForm):
|
|||||||
obj.id = new_id(Account)
|
obj.id = new_id(Account)
|
||||||
obj.base_code = self.base_code.data
|
obj.base_code = self.base_code.data
|
||||||
if prev_base_code != self.base_code.data:
|
if prev_base_code != self.base_code.data:
|
||||||
last_same_base: Account = Account.query\
|
max_no: int = db.session.scalars(
|
||||||
.filter(Account.base_code == self.base_code.data)\
|
sa.select(sa.func.max(Account.no))
|
||||||
.order_by(Account.base_code.desc()).first()
|
.filter(Account.base_code == self.base_code.data)).one()
|
||||||
obj.no = 1 if last_same_base is None else last_same_base.no + 1
|
obj.no = 1 if max_no is None else max_no + 1
|
||||||
obj.title = self.title.data
|
obj.title = self.title.data
|
||||||
obj.is_offset_needed = self.is_offset_needed.data
|
obj.is_offset_needed = self.is_offset_needed.data
|
||||||
if is_new:
|
if is_new:
|
||||||
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
|
current_user_pk: int = get_current_user_pk()
|
||||||
obj.created_by_id = current_user_pk
|
obj.created_by_id = current_user_pk
|
||||||
obj.updated_by_id = current_user_pk
|
obj.updated_by_id = current_user_pk
|
||||||
if prev_base_code is not None \
|
if prev_base_code is not None \
|
||||||
@ -87,7 +101,7 @@ class AccountForm(FlaskForm):
|
|||||||
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
|
current_user_pk: int = get_current_user_pk()
|
||||||
obj.updated_by_id = current_user_pk
|
obj.updated_by_id = current_user_pk
|
||||||
obj.updated_at = sa.func.now()
|
obj.updated_at = sa.func.now()
|
||||||
if hasattr(self, "__post_update"):
|
if hasattr(self, "__post_update"):
|
||||||
@ -127,3 +141,48 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
|
|||||||
for i in range(len(accounts)):
|
for i in range(len(accounts)):
|
||||||
if accounts[i].no != i + 1:
|
if accounts[i].no != i + 1:
|
||||||
accounts[i].no = i + 1
|
accounts[i].no = i + 1
|
||||||
|
|
||||||
|
|
||||||
|
class AccountReorderForm:
|
||||||
|
"""The form to reorder the accounts."""
|
||||||
|
|
||||||
|
def __init__(self, base: BaseAccount):
|
||||||
|
"""Constructs the form to reorder the accounts under a base account.
|
||||||
|
|
||||||
|
:param base: The base account.
|
||||||
|
"""
|
||||||
|
self.base: BaseAccount = base
|
||||||
|
self.is_modified: bool = False
|
||||||
|
|
||||||
|
def save_order(self) -> None:
|
||||||
|
"""Saves the order of the account.
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
accounts: list[Account] = self.base.accounts
|
||||||
|
|
||||||
|
# Collects the specified order.
|
||||||
|
orders: dict[Account, int] = {}
|
||||||
|
for account in accounts:
|
||||||
|
if f"{account.id}-no" in request.form:
|
||||||
|
try:
|
||||||
|
orders[account] = int(request.form[f"{account.id}-no"])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Missing and invalid orders are appended to the end.
|
||||||
|
missing: list[Account] = [x for x in accounts if x not in orders]
|
||||||
|
if len(missing) > 0:
|
||||||
|
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
||||||
|
for account in missing:
|
||||||
|
orders[account] = next_no
|
||||||
|
|
||||||
|
# Sort by the specified order first, and their original order.
|
||||||
|
accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code))
|
||||||
|
|
||||||
|
# Update the orders.
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
for i in range(len(accounts)):
|
||||||
|
if accounts[i].no != i + 1:
|
||||||
|
accounts[i].no = i + 1
|
||||||
|
self.is_modified = True
|
||||||
|
@ -29,7 +29,7 @@ from accounting.models import Account, BaseAccount
|
|||||||
from accounting.utils.next_url import inherit_next, or_next
|
from accounting.utils.next_url import inherit_next, or_next
|
||||||
from accounting.utils.pagination import Pagination
|
from accounting.utils.pagination import Pagination
|
||||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
from accounting.utils.permission import can_view, has_permission, can_edit
|
||||||
from .forms import AccountForm, sort_accounts_in
|
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("account", __name__)
|
bp: Blueprint = Blueprint("account", __name__)
|
||||||
"""The view blueprint for the account management."""
|
"""The view blueprint for the account management."""
|
||||||
@ -38,7 +38,7 @@ bp: Blueprint = Blueprint("account", __name__)
|
|||||||
@bp.get("", endpoint="list")
|
@bp.get("", endpoint="list")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def list_accounts() -> str:
|
def list_accounts() -> str:
|
||||||
"""Lists the base accounts.
|
"""Lists the accounts.
|
||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
@ -95,7 +95,8 @@ def add_account() -> redirect:
|
|||||||
def show_account_detail(account: Account) -> str:
|
def show_account_detail(account: Account) -> str:
|
||||||
"""Shows the account detail.
|
"""Shows the account detail.
|
||||||
|
|
||||||
:return: The account detail.
|
:param account: The account.
|
||||||
|
:return: The detail.
|
||||||
"""
|
"""
|
||||||
return render_template("accounting/account/detail.html", obj=account)
|
return render_template("accounting/account/detail.html", obj=account)
|
||||||
|
|
||||||
@ -105,7 +106,8 @@ def show_account_detail(account: Account) -> str:
|
|||||||
def show_account_edit_form(account: Account) -> str:
|
def show_account_edit_form(account: Account) -> str:
|
||||||
"""Shows the form to edit an account.
|
"""Shows the form to edit an account.
|
||||||
|
|
||||||
:return: The form to edit an account.
|
:param account: The account.
|
||||||
|
:return: The form to edit the account.
|
||||||
"""
|
"""
|
||||||
form: AccountForm
|
form: AccountForm
|
||||||
if "form" in session:
|
if "form" in session:
|
||||||
@ -123,6 +125,7 @@ def show_account_edit_form(account: Account) -> str:
|
|||||||
def update_account(account: Account) -> redirect:
|
def update_account(account: Account) -> redirect:
|
||||||
"""Updates an account.
|
"""Updates an account.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
:return: The redirection to the account detail on success, or the account
|
:return: The redirection to the account detail on success, or the account
|
||||||
edit form on error.
|
edit form on error.
|
||||||
"""
|
"""
|
||||||
@ -136,7 +139,7 @@ def update_account(account: Account) -> redirect:
|
|||||||
account=account)))
|
account=account)))
|
||||||
with db.session.no_autoflush:
|
with db.session.no_autoflush:
|
||||||
form.populate_obj(account)
|
form.populate_obj(account)
|
||||||
if not db.session.is_modified(account):
|
if not account.is_modified:
|
||||||
flash(lazy_gettext("The account was not modified."), "success")
|
flash(lazy_gettext("The account was not modified."), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.account.detail",
|
return redirect(inherit_next(url_for("accounting.account.detail",
|
||||||
account=account)))
|
account=account)))
|
||||||
@ -152,13 +155,42 @@ def update_account(account: Account) -> redirect:
|
|||||||
def delete_account(account: Account) -> redirect:
|
def delete_account(account: Account) -> redirect:
|
||||||
"""Deletes an account.
|
"""Deletes an account.
|
||||||
|
|
||||||
|
:param account: The account.
|
||||||
:return: The redirection to the account list on success, or the account
|
:return: The redirection to the account list on success, or the account
|
||||||
detail on error.
|
detail on error.
|
||||||
"""
|
"""
|
||||||
for l10n in account.l10n:
|
account.delete()
|
||||||
db.session.delete(l10n)
|
|
||||||
db.session.delete(account)
|
|
||||||
sort_accounts_in(account.base_code, account.id)
|
sort_accounts_in(account.base_code, account.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
flash(lazy_gettext("The account is deleted successfully."), "success")
|
||||||
return redirect(or_next(url_for("accounting.account.list")))
|
return redirect(or_next(url_for("accounting.account.list")))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_account_order(base: BaseAccount) -> str:
|
||||||
|
"""Shows the order of the accounts under a same base account.
|
||||||
|
|
||||||
|
:param base: The base account.
|
||||||
|
:return: The order of the accounts under the base account.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/account/order.html", base=base)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def sort_accounts(base: BaseAccount) -> redirect:
|
||||||
|
"""Reorders the accounts under a base account.
|
||||||
|
|
||||||
|
:param base: The base account.
|
||||||
|
:return: The redirection to the incoming account or the account list. The
|
||||||
|
reordering operation does not fail.
|
||||||
|
"""
|
||||||
|
form: AccountReorderForm = AccountReorderForm(base)
|
||||||
|
form.save_order()
|
||||||
|
if not form.is_modified:
|
||||||
|
flash(lazy_gettext("The order was not modified."), "success")
|
||||||
|
return redirect(or_next(url_for("accounting.account.list")))
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The order is updated successfully."), "success")
|
||||||
|
return redirect(or_next(url_for("accounting.account.list")))
|
||||||
|
@ -23,8 +23,8 @@ from flask import Flask, Blueprint
|
|||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initialize the application.
|
"""Initialize the application.
|
||||||
|
|
||||||
:param bp: The blueprint of the accounting application.
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from .converters import BaseAccountConverter
|
from .converters import BaseAccountConverter
|
||||||
|
@ -46,7 +46,8 @@ def list_accounts() -> str:
|
|||||||
def show_account_detail(account: BaseAccount) -> str:
|
def show_account_detail(account: BaseAccount) -> str:
|
||||||
"""Shows the account detail.
|
"""Shows the account detail.
|
||||||
|
|
||||||
:return: The account detail.
|
:param account: The account.
|
||||||
|
:return: The detail.
|
||||||
"""
|
"""
|
||||||
return render_template("accounting/base-account/detail.html", obj=account)
|
return render_template("accounting/base-account/detail.html", obj=account)
|
||||||
|
|
||||||
|
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,21 +25,15 @@ time.
|
|||||||
|
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
from accounting import AbstractUserUtils
|
db: SQLAlchemy = SQLAlchemy()
|
||||||
|
|
||||||
db: SQLAlchemy
|
|
||||||
"""The database instance."""
|
"""The database instance."""
|
||||||
user_utils: AbstractUserUtils
|
|
||||||
"""The user utilities."""
|
|
||||||
|
|
||||||
|
|
||||||
def set_db(new_db: SQLAlchemy, new_user_utils: AbstractUserUtils) -> None:
|
def set_db(new_db: SQLAlchemy) -> None:
|
||||||
"""Sets the database instance.
|
"""Sets the database instance.
|
||||||
|
|
||||||
:param new_db: The database instance.
|
:param new_db: The database instance.
|
||||||
:param new_user_utils: The user utilities.
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
global db, user_utils
|
global db
|
||||||
db = new_db
|
db = new_db
|
||||||
user_utils = new_user_utils
|
|
||||||
|
@ -39,6 +39,17 @@ def gettext(string, **variables) -> str:
|
|||||||
return domain.gettext(string, **variables)
|
return domain.gettext(string, **variables)
|
||||||
|
|
||||||
|
|
||||||
|
def pgettext(context, string, **variables) -> str:
|
||||||
|
"""A replacement of the Babel gettext() function..
|
||||||
|
|
||||||
|
:param context: The context.
|
||||||
|
:param string: The message to translate.
|
||||||
|
:param variables: The variable substitution.
|
||||||
|
:return: The translated message.
|
||||||
|
"""
|
||||||
|
return domain.pgettext(context, string, **variables)
|
||||||
|
|
||||||
|
|
||||||
def lazy_gettext(string, **variables) -> LazyString:
|
def lazy_gettext(string, **variables) -> LazyString:
|
||||||
"""A replacement of the Babel lazy_gettext() function..
|
"""A replacement of the Babel lazy_gettext() function..
|
||||||
|
|
||||||
@ -105,8 +116,8 @@ def __babel_js_catalog_view() -> Response:
|
|||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
"""Initializes the application.
|
"""Initializes the application.
|
||||||
|
|
||||||
:param bp: The blueprint of the accounting application.
|
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
bp.add_url_rule("/_jstrans.js", "babel_catalog",
|
bp.add_url_rule("/_jstrans.js", "babel_catalog",
|
||||||
|
@ -25,10 +25,8 @@ from flask import current_app
|
|||||||
from flask_babel import get_locale
|
from flask_babel import get_locale
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from accounting.database import db, user_utils
|
from accounting.database import db
|
||||||
|
from accounting.utils.user import user_cls, user_pk_column
|
||||||
user_cls: db.Model = user_utils.cls
|
|
||||||
user_pk_column: db.Column = user_utils.pk_column
|
|
||||||
|
|
||||||
|
|
||||||
class BaseAccount(db.Model):
|
class BaseAccount(db.Model):
|
||||||
@ -66,13 +64,23 @@ class BaseAccount(db.Model):
|
|||||||
return l10n.title
|
return l10n.title
|
||||||
return self.title_l10n
|
return self.title_l10n
|
||||||
|
|
||||||
|
@property
|
||||||
|
def query_values(self) -> list[str]:
|
||||||
|
"""Returns the values to be queried.
|
||||||
|
|
||||||
|
:return: The values to be queried.
|
||||||
|
"""
|
||||||
|
return [self.code, self.title_l10n] + [x.title for x in self.l10n]
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountL10n(db.Model):
|
class BaseAccountL10n(db.Model):
|
||||||
"""A localized base account title."""
|
"""A localized base account title."""
|
||||||
__tablename__ = "accounting_base_accounts_l10n"
|
__tablename__ = "accounting_base_accounts_l10n"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
|
account_code = db.Column(db.String,
|
||||||
ondelete="CASCADE"),
|
db.ForeignKey(BaseAccount.code,
|
||||||
|
onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
nullable=False, primary_key=True)
|
nullable=False, primary_key=True)
|
||||||
"""The code of the account."""
|
"""The code of the account."""
|
||||||
account = db.relationship(BaseAccount, back_populates="l10n")
|
account = db.relationship(BaseAccount, back_populates="l10n")
|
||||||
@ -87,10 +95,12 @@ class Account(db.Model):
|
|||||||
"""An account."""
|
"""An account."""
|
||||||
__tablename__ = "accounting_accounts"
|
__tablename__ = "accounting_accounts"
|
||||||
"""The table name."""
|
"""The table name."""
|
||||||
id = db.Column(db.Integer, nullable=False, primary_key=True)
|
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||||
|
autoincrement=False)
|
||||||
"""The account ID."""
|
"""The account ID."""
|
||||||
base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
|
base_code = db.Column(db.String,
|
||||||
ondelete="CASCADE"),
|
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
nullable=False)
|
nullable=False)
|
||||||
"""The code of the base account."""
|
"""The code of the base account."""
|
||||||
base = db.relationship(BaseAccount, back_populates="accounts")
|
base = db.relationship(BaseAccount, back_populates="accounts")
|
||||||
@ -104,7 +114,9 @@ class Account(db.Model):
|
|||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
server_default=db.func.now())
|
server_default=db.func.now())
|
||||||
"""The time of creation."""
|
"""The time of creation."""
|
||||||
created_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
|
created_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
nullable=False)
|
nullable=False)
|
||||||
"""The ID of the creator."""
|
"""The ID of the creator."""
|
||||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||||
@ -112,7 +124,9 @@ class Account(db.Model):
|
|||||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
server_default=db.func.now())
|
server_default=db.func.now())
|
||||||
"""The time of last update."""
|
"""The time of last update."""
|
||||||
updated_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
|
updated_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
nullable=False)
|
nullable=False)
|
||||||
"""The ID of the updator."""
|
"""The ID of the updator."""
|
||||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||||
@ -120,7 +134,6 @@ class Account(db.Model):
|
|||||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
l10n = db.relationship("AccountL10n", back_populates="account",
|
||||||
lazy=False)
|
lazy=False)
|
||||||
"""The localized titles."""
|
"""The localized titles."""
|
||||||
db.UniqueConstraint(base_code, no)
|
|
||||||
|
|
||||||
__CASH = "1111-001"
|
__CASH = "1111-001"
|
||||||
"""The code of the cash account,"""
|
"""The code of the cash account,"""
|
||||||
@ -182,16 +195,14 @@ class Account(db.Model):
|
|||||||
if l10n.locale == current_locale:
|
if l10n.locale == current_locale:
|
||||||
l10n.title = value
|
l10n.title = value
|
||||||
return
|
return
|
||||||
self.l10n.append(AccountL10n(
|
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
||||||
locale=current_locale, title=value))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_code(cls, code: str) -> t.Self | None:
|
def find_by_code(cls, code: str) -> t.Self | None:
|
||||||
"""Finds an accounting account by its code.
|
"""Finds an account by its code.
|
||||||
|
|
||||||
:param code: The code.
|
:param code: The code.
|
||||||
:return: The accounting account, or None if this account does not
|
:return: The account, or None if this account does not exist.
|
||||||
exist.
|
|
||||||
"""
|
"""
|
||||||
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
||||||
if m is None:
|
if m is None:
|
||||||
@ -288,8 +299,21 @@ class Account(db.Model):
|
|||||||
"""
|
"""
|
||||||
return cls.find_by_code(cls.__NET_CHANGE)
|
return cls.find_by_code(cls.__NET_CHANGE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_modified(self) -> bool:
|
||||||
|
"""Returns whether a product account was modified.
|
||||||
|
|
||||||
|
:return: True if modified, or False otherwise.
|
||||||
|
"""
|
||||||
|
if db.session.is_modified(self):
|
||||||
|
return True
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if db.session.is_modified(l10n):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
"""Deletes this accounting account.
|
"""Deletes this account.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
@ -301,10 +325,128 @@ class Account(db.Model):
|
|||||||
class AccountL10n(db.Model):
|
class AccountL10n(db.Model):
|
||||||
"""A localized account title."""
|
"""A localized account title."""
|
||||||
__tablename__ = "accounting_accounts_l10n"
|
__tablename__ = "accounting_accounts_l10n"
|
||||||
account_id = db.Column(db.Integer, db.ForeignKey(Account.id,
|
"""The table name."""
|
||||||
ondelete="CASCADE"),
|
account_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(Account.id, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
nullable=False, primary_key=True)
|
nullable=False, primary_key=True)
|
||||||
|
"""The account ID."""
|
||||||
account = db.relationship(Account, back_populates="l10n")
|
account = db.relationship(Account, back_populates="l10n")
|
||||||
|
"""The account."""
|
||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||||
|
"""The locale."""
|
||||||
title = db.Column(db.String, nullable=False)
|
title = db.Column(db.String, nullable=False)
|
||||||
db.UniqueConstraint(account_id, locale)
|
"""The localized title."""
|
||||||
|
|
||||||
|
|
||||||
|
class Currency(db.Model):
|
||||||
|
"""A currency."""
|
||||||
|
__tablename__ = "accounting_currencies"
|
||||||
|
"""The table name."""
|
||||||
|
code = db.Column(db.String, nullable=False, primary_key=True)
|
||||||
|
"""The code."""
|
||||||
|
name_l10n = db.Column("name", db.String, nullable=False)
|
||||||
|
"""The name."""
|
||||||
|
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of creation."""
|
||||||
|
created_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the creator."""
|
||||||
|
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||||
|
"""The creator."""
|
||||||
|
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||||
|
server_default=db.func.now())
|
||||||
|
"""The time of last update."""
|
||||||
|
updated_by_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey(user_pk_column,
|
||||||
|
onupdate="CASCADE"),
|
||||||
|
nullable=False)
|
||||||
|
"""The ID of the updator."""
|
||||||
|
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||||
|
"""The updator."""
|
||||||
|
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
||||||
|
lazy=False)
|
||||||
|
"""The localized names."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Returns the string representation of the currency.
|
||||||
|
|
||||||
|
:return: The string representation of the currency.
|
||||||
|
"""
|
||||||
|
return F"{self.name} ({self.code})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""Returns the name in the current locale.
|
||||||
|
|
||||||
|
:return: The name in the current locale.
|
||||||
|
"""
|
||||||
|
current_locale = str(get_locale())
|
||||||
|
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||||
|
return self.name_l10n
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if l10n.locale == current_locale:
|
||||||
|
return l10n.name
|
||||||
|
return self.name_l10n
|
||||||
|
|
||||||
|
@name.setter
|
||||||
|
def name(self, value: str) -> None:
|
||||||
|
"""Sets the name in the current locale.
|
||||||
|
|
||||||
|
:param value: The new name.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
if self.name_l10n is None:
|
||||||
|
self.name_l10n = value
|
||||||
|
return
|
||||||
|
current_locale = str(get_locale())
|
||||||
|
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||||
|
self.name_l10n = value
|
||||||
|
return
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if l10n.locale == current_locale:
|
||||||
|
l10n.name = value
|
||||||
|
return
|
||||||
|
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_modified(self) -> bool:
|
||||||
|
"""Returns whether a product account was modified.
|
||||||
|
|
||||||
|
:return: True if modified, or False otherwise.
|
||||||
|
"""
|
||||||
|
if db.session.is_modified(self):
|
||||||
|
return True
|
||||||
|
for l10n in self.l10n:
|
||||||
|
if db.session.is_modified(l10n):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
"""Deletes the currency.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
|
||||||
|
cls: t.Type[t.Self] = self.__class__
|
||||||
|
cls.query.filter(cls.code == self.code).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyL10n(db.Model):
|
||||||
|
"""A localized currency name."""
|
||||||
|
__tablename__ = "accounting_currencies_l10n"
|
||||||
|
"""The table name."""
|
||||||
|
currency_code = db.Column(db.String,
|
||||||
|
db.ForeignKey(Currency.code, onupdate="CASCADE",
|
||||||
|
ondelete="CASCADE"),
|
||||||
|
nullable=False, primary_key=True)
|
||||||
|
"""The currency code."""
|
||||||
|
currency = db.relationship(Currency, back_populates="l10n")
|
||||||
|
"""The currency."""
|
||||||
|
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||||
|
"""The locale."""
|
||||||
|
name = db.Column(db.String, nullable=False)
|
||||||
|
"""The localized name."""
|
||||||
|
@ -21,48 +21,50 @@
|
|||||||
* First written: 2023/2/1
|
* First written: 2023/2/1
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.clickable {
|
.accounting-clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-group .btn .search-input {
|
.btn-group .btn .accounting-search-input {
|
||||||
min-height: calc(1em + .5rem + 2px);
|
min-height: calc(1em + .5rem + 2px);
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
.btn-group .btn .search-label button {
|
.btn-group .btn .accounting-search-label button {
|
||||||
border: none;
|
border: none;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The account management */
|
/** The card layout */
|
||||||
.account {
|
.accounting-card {
|
||||||
padding: 2em 1.5em;
|
padding: 2em 1.5em;
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
background-color: #E9ECEF;
|
background-color: #E9ECEF;
|
||||||
border-radius: 0.3em;
|
border-radius: 0.3em;
|
||||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
||||||
}
|
}
|
||||||
.account .account-title {
|
.accounting-card-title {
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: bolder;
|
font-weight: bolder;
|
||||||
}
|
}
|
||||||
.account .account-code {
|
.accounting-card-code {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
color: #373b3e;
|
color: #373b3e;
|
||||||
}
|
}
|
||||||
.list-base-selector {
|
|
||||||
|
/** The option selector */
|
||||||
|
.accounting-selector-list {
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Material Design text field (floating form control in Bootstrap) */
|
/* The Material Design text field (floating form control in Bootstrap) */
|
||||||
.material-text-field {
|
.accounting-material-text-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: calc(3.5rem + 2px);
|
min-height: calc(3.5rem + 2px);
|
||||||
padding-top: 1.625rem;
|
padding-top: 1.625rem;
|
||||||
}
|
}
|
||||||
.material-text-field > .form-label {
|
.accounting-material-text-field > .form-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -71,27 +73,27 @@
|
|||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
transition: opacity .1s ease-in-out,transform .1s ease-in-out;
|
transition: opacity .1s ease-in-out,transform .1s ease-in-out;
|
||||||
}
|
}
|
||||||
.material-text-field.not-empty > .form-label {
|
.accounting-material-text-field.accounting-not-empty > .form-label {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
|
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The Material Design floating action buttons */
|
/* The Material Design floating action buttons */
|
||||||
.material-fab {
|
.accounting-material-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 2rem;
|
right: 2rem;
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
}
|
}
|
||||||
.material-fab .btn {
|
.accounting-material-fab .btn {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform: scale(1.5);
|
transform: scale(1.5);
|
||||||
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
|
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 2.5rem;
|
margin-top: 2.5rem;
|
||||||
}
|
}
|
||||||
.material-fab .btn:hover, .material-fab .btn:focus {
|
.accounting-material-fab .btn:hover, .accounting-material-fab .btn:focus {
|
||||||
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,12 +23,12 @@
|
|||||||
|
|
||||||
// Initializes the page JavaScript.
|
// Initializes the page JavaScript.
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
initializeBaseAccountSelector()
|
initializeBaseAccountSelector();
|
||||||
document.getElementById("account-base-code")
|
document.getElementById("accounting-base-code")
|
||||||
.onchange = validateBase;
|
.onchange = validateBase;
|
||||||
document.getElementById("account-title")
|
document.getElementById("accounting-title")
|
||||||
.onchange = validateTitle;
|
.onchange = validateTitle;
|
||||||
document.getElementById("account-form")
|
document.getElementById("accounting-form")
|
||||||
.onsubmit = validateForm;
|
.onsubmit = validateForm;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,25 +38,25 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function initializeBaseAccountSelector() {
|
function initializeBaseAccountSelector() {
|
||||||
const selector = document.getElementById("select-base-modal");
|
const selector = document.getElementById("accounting-base-selector-model");
|
||||||
const base = document.getElementById("account-base");
|
const base = document.getElementById("accounting-base");
|
||||||
const baseCode = document.getElementById("account-base-code");
|
const baseCode = document.getElementById("accounting-base-code");
|
||||||
const baseContent = document.getElementById("account-base-content");
|
const baseContent = document.getElementById("accounting-base-content");
|
||||||
const options = Array.from(document.getElementsByClassName("list-group-item-base"));
|
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||||
const btnClear = document.getElementById("btn-clear-base");
|
const btnClear = document.getElementById("accounting-btn-clear-base");
|
||||||
selector.addEventListener("show.bs.modal", function () {
|
selector.addEventListener("show.bs.modal", function () {
|
||||||
base.classList.add("not-empty");
|
base.classList.add("accounting-not-empty");
|
||||||
options.forEach(function (item) {
|
options.forEach(function (item) {
|
||||||
item.classList.remove("active");
|
item.classList.remove("active");
|
||||||
});
|
});
|
||||||
const selected = document.getElementById("list-group-item-base-" + baseCode.value);
|
const selected = document.getElementById("accounting-base-option-" + baseCode.value);
|
||||||
if (selected !== null) {
|
if (selected !== null) {
|
||||||
selected.classList.add("active");
|
selected.classList.add("active");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
selector.addEventListener("hidden.bs.modal", function () {
|
selector.addEventListener("hidden.bs.modal", function () {
|
||||||
if (baseCode.value === "") {
|
if (baseCode.value === "") {
|
||||||
base.classList.remove("not-empty");
|
base.classList.remove("accounting-not-empty");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
options.forEach(function (option) {
|
options.forEach(function (option) {
|
||||||
@ -79,6 +79,54 @@ function initializeBaseAccountSelector() {
|
|||||||
validateBase();
|
validateBase();
|
||||||
bootstrap.Modal.getInstance(selector).hide();
|
bootstrap.Modal.getInstance(selector).hide();
|
||||||
}
|
}
|
||||||
|
initializeBaseAccountQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the query on the base account options.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeBaseAccountQuery() {
|
||||||
|
const query = document.getElementById("accounting-base-selector-query");
|
||||||
|
const optionList = document.getElementById("accounting-base-option-list");
|
||||||
|
const options = Array.from(document.getElementsByClassName("accounting-base-option"));
|
||||||
|
const queryNoResult = document.getElementById("accounting-base-option-no-result");
|
||||||
|
query.addEventListener("input", function () {
|
||||||
|
console.log(query.value);
|
||||||
|
if (query.value === "") {
|
||||||
|
options.forEach(function (option) {
|
||||||
|
option.classList.remove("d-none");
|
||||||
|
});
|
||||||
|
optionList.classList.remove("d-none");
|
||||||
|
queryNoResult.classList.add("d-none");
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let hasAnyMatched = false;
|
||||||
|
options.forEach(function (option) {
|
||||||
|
const queryValues = JSON.parse(option.dataset.queryValues);
|
||||||
|
let isMatched = false;
|
||||||
|
for (let i = 0; i < queryValues.length; i++) {
|
||||||
|
if (queryValues[i].includes(query.value)) {
|
||||||
|
isMatched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isMatched) {
|
||||||
|
option.classList.remove("d-none");
|
||||||
|
hasAnyMatched = true;
|
||||||
|
} else {
|
||||||
|
option.classList.add("d-none");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!hasAnyMatched) {
|
||||||
|
optionList.classList.add("d-none");
|
||||||
|
queryNoResult.classList.remove("d-none");
|
||||||
|
} else {
|
||||||
|
optionList.classList.remove("d-none");
|
||||||
|
queryNoResult.classList.add("d-none");
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -101,9 +149,9 @@ function validateForm() {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function validateBase() {
|
function validateBase() {
|
||||||
const field = document.getElementById("account-base-code");
|
const field = document.getElementById("accounting-base-code");
|
||||||
const error = document.getElementById("account-base-code-error");
|
const error = document.getElementById("accounting-base-code-error");
|
||||||
const displayField = document.getElementById("account-base");
|
const displayField = document.getElementById("accounting-base");
|
||||||
field.value = field.value.trim();
|
field.value = field.value.trim();
|
||||||
if (field.value === "") {
|
if (field.value === "") {
|
||||||
displayField.classList.add("is-invalid");
|
displayField.classList.add("is-invalid");
|
||||||
@ -122,8 +170,8 @@ function validateBase() {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
function validateTitle() {
|
function validateTitle() {
|
||||||
const field = document.getElementById("account-title");
|
const field = document.getElementById("accounting-title");
|
||||||
const error = document.getElementById("account-title-error");
|
const error = document.getElementById("accounting-title-error");
|
||||||
field.value = field.value.trim();
|
field.value = field.value.trim();
|
||||||
if (field.value === "") {
|
if (field.value === "") {
|
||||||
field.classList.add("is-invalid");
|
field.classList.add("is-invalid");
|
||||||
|
39
src/accounting/static/js/account-order.js
Normal file
39
src/accounting/static/js/account-order.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* account-order.js: The JavaScript for the account order
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/2
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const list = document.getElementById("accounting-order-list");
|
||||||
|
if (list !== null) {
|
||||||
|
const onReorder = function () {
|
||||||
|
const accounts = Array.from(list.children);
|
||||||
|
for (let i = 0; i < accounts.length; i++) {
|
||||||
|
const no = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-no");
|
||||||
|
const code = document.getElementById("accounting-order-" + accounts[i].dataset.id + "-code");
|
||||||
|
no.value = String(i + 1);
|
||||||
|
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initializeDragAndDropReordering(list, onReorder);
|
||||||
|
}
|
||||||
|
});
|
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;
|
||||||
|
}
|
108
src/accounting/static/js/drag-and-drop-reorder.js
Normal file
108
src/accounting/static/js/drag-and-drop-reorder.js
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/3
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the drag-and-drop reordering on a list.
|
||||||
|
*
|
||||||
|
* @param list {HTMLElement} the list to be reordered
|
||||||
|
* @param onReorder {(function())|*} The callback to reorder the items
|
||||||
|
*/
|
||||||
|
function initializeDragAndDropReordering(list, onReorder) {
|
||||||
|
initializeMouseDragAndDropReordering(list, onReorder);
|
||||||
|
initializeTouchDragAndDropReordering(list, onReorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the drag-and-drop reordering with mouse.
|
||||||
|
*
|
||||||
|
* @param list {HTMLElement} the list to be reordered
|
||||||
|
* @param onReorder {(function())|*} The callback to reorder the items
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeMouseDragAndDropReordering(list, onReorder) {
|
||||||
|
const items = Array.from(list.children);
|
||||||
|
let dragged = null;
|
||||||
|
items.forEach(function (item) {
|
||||||
|
item.draggable = true;
|
||||||
|
item.addEventListener("dragstart", function () {
|
||||||
|
dragged = item;
|
||||||
|
dragged.classList.add("list-group-item-dark");
|
||||||
|
});
|
||||||
|
item.addEventListener("dragover", function () {
|
||||||
|
onDragOver(dragged, item);
|
||||||
|
onReorder();
|
||||||
|
});
|
||||||
|
item.addEventListener("dragend", function () {
|
||||||
|
dragged.classList.remove("list-group-item-dark");
|
||||||
|
dragged = null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the drag-and-drop reordering with touch devices.
|
||||||
|
*
|
||||||
|
* @param list {HTMLElement} the list to be reordered
|
||||||
|
* @param onReorder {(function())|*} The callback to reorder the items
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||||
|
const items = Array.from(list.children);
|
||||||
|
items.forEach(function (item) {
|
||||||
|
item.addEventListener("touchstart", function () {
|
||||||
|
item.classList.add("list-group-item-dark");
|
||||||
|
});
|
||||||
|
item.addEventListener("touchmove", function (event) {
|
||||||
|
const touch = event.targetTouches[0];
|
||||||
|
const target = document.elementFromPoint(touch.pageX, touch.pageY);
|
||||||
|
onDragOver(item, target);
|
||||||
|
onReorder();
|
||||||
|
});
|
||||||
|
item.addEventListener("touchend", function () {
|
||||||
|
item.classList.remove("list-group-item-dark");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles when an item is dragged over the other item.
|
||||||
|
*
|
||||||
|
* @param dragged {Element} the item that was dragged
|
||||||
|
* @param target {Element} the other item that was dragged over
|
||||||
|
*/
|
||||||
|
function onDragOver(dragged, target) {
|
||||||
|
if (target.parentElement !== dragged.parentElement || target === dragged) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let isBefore = false;
|
||||||
|
for (let p = target; p !== null; p = p.previousSibling) {
|
||||||
|
if (p === dragged) {
|
||||||
|
isBefore = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isBefore) {
|
||||||
|
target.parentElement.insertBefore(dragged, target.nextSibling);
|
||||||
|
} else {
|
||||||
|
target.parentElement.insertBefore(dragged, target);
|
||||||
|
}
|
||||||
|
}
|
@ -26,41 +26,47 @@ First written: 2023/1/31
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-3">
|
<div class="btn-group mb-3">
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
{{ A_("Back") }}
|
{{ A_("Back") }}
|
||||||
</a>
|
</a>
|
||||||
{% if can_edit_accounting() %}
|
{% if accounting_can_edit() %}
|
||||||
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
|
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
|
||||||
<i class="fa-solid fa-gear"></i>
|
<i class="fa-solid fa-gear"></i>
|
||||||
{{ A_("Settings") }}
|
{{ A_("Settings") }}
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
|
{% endif %}
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
|
||||||
|
<i class="fa-solid fa-bars-staggered"></i>
|
||||||
|
{{ A_("Order") }}
|
||||||
|
</a>
|
||||||
|
{% if accounting_can_edit() %}
|
||||||
|
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
|
||||||
<i class="fa-solid fa-trash"></i>
|
<i class="fa-solid fa-trash"></i>
|
||||||
{{ A_("Delete") }}
|
{{ A_("Delete") }}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_edit_accounting() %}
|
{% if accounting_can_edit() %}
|
||||||
<div class="d-md-none material-fab">
|
<div class="d-md-none accounting-material-fab">
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if can_edit_accounting() %}
|
{% if accounting_can_edit() %}
|
||||||
<form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
|
<form action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
|
||||||
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
{% if "next" in request.args %}
|
{% if "next" in request.args %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
|
<div class="modal fade" id="accounting-delete-modal" tabindex="-1" aria-labelledby="accounting-delete-model-label" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@ -76,9 +82,9 @@ First written: 2023/1/31
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="account col-sm-6">
|
<div class="accounting-card col-sm-6">
|
||||||
<div class="account-title">{{ obj.title }}</div>
|
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||||
<div class="account-code">{{ obj.code }}</div>
|
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||||
{% if obj.is_offset_needed %}
|
{% if obj.is_offset_needed %}
|
||||||
<div>
|
<div>
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
|
||||||
|
@ -23,6 +23,6 @@ First written: 2023/2/1
|
|||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|inherit_next }}{% endblock %}
|
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|accounting_inherit_next }}{% endblock %}
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}
|
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}
|
||||||
|
@ -34,16 +34,16 @@ First written: 2023/2/1
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="account-form" action="{% block action_url %}{% endblock %}" method="post">
|
<form id="accounting-form" action="{% block action_url %}{% endblock %}" method="post">
|
||||||
{{ form.csrf_token }}
|
{{ form.csrf_token }}
|
||||||
{% if "next" in request.args %}
|
{% if "next" in request.args %}
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="account-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
<input id="accounting-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
|
||||||
<div id="account-base" class="form-control clickable material-text-field {% if form.base_code.data %} not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#select-base-modal">
|
<div id="accounting-base" class="form-control accounting-clickable accounting-material-text-field {% if form.base_code.data %} accounting-not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-base-selector-model">
|
||||||
<label id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label>
|
<label class="form-label" for="accounting-base">{{ A_("Base account") }}</label>
|
||||||
<div id="account-base-content">
|
<div id="accounting-base-content">
|
||||||
{% if form.base_code.data %}
|
{% if form.base_code.data %}
|
||||||
{% if form.base_code.errors %}
|
{% if form.base_code.errors %}
|
||||||
{{ A_("(Unknown)") }}
|
{{ A_("(Unknown)") }}
|
||||||
@ -53,18 +53,18 @@ First written: 2023/2/1
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="account-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
<div id="accounting-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
<div class="form-floating mb-3">
|
||||||
<input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
|
<input id="accounting-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
|
||||||
<label class="form-label" for="account-title">{{ A_("Title") }}</label>
|
<label class="form-label" for="accounting-title">{{ A_("Title") }}</label>
|
||||||
<div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
<div id="accounting-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-check form-switch mb-3">
|
<div class="form-check form-switch mb-3">
|
||||||
<input id="account-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
|
<input id="accounting-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
|
||||||
<label class="form-check-label" for="account-is-offset-needed">
|
<label class="form-check-label" for="accounting-is-offset-needed">
|
||||||
{{ A_("The entries in the account need offsets.") }}
|
{{ A_("The entries in the account need offsets.") }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -76,43 +76,44 @@ First written: 2023/2/1
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-md-none material-fab">
|
<div class="d-md-none accounting-material-fab">
|
||||||
<button class="btn btn-primary" type="submit">
|
<button class="btn btn-primary" type="submit">
|
||||||
<i class="fa-solid fa-floppy-disk"></i>
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="modal fade" id="select-base-modal" tabindex="-1" aria-labelledby="select-base-model-label" aria-hidden="true">
|
<div class="modal fade" id="accounting-base-selector-model" tabindex="-1" aria-labelledby="accounting-base-selector-model-label" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5" id="base-selector-model-label">{{ A_("Select Base Account") }}</h1>
|
<h1 class="modal-title fs-5" id="accounting-base-selector-model-label">{{ A_("Select Base Account") }}</h1>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="input-group mb-2">
|
<div class="input-group mb-2">
|
||||||
<input id="select-base-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
|
<input id="accounting-base-selector-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
|
||||||
<label class="input-group-text" for="select-base-query">
|
<label class="input-group-text" for="accounting-base-selector-query">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
{{ A_("Search") }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="list-group list-base-selector">
|
<ul id="accounting-base-option-list" class="list-group accounting-selector-list">
|
||||||
{% for base in form.base_options %}
|
{% for base in form.base_options %}
|
||||||
<li id="list-group-item-base-{{ base.code }}" class="list-group-item list-group-item-base clickable" data-code="{{ base.code }}" data-content="{{ base }}">
|
<li id="accounting-base-option-{{ base.code }}" class="list-group-item accounting-base-option accounting-clickable" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}">
|
||||||
{{ base }}
|
{{ base }}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
|
<p id="accounting-base-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
||||||
{% if form.base_code.data %}
|
{% if form.base_code.data %}
|
||||||
<button id="btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
|
<button id="accounting-btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<button id="btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
|
<button id="accounting-btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -21,20 +21,20 @@ First written: 2023/1/30
|
|||||||
#}
|
#}
|
||||||
{% extends "accounting/base.html" %}
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Account Management") }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Account Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2">
|
<div class="btn-group mb-2">
|
||||||
{% if can_edit_accounting() %}
|
{% if accounting_can_edit() %}
|
||||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
|
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
|
||||||
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
||||||
<label for="search-input" class="search-label">
|
<label for="accounting-search" class="accounting-search-label">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
{{ A_("Search") }}
|
||||||
@ -43,9 +43,9 @@ First written: 2023/1/30
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can_edit_accounting() %}
|
{% if accounting_can_edit() %}
|
||||||
<div class="d-md-none material-fab">
|
<div class="d-md-none accounting-material-fab">
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@ -56,7 +56,7 @@ First written: 2023/1/30
|
|||||||
|
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
{% for item in list %}
|
{% for item in list %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|append_next }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|accounting_append_next }}">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
{% if item.is_offset_needed %}
|
{% if item.is_offset_needed %}
|
||||||
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
|
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
|
||||||
|
84
src/accounting/templates/accounting/account/order.html
Normal file
84
src/accounting/templates/accounting/account/order.html
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
order.html: The order of the accounts under a same base account
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/2
|
||||||
|
#}
|
||||||
|
{% extends "accounting/account/include/form.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/account-order.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("The Accounts of %(base)s", base=base) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group mb-3">
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||||
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
|
{{ A_("Back") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if base.accounts|length > 1 and accounting_can_edit() %}
|
||||||
|
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{% if "next" in request.args %}
|
||||||
|
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||||
|
{% endif %}
|
||||||
|
<ul id="accounting-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
|
||||||
|
{% for account in base.accounts|sort(attribute="no") %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}">
|
||||||
|
<input id="accounting-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
|
||||||
|
<div>
|
||||||
|
<span id="accounting-order-{{ account.id }}-code">{{ account.code }}</span>
|
||||||
|
{{ account.title }}
|
||||||
|
</div>
|
||||||
|
<i class="fa-solid fa-bars"></i>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-none d-md-block">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
{{ A_("Save") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-md-none accounting-material-fab">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% elif base.accounts %}
|
||||||
|
<ul class="list-group mb-3">
|
||||||
|
{% for account in base.accounts|sort(attribute="no") %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
{{ account }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -26,19 +26,19 @@ First written: 2023/2/1
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-3">
|
<div class="btn-group mb-3">
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
|
||||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
{{ A_("Back") }}
|
{{ A_("Back") }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="account col-sm-6">
|
<div class="accounting-card col-sm-6">
|
||||||
<div class="account-title">{{ obj.title }}</div>
|
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||||
<div class="account-code">{{ obj.code }}</div>
|
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||||
{% if obj.accounts %}
|
{% if obj.accounts %}
|
||||||
<div>
|
<div>
|
||||||
{% for account in obj.accounts %}
|
{% for account in obj.accounts %}
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|append_next }}">
|
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
|
||||||
{{ account }}
|
{{ account }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -21,14 +21,14 @@ First written: 2023/1/26
|
|||||||
#}
|
#}
|
||||||
{% extends "accounting/base.html" %}
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Base Account Managements") }}{% endblock %}{% endblock %}
|
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Base Account Managements") }}{% endif %}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="btn-group mb-2">
|
<div class="btn-group mb-2">
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
|
||||||
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
<input id="accounting-search" class="form-control form-control-sm accounting-search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
||||||
<label for="search-input" class="search-label">
|
<label for="accounting-search" class="accounting-search-label">
|
||||||
<button type="submit">
|
<button type="submit">
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
{{ A_("Search") }}
|
{{ A_("Search") }}
|
||||||
@ -42,7 +42,7 @@ First written: 2023/1/26
|
|||||||
|
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
{% for item in list %}
|
{% for item in list %}
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|append_next }}">
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|accounting_append_next }}">
|
||||||
{{ item }}
|
{{ item }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
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)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/1/26
|
First written: 2023/1/26
|
||||||
#}
|
#}
|
||||||
{% if can_view_accounting() %}
|
{% if accounting_can_view() %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
<span class="nav-link dropdown-toggle" data-bs-toggle="dropdown">
|
||||||
<i class="fa-solid fa-gear"></i>
|
<i class="fa-solid fa-gear"></i>
|
||||||
@ -38,6 +38,12 @@ First written: 2023/1/26
|
|||||||
{{ A_("Base Accounts") }}
|
{{ A_("Base Accounts") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
|
||||||
|
<i class="fa-solid fa-money-bill-wave"></i>
|
||||||
|
{{ A_("Currencies") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -19,10 +19,10 @@ pagination.html: The pagination navigation bar.
|
|||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
First written: 2023/1/26
|
First written: 2023/1/26
|
||||||
#}
|
#}
|
||||||
{% if pagination.is_needed %}
|
{% if pagination.is_paged %}
|
||||||
<nav aria-label="Page navigation">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% for link in pagination.page_links %}
|
{% for link in pagination.pages %}
|
||||||
{% if link.uri is none %}
|
{% if link.uri is none %}
|
||||||
<li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
|
<li class="page-item disabled {% if not link.is_for_mobile %} d-none d-md-inline {% endif %}">
|
||||||
<span class="page-link">
|
<span class="page-link">
|
||||||
@ -42,7 +42,7 @@ First written: 2023/1/26
|
|||||||
{{ pagination.page_size }}
|
{{ pagination.page_size }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="dropdown-menu">
|
<ul class="dropdown-menu">
|
||||||
{% for link in pagination.page_sizes %}
|
{% for link in pagination.page_size_options %}
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
|
<a class="dropdown-item {% if link.is_current %} active {% endif %}" href="{{ link.uri }}">
|
||||||
{{ link.text }}
|
{{ link.text }}
|
||||||
|
@ -8,8 +8,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
|
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
|
||||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||||
"POT-Creation-Date: 2023-02-01 19:51+0800\n"
|
"POT-Creation-Date: 2023-02-07 16:22+0800\n"
|
||||||
"PO-Revision-Date: 2023-02-01 19:52+0800\n"
|
"PO-Revision-Date: 2023-02-07 18:04+0800\n"
|
||||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||||
"Language: zh_Hant\n"
|
"Language: zh_Hant\n"
|
||||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||||
@ -19,35 +19,94 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.11.0\n"
|
"Generated-By: Babel 2.11.0\n"
|
||||||
|
|
||||||
#: src/accounting/account/forms.py:39
|
#: src/accounting/account/forms.py:41
|
||||||
msgid "The base account does not exist."
|
msgid "The base account does not exist."
|
||||||
msgstr "沒有這個基本科目。"
|
msgstr "沒有這個基本科目。"
|
||||||
|
|
||||||
#: src/accounting/account/forms.py:48
|
#: src/accounting/account/forms.py:52
|
||||||
|
msgid "The base account is not available."
|
||||||
|
msgstr "不能選這個基本科目。"
|
||||||
|
|
||||||
|
#: src/accounting/account/forms.py:61
|
||||||
#: src/accounting/static/js/account-form.js:110
|
#: src/accounting/static/js/account-form.js:110
|
||||||
msgid "Please select the base account."
|
msgid "Please select the base account."
|
||||||
msgstr "請選擇基本科目。"
|
msgstr "請選擇基本科目。"
|
||||||
|
|
||||||
#: src/accounting/account/forms.py:53
|
#: src/accounting/account/forms.py:67
|
||||||
msgid "Please fill in the title"
|
msgid "Please fill in the title"
|
||||||
msgstr "請填上標題。"
|
msgstr "請填上標題。"
|
||||||
|
|
||||||
|
#: src/accounting/account/query.py:50
|
||||||
|
#: src/accounting/templates/accounting/account/detail.html:90
|
||||||
|
#: src/accounting/templates/accounting/account/list.html:62
|
||||||
|
msgid "Offset needed"
|
||||||
|
msgstr "逐筆核銷"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:88
|
#: src/accounting/account/views.py:88
|
||||||
msgid "The account is added successfully"
|
msgid "The account is added successfully"
|
||||||
msgstr "科目加好了。"
|
msgstr "科目加好了。"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:140
|
#: src/accounting/account/views.py:143
|
||||||
msgid "The account was not modified."
|
msgid "The account was not modified."
|
||||||
msgstr "科目未異動。"
|
msgstr "科目未異動。"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:145
|
#: src/accounting/account/views.py:148
|
||||||
msgid "The account is updated successfully."
|
msgid "The account is updated successfully."
|
||||||
msgstr "科目存好了。"
|
msgstr "科目存好了。"
|
||||||
|
|
||||||
#: src/accounting/account/views.py:163
|
#: src/accounting/account/views.py:165
|
||||||
msgid "The account is deleted successfully."
|
msgid "The account is deleted successfully."
|
||||||
msgstr "科目刪掉了"
|
msgstr "科目刪掉了"
|
||||||
|
|
||||||
|
#: src/accounting/account/views.py:192
|
||||||
|
msgid "The order was not modified."
|
||||||
|
msgstr "順序未異動。"
|
||||||
|
|
||||||
|
#: src/accounting/account/views.py:195
|
||||||
|
msgid "The order is updated successfully."
|
||||||
|
msgstr "順序存好了。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/forms.py:47
|
||||||
|
#: src/accounting/static/js/currency-form.js:136
|
||||||
|
msgid "Code conflicts with another currency."
|
||||||
|
msgstr "代碼與其它貨幣重複。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/forms.py:52
|
||||||
|
#: src/accounting/static/js/currency-form.js:92
|
||||||
|
msgid "Please fill in the code."
|
||||||
|
msgstr "請填上代碼。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/forms.py:54
|
||||||
|
#: src/accounting/static/js/currency-form.js:103
|
||||||
|
msgid "Code can only be composed of 3 upper-cased letters."
|
||||||
|
msgstr "代碼限為三個大寫英文字母。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/forms.py:57
|
||||||
|
#: src/accounting/static/js/currency-form.js:98
|
||||||
|
msgid "This code is not available."
|
||||||
|
msgstr "不能用這個代碼。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/forms.py:63
|
||||||
|
#: src/accounting/static/js/currency-form.js:168
|
||||||
|
msgid "Please fill in the name."
|
||||||
|
msgstr "請填上名稱。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/views.py:90
|
||||||
|
msgid "The currency is added successfully"
|
||||||
|
msgstr "貨幣加好了。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/views.py:146
|
||||||
|
msgid "The currency was not modified."
|
||||||
|
msgstr "貨幣未異動。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/views.py:151
|
||||||
|
msgid "The currency is updated successfully."
|
||||||
|
msgstr "貨幣存好了。"
|
||||||
|
|
||||||
|
#: src/accounting/currency/views.py:167
|
||||||
|
msgid "The currency is deleted successfully."
|
||||||
|
msgstr "貨幣刪掉了"
|
||||||
|
|
||||||
#: src/accounting/static/js/account-form.js:130
|
#: src/accounting/static/js/account-form.js:130
|
||||||
msgid "Please fill in the title."
|
msgid "Please fill in the title."
|
||||||
msgstr "請填上標題。"
|
msgstr "請填上標題。"
|
||||||
@ -58,45 +117,53 @@ msgstr "新增科目"
|
|||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:31
|
#: src/accounting/templates/accounting/account/detail.html:31
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:33
|
#: src/accounting/templates/accounting/account/include/form.html:33
|
||||||
|
#: src/accounting/templates/accounting/account/order.html:36
|
||||||
#: src/accounting/templates/accounting/base-account/detail.html:31
|
#: src/accounting/templates/accounting/base-account/detail.html:31
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:31
|
||||||
|
#: src/accounting/templates/accounting/currency/include/form.html:33
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr "回上頁"
|
msgstr "回上頁"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:36
|
#: src/accounting/templates/accounting/account/detail.html:36
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:36
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "設定"
|
msgstr "設定"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:40
|
#: src/accounting/templates/accounting/account/detail.html:41
|
||||||
|
msgid "Order"
|
||||||
|
msgstr "次序"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/account/detail.html:46
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:42
|
||||||
msgid "Delete"
|
msgid "Delete"
|
||||||
msgstr "刪除"
|
msgstr "刪除"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:63
|
#: src/accounting/templates/accounting/account/detail.html:69
|
||||||
msgid "Delete Account Confirmation"
|
msgid "Delete Account Confirmation"
|
||||||
msgstr "科目刪除確認"
|
msgstr "科目刪除確認"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:67
|
#: src/accounting/templates/accounting/account/detail.html:73
|
||||||
msgid "Do you really want to delete this account?"
|
msgid "Do you really want to delete this account?"
|
||||||
msgstr "你確定要刪掉這個科目嗎?"
|
msgstr "你確定要刪掉這個科目嗎?"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:70
|
#: src/accounting/templates/accounting/account/detail.html:76
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:111
|
#: src/accounting/templates/accounting/account/include/form.html:111
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:72
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "取消"
|
msgstr "取消"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:71
|
#: src/accounting/templates/accounting/account/detail.html:77
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:73
|
||||||
msgid "Confirm"
|
msgid "Confirm"
|
||||||
msgstr "確定"
|
msgstr "確定"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:84
|
#: src/accounting/templates/accounting/account/detail.html:94
|
||||||
#: src/accounting/templates/accounting/account/list.html:62
|
#: src/accounting/templates/accounting/currency/detail.html:85
|
||||||
msgid "Offset needed"
|
|
||||||
msgstr "逐筆核銷"
|
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:88
|
|
||||||
msgid "Created"
|
msgid "Created"
|
||||||
msgstr "建檔"
|
msgstr "建檔"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/detail.html:89
|
#: src/accounting/templates/accounting/account/detail.html:95
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:86
|
||||||
msgid "Updated"
|
msgid "Updated"
|
||||||
msgstr "更新"
|
msgstr "更新"
|
||||||
|
|
||||||
@ -105,25 +172,47 @@ msgstr "更新"
|
|||||||
msgid "%(account)s Settings"
|
msgid "%(account)s Settings"
|
||||||
msgstr "%(account)s設定"
|
msgstr "%(account)s設定"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/account/list.html:24
|
||||||
|
#: src/accounting/templates/accounting/base-account/list.html:24
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:24
|
||||||
|
#, python-format
|
||||||
|
msgid "Search Result for \"%(query)s\""
|
||||||
|
msgstr "「%(query)s」搜尋結果"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/list.html:24
|
#: src/accounting/templates/accounting/account/list.html:24
|
||||||
msgid "Account Management"
|
msgid "Account Management"
|
||||||
msgstr "科目管理"
|
msgstr "科目管理"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/list.html:32
|
#: src/accounting/templates/accounting/account/list.html:32
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:32
|
||||||
msgid "New"
|
msgid "New"
|
||||||
msgstr "新增"
|
msgstr "新增"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:98
|
#: src/accounting/templates/accounting/account/include/form.html:98
|
||||||
#: src/accounting/templates/accounting/account/list.html:40
|
#: src/accounting/templates/accounting/account/list.html:40
|
||||||
#: src/accounting/templates/accounting/base-account/list.html:34
|
#: src/accounting/templates/accounting/base-account/list.html:34
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:40
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr "搜尋"
|
msgstr "搜尋"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/list.html:68
|
#: src/accounting/templates/accounting/account/list.html:68
|
||||||
|
#: src/accounting/templates/accounting/account/order.html:81
|
||||||
#: src/accounting/templates/accounting/base-account/list.html:51
|
#: src/accounting/templates/accounting/base-account/list.html:51
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:57
|
||||||
msgid "There is no data."
|
msgid "There is no data."
|
||||||
msgstr "沒有資料。"
|
msgstr "沒有資料。"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/account/order.html:29
|
||||||
|
#, python-format
|
||||||
|
msgid "The Accounts of %(base)s"
|
||||||
|
msgstr "%(base)s下的科目"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/account/include/form.html:75
|
||||||
|
#: src/accounting/templates/accounting/account/order.html:62
|
||||||
|
#: src/accounting/templates/accounting/currency/include/form.html:57
|
||||||
|
msgid "Save"
|
||||||
|
msgstr "儲存"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:45
|
#: src/accounting/templates/accounting/account/include/form.html:45
|
||||||
msgid "Base account"
|
msgid "Base account"
|
||||||
msgstr "基本科目"
|
msgstr "基本科目"
|
||||||
@ -140,10 +229,6 @@ msgstr "標題"
|
|||||||
msgid "The entries in the account need offsets."
|
msgid "The entries in the account need offsets."
|
||||||
msgstr "帳目要逐筆核銷。"
|
msgstr "帳目要逐筆核銷。"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:75
|
|
||||||
msgid "Save"
|
|
||||||
msgstr "儲存"
|
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/account/include/form.html:90
|
#: src/accounting/templates/accounting/account/include/form.html:90
|
||||||
msgid "Select Base Account"
|
msgid "Select Base Account"
|
||||||
msgstr "選擇基本科目"
|
msgstr "選擇基本科目"
|
||||||
@ -157,6 +242,35 @@ msgstr "清除"
|
|||||||
msgid "Base Account Managements"
|
msgid "Base Account Managements"
|
||||||
msgstr "基本科目管理"
|
msgstr "基本科目管理"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/currency/create.html:24
|
||||||
|
msgid "Add a New Currency"
|
||||||
|
msgstr "新增貨幣"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:65
|
||||||
|
msgid "Delete Currency Confirmation"
|
||||||
|
msgstr "貨幣刪除確認"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/currency/detail.html:69
|
||||||
|
msgid "Do you really want to delete this currency?"
|
||||||
|
msgstr "你確定要刪掉這個貨幣嗎?"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/currency/edit.html:24
|
||||||
|
#, python-format
|
||||||
|
msgid "%(currency)s Settings"
|
||||||
|
msgstr "%(currency)s設定"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/currency/list.html:24
|
||||||
|
msgid "Currency Management"
|
||||||
|
msgstr "貨幣管理"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/currency/include/form.html:44
|
||||||
|
msgid "Code"
|
||||||
|
msgstr "代碼"
|
||||||
|
|
||||||
|
#: src/accounting/templates/accounting/currency/include/form.html:50
|
||||||
|
msgid "Name"
|
||||||
|
msgstr "名稱"
|
||||||
|
|
||||||
#: src/accounting/templates/accounting/include/nav.html:26
|
#: src/accounting/templates/accounting/include/nav.html:26
|
||||||
msgid "Accounting"
|
msgid "Accounting"
|
||||||
msgstr "記帳"
|
msgstr "記帳"
|
||||||
@ -169,11 +283,17 @@ msgstr "科目"
|
|||||||
msgid "Base Accounts"
|
msgid "Base Accounts"
|
||||||
msgstr "基本科目"
|
msgstr "基本科目"
|
||||||
|
|
||||||
#: src/accounting/utils/pagination.py:146
|
#: src/accounting/templates/accounting/include/nav.html:44
|
||||||
msgid "Previous"
|
msgid "Currencies"
|
||||||
msgstr "前一頁"
|
msgstr "貨幣"
|
||||||
|
|
||||||
#: src/accounting/utils/pagination.py:194
|
#: src/accounting/utils/pagination.py:206
|
||||||
|
msgctxt "Pagination|"
|
||||||
|
msgid "Previous"
|
||||||
|
msgstr "上一頁"
|
||||||
|
|
||||||
|
#: src/accounting/utils/pagination.py:255
|
||||||
|
msgctxt "Pagination|"
|
||||||
msgid "Next"
|
msgid "Next"
|
||||||
msgstr "下一頁"
|
msgstr "下一頁"
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ This module should not import any other module from the application.
|
|||||||
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
|
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
|
||||||
urlunparse
|
urlunparse
|
||||||
|
|
||||||
from flask import request
|
from flask import request, Blueprint
|
||||||
|
|
||||||
|
|
||||||
def append_next(uri: str) -> str:
|
def append_next(uri: str) -> str:
|
||||||
@ -73,3 +73,14 @@ def __set_next(uri: str, next_uri: str) -> str:
|
|||||||
parts: list[str] = list(uri_p)
|
parts: list[str] = list(uri_p)
|
||||||
parts[4] = urlencode(params)
|
parts[4] = urlencode(params)
|
||||||
return urlunparse(parts)
|
return urlunparse(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(bp: Blueprint) -> None:
|
||||||
|
"""Initializes the application.
|
||||||
|
|
||||||
|
:param bp: The blueprint of the accounting application.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
bp.add_app_template_filter(append_next, "accounting_append_next")
|
||||||
|
bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
|
||||||
|
bp.add_app_template_filter(or_next, "accounting_or_next")
|
||||||
|
@ -24,12 +24,13 @@ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
|
|||||||
ParseResult
|
ParseResult
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
|
from werkzeug.routing import RequestRedirect
|
||||||
|
|
||||||
from accounting.locale import gettext
|
from accounting.locale import gettext, pgettext
|
||||||
|
|
||||||
|
|
||||||
class PageLink:
|
class Link:
|
||||||
"""A link in the pagination."""
|
"""A link."""
|
||||||
|
|
||||||
def __init__(self, text: str, uri: str | None = None,
|
def __init__(self, text: str, uri: str | None = None,
|
||||||
is_current: bool = False, is_for_mobile: bool = False):
|
is_current: bool = False, is_for_mobile: bool = False):
|
||||||
@ -52,15 +53,20 @@ class PageLink:
|
|||||||
"""Whether the link should be shown on mobile screens."""
|
"""Whether the link should be shown on mobile screens."""
|
||||||
|
|
||||||
|
|
||||||
|
class Redirection(RequestRedirect):
|
||||||
|
"""The redirection."""
|
||||||
|
code = 302
|
||||||
|
"""The HTTP code."""
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_PAGE_SIZE: int = 10
|
||||||
|
"""The default page size."""
|
||||||
|
|
||||||
T = t.TypeVar("T")
|
T = t.TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
class Pagination(t.Generic[T]):
|
class Pagination(t.Generic[T]):
|
||||||
"""The pagination utilities"""
|
"""The pagination utility."""
|
||||||
AVAILABLE_PAGE_SIZES: list[int] = [10, 100, 200]
|
|
||||||
"""The available page sizes."""
|
|
||||||
DEFAULT_PAGE_SIZE: int = 10
|
|
||||||
"""The default page size."""
|
|
||||||
|
|
||||||
def __init__(self, items: list[T], is_reversed: bool = False):
|
def __init__(self, items: list[T], is_reversed: bool = False):
|
||||||
"""Constructs the pagination.
|
"""Constructs the pagination.
|
||||||
@ -68,130 +74,186 @@ class Pagination(t.Generic[T]):
|
|||||||
:param items: The items.
|
:param items: The items.
|
||||||
:param is_reversed: True if the default page is the last page, or False
|
:param is_reversed: True if the default page is the last page, or False
|
||||||
otherwise.
|
otherwise.
|
||||||
|
:raise Redirection: When the pagination parameters are malformed.
|
||||||
"""
|
"""
|
||||||
self.__items: list[T] = items
|
pagination: AbstractPagination[T] = EmptyPagination[T]() \
|
||||||
"""All the items."""
|
if len(items) == 0 \
|
||||||
self.__is_reversed: bool = is_reversed
|
else NonEmptyPagination[T](items, is_reversed)
|
||||||
"""Whether the default page is the last page."""
|
self.is_paged: bool = pagination.is_paged
|
||||||
self.page_size: int = int(request.args.get("page-size",
|
"""Whether there should be pagination."""
|
||||||
self.DEFAULT_PAGE_SIZE))
|
self.list: list[T] = pagination.list
|
||||||
"""The number of items in a page."""
|
"""The items shown in the list"""
|
||||||
self.__total_pages: int = 0 if len(items) == 0 \
|
self.pages: list[Link] = pagination.pages
|
||||||
else int((len(items) - 1) / self.page_size) + 1
|
"""The pages."""
|
||||||
"""The total number of pages."""
|
self.page_size: int = pagination.page_size
|
||||||
self.is_needed: bool = self.__total_pages > 1
|
"""The number of items in a page."""
|
||||||
|
self.page_size_options: list[Link] = pagination.page_size_options
|
||||||
|
"""The options to the number of items in a page."""
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractPagination(t.Generic[T]):
|
||||||
|
"""An abstract pagination."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Constructs an empty pagination."""
|
||||||
|
self.page_size: int = DEFAULT_PAGE_SIZE
|
||||||
|
"""The number of items in a page."""
|
||||||
|
self.is_paged: bool = False
|
||||||
"""Whether there should be pagination."""
|
"""Whether there should be pagination."""
|
||||||
self.__default_page_no: int = 0
|
|
||||||
"""The default page number."""
|
|
||||||
self.page_no: int = 0
|
|
||||||
"""The current page number."""
|
|
||||||
self.list: list[T] = []
|
self.list: list[T] = []
|
||||||
"""The items shown in the list"""
|
"""The items shown in the list"""
|
||||||
if self.__total_pages > 0:
|
self.pages: list[Link] = []
|
||||||
self.__set_list()
|
"""The pages."""
|
||||||
|
self.page_size_options: list[Link] = []
|
||||||
|
"""The options to the number of items in a page."""
|
||||||
|
|
||||||
|
|
||||||
|
class EmptyPagination(AbstractPagination[T]):
|
||||||
|
"""The pagination from empty data."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class NonEmptyPagination(AbstractPagination[T]):
|
||||||
|
"""The pagination with real data."""
|
||||||
|
PAGE_SIZE_OPTIONS: list[int] = [10, 100, 200]
|
||||||
|
"""The page size options."""
|
||||||
|
|
||||||
|
def __init__(self, items: list[T], is_reversed: bool = False):
|
||||||
|
"""Constructs the pagination.
|
||||||
|
|
||||||
|
:param items: The items.
|
||||||
|
:param is_reversed: True if the default page is the last page, or False
|
||||||
|
otherwise.
|
||||||
|
:raise Redirection: When the pagination parameters are malformed.
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
self.__current_uri: str = request.full_path if request.query_string \
|
self.__current_uri: str = request.full_path if request.query_string \
|
||||||
else request.path
|
else request.path
|
||||||
"""The current URI."""
|
"""The current URI."""
|
||||||
self.__base_uri_params: tuple[list[str], list[tuple[str, str]]] \
|
self.__is_reversed: bool = is_reversed
|
||||||
= self.__get_base_uri_params()
|
"""Whether the default page is the last page."""
|
||||||
"""The base URI parameters."""
|
self.page_size = self.__get_page_size()
|
||||||
self.page_links: list[PageLink] = self.__get_page_links()
|
self.__total_pages: int = int((len(items) - 1) / self.page_size) + 1
|
||||||
"""The pagination links."""
|
"""The total number of pages."""
|
||||||
self.page_sizes: list[PageLink] = self.__get_page_sizes()
|
self.is_paged = self.__total_pages > 1
|
||||||
"""The links to switch the number of items in a page."""
|
self.__default_page_no: int = self.__total_pages \
|
||||||
|
if self.__is_reversed else 1
|
||||||
def __set_list(self) -> None:
|
"""The default page number."""
|
||||||
"""Sets the items to show in the list.
|
self.__page_no: int = self.__get_page_no()
|
||||||
|
"""The current page number."""
|
||||||
:return: None.
|
lower_bound: int = (self.__page_no - 1) * self.page_size
|
||||||
"""
|
|
||||||
self.__default_page_no = self.__total_pages if self.__is_reversed \
|
|
||||||
else 1
|
|
||||||
self.page_no = int(request.args.get("page-no",
|
|
||||||
self.__default_page_no))
|
|
||||||
if self.page_no < 1:
|
|
||||||
self.page_no = 1
|
|
||||||
if self.page_no > self.__total_pages:
|
|
||||||
self.page_no = self.__total_pages
|
|
||||||
lower_bound: int = (self.page_no - 1) * self.page_size
|
|
||||||
upper_bound: int = lower_bound + self.page_size
|
upper_bound: int = lower_bound + self.page_size
|
||||||
if upper_bound > len(self.__items):
|
if upper_bound > len(items):
|
||||||
upper_bound = len(self.__items)
|
upper_bound = len(items)
|
||||||
self.list = self.__items[lower_bound:upper_bound]
|
self.list = items[lower_bound:upper_bound]
|
||||||
|
self.pages = self.__get_pages()
|
||||||
|
self.page_size_options = self.__get_page_size_options()
|
||||||
|
|
||||||
def __get_base_uri_params(self) -> tuple[list[str], list[tuple[str, str]]]:
|
def __get_page_size(self) -> int:
|
||||||
"""Returns the base URI and its parameters, with the "page-no" and
|
"""Returns the page size.
|
||||||
"page-size" parameters removed.
|
|
||||||
|
|
||||||
:return: The URI parts and the cleaned-up query parameters.
|
:return: The page size.
|
||||||
|
:raise Redirection: When the page size is malformed.
|
||||||
"""
|
"""
|
||||||
uri_p: ParseResult = urlparse(self.__current_uri)
|
if "page-size" not in request.args:
|
||||||
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
return DEFAULT_PAGE_SIZE
|
||||||
params = [x for x in params if x[0] not in ["page-no", "page-size"]]
|
try:
|
||||||
parts: list[str] = list(uri_p)
|
page_size: int = int(request.args["page-size"])
|
||||||
return parts, params
|
except ValueError:
|
||||||
|
raise Redirection(self.__uri_set("page-size", None))
|
||||||
|
if page_size == DEFAULT_PAGE_SIZE or page_size < 1:
|
||||||
|
raise Redirection(self.__uri_set("page-size", None))
|
||||||
|
return page_size
|
||||||
|
|
||||||
def __get_page_links(self) -> list[PageLink]:
|
def __get_page_no(self) -> int:
|
||||||
|
"""Returns the page number.
|
||||||
|
|
||||||
|
:return: The page number.
|
||||||
|
:raise Redirection: When the page number is malformed.
|
||||||
|
"""
|
||||||
|
if "page-no" not in request.args:
|
||||||
|
return self.__default_page_no
|
||||||
|
try:
|
||||||
|
page_no: int = int(request.args["page-no"])
|
||||||
|
except ValueError:
|
||||||
|
raise Redirection(self.__uri_set("page-no", None))
|
||||||
|
if page_no == self.__default_page_no:
|
||||||
|
raise Redirection(self.__uri_set("page-no", None))
|
||||||
|
if page_no < 1:
|
||||||
|
if not self.__is_reversed:
|
||||||
|
raise Redirection(self.__uri_set("page-no", None))
|
||||||
|
raise Redirection(self.__uri_set("page-no", "1"))
|
||||||
|
if page_no > self.__total_pages:
|
||||||
|
if self.__is_reversed:
|
||||||
|
raise Redirection(self.__uri_set("page-no", None))
|
||||||
|
raise Redirection(self.__uri_set("page-no",
|
||||||
|
str(self.__total_pages)))
|
||||||
|
return page_no
|
||||||
|
|
||||||
|
def __get_pages(self) -> list[Link]:
|
||||||
"""Returns the page links in the pagination navigation.
|
"""Returns the page links in the pagination navigation.
|
||||||
|
|
||||||
:return: The page links in the pagination navigation.
|
:return: The page links in the pagination navigation.
|
||||||
"""
|
"""
|
||||||
if self.__total_pages < 2:
|
if not self.is_paged:
|
||||||
return []
|
return []
|
||||||
uri: str | None
|
uri: str | None
|
||||||
links: list[PageLink] = []
|
links: list[Link] = []
|
||||||
|
|
||||||
# The previous page.
|
# The previous page.
|
||||||
uri = None if self.page_no == 1 else self.__uri_page(self.page_no - 1)
|
uri = None if self.__page_no == 1 \
|
||||||
links.append(PageLink(gettext("Previous"), uri, is_for_mobile=True))
|
else self.__uri_page(self.__page_no - 1)
|
||||||
|
links.append(Link(pgettext("Pagination|", "Previous"), uri,
|
||||||
|
is_for_mobile=True))
|
||||||
|
|
||||||
# The first page.
|
# The first page.
|
||||||
if self.page_no > 1:
|
if self.__page_no > 1:
|
||||||
links.append(PageLink("1", self.__uri_page(1)))
|
links.append(Link("1", self.__uri_page(1)))
|
||||||
|
|
||||||
# The eclipse of the previous pages.
|
# The eclipse of the previous pages.
|
||||||
if self.page_no - 3 == 2:
|
if self.__page_no - 3 == 2:
|
||||||
links.append(PageLink(str(self.page_no - 3),
|
links.append(Link(str(self.__page_no - 3),
|
||||||
self.__uri_page(self.page_no - 3)))
|
self.__uri_page(self.__page_no - 3)))
|
||||||
elif self.page_no - 3 > 2:
|
elif self.__page_no - 3 > 2:
|
||||||
links.append(PageLink("…"))
|
links.append(Link("…"))
|
||||||
|
|
||||||
# The previous two pages.
|
# The previous two pages.
|
||||||
if self.page_no - 2 > 1:
|
if self.__page_no - 2 > 1:
|
||||||
links.append(PageLink(str(self.page_no - 2),
|
links.append(Link(str(self.__page_no - 2),
|
||||||
self.__uri_page(self.page_no - 2)))
|
self.__uri_page(self.__page_no - 2)))
|
||||||
if self.page_no - 1 > 1:
|
if self.__page_no - 1 > 1:
|
||||||
links.append(PageLink(str(self.page_no - 1),
|
links.append(Link(str(self.__page_no - 1),
|
||||||
self.__uri_page(self.page_no - 1)))
|
self.__uri_page(self.__page_no - 1)))
|
||||||
|
|
||||||
# The current page.
|
# The current page.
|
||||||
links.append(PageLink(str(self.page_no), self.__uri_page(self.page_no),
|
links.append(Link(str(self.__page_no), self.__uri_page(self.__page_no),
|
||||||
is_current=True))
|
is_current=True))
|
||||||
|
|
||||||
# The next two pages.
|
# The next two pages.
|
||||||
if self.page_no + 1 < self.__total_pages:
|
if self.__page_no + 1 < self.__total_pages:
|
||||||
links.append(PageLink(str(self.page_no + 1),
|
links.append(Link(str(self.__page_no + 1),
|
||||||
self.__uri_page(self.page_no + 1)))
|
self.__uri_page(self.__page_no + 1)))
|
||||||
if self.page_no + 2 < self.__total_pages:
|
if self.__page_no + 2 < self.__total_pages:
|
||||||
links.append(PageLink(str(self.page_no + 2),
|
links.append(Link(str(self.__page_no + 2),
|
||||||
self.__uri_page(self.page_no + 2)))
|
self.__uri_page(self.__page_no + 2)))
|
||||||
|
|
||||||
# The eclipse of the next pages.
|
# The eclipse of the next pages.
|
||||||
if self.page_no + 3 == self.__total_pages - 1:
|
if self.__page_no + 3 == self.__total_pages - 1:
|
||||||
links.append(PageLink(str(self.page_no + 3),
|
links.append(Link(str(self.__page_no + 3),
|
||||||
self.__uri_page(self.page_no + 3)))
|
self.__uri_page(self.__page_no + 3)))
|
||||||
elif self.page_no + 3 < self.__total_pages - 1:
|
elif self.__page_no + 3 < self.__total_pages - 1:
|
||||||
links.append(PageLink("…"))
|
links.append(Link("…"))
|
||||||
|
|
||||||
# The last page.
|
# The last page.
|
||||||
if self.page_no < self.__total_pages:
|
if self.__page_no < self.__total_pages:
|
||||||
links.append(PageLink(str(self.__total_pages),
|
links.append(Link(str(self.__total_pages),
|
||||||
self.__uri_page(self.__total_pages)))
|
self.__uri_page(self.__total_pages)))
|
||||||
|
|
||||||
# The next page.
|
# The next page.
|
||||||
uri = None if self.page_no == self.__total_pages \
|
uri = None if self.__page_no == self.__total_pages \
|
||||||
else self.__uri_page(self.page_no + 1)
|
else self.__uri_page(self.__page_no + 1)
|
||||||
links.append(PageLink(gettext("Next"), uri, is_for_mobile=True))
|
links.append(Link(pgettext("Pagination|", "Next"), uri,
|
||||||
|
is_for_mobile=True))
|
||||||
|
|
||||||
return links
|
return links
|
||||||
|
|
||||||
@ -201,21 +263,22 @@ class Pagination(t.Generic[T]):
|
|||||||
:param page_no: The page number.
|
:param page_no: The page number.
|
||||||
:return: The URI of the page.
|
:return: The URI of the page.
|
||||||
"""
|
"""
|
||||||
params: list[tuple[str, str]] = []
|
if page_no == self.__page_no:
|
||||||
if page_no != self.__default_page_no:
|
return self.__current_uri
|
||||||
params.append(("page-no", str(page_no)))
|
if page_no == self.__default_page_no:
|
||||||
if self.page_size != self.DEFAULT_PAGE_SIZE:
|
return self.__uri_set("page-no", None)
|
||||||
params.append(("page-size", str(self.page_size)))
|
return self.__uri_set("page-no", str(page_no))
|
||||||
return self.__uri_set_params(params)
|
|
||||||
|
|
||||||
def __get_page_sizes(self) -> list[PageLink]:
|
def __get_page_size_options(self) -> list[Link]:
|
||||||
"""Returns the available page sizes.
|
"""Returns the page size options.
|
||||||
|
|
||||||
:return: The available page sizes.
|
:return: The page size options.
|
||||||
"""
|
"""
|
||||||
return [PageLink(str(x), self.__uri_size(x),
|
if not self.is_paged:
|
||||||
is_current=x == self.page_size)
|
return []
|
||||||
for x in self.AVAILABLE_PAGE_SIZES]
|
return [Link(str(x), self.__uri_size(x),
|
||||||
|
is_current=x == self.page_size)
|
||||||
|
for x in self.PAGE_SIZE_OPTIONS]
|
||||||
|
|
||||||
def __uri_size(self, page_size: int) -> str:
|
def __uri_size(self, page_size: int) -> str:
|
||||||
"""Returns the URI of a page size.
|
"""Returns the URI of a page size.
|
||||||
@ -225,16 +288,34 @@ class Pagination(t.Generic[T]):
|
|||||||
"""
|
"""
|
||||||
if page_size == self.page_size:
|
if page_size == self.page_size:
|
||||||
return self.__current_uri
|
return self.__current_uri
|
||||||
return self.__uri_set_params([("page-size", str(page_size))])
|
if page_size == DEFAULT_PAGE_SIZE:
|
||||||
|
return self.__uri_set("page-size", None)
|
||||||
|
return self.__uri_set("page-size", str(page_size))
|
||||||
|
|
||||||
def __uri_set_params(self, params: list[tuple[str, str]]) -> str:
|
def __uri_set(self, name: str, value: str | None) -> str:
|
||||||
"""Returns the URI with the query parameters set.
|
"""Raises current URI with a parameter set.
|
||||||
|
|
||||||
:param params: The query parameters.
|
:param name: The name of the parameter.
|
||||||
:return: The URI with the query parameters set.
|
:param value: The value, or None to remove the parameter.
|
||||||
|
:return: The URI with the parameter set.
|
||||||
"""
|
"""
|
||||||
cur_params: list[tuple[str, str]] = self.__base_uri_params[1].copy()
|
uri_p: ParseResult = urlparse(self.__current_uri)
|
||||||
cur_params.extend(params)
|
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
|
||||||
parts: list[str] = self.__base_uri_params[0].copy()
|
|
||||||
parts[4] = urlencode(cur_params)
|
# Try to keep the position of the parameter.
|
||||||
|
i: int = 0
|
||||||
|
is_found: bool = False
|
||||||
|
while i < len(params):
|
||||||
|
if params[i][0] == name:
|
||||||
|
if is_found or value is None:
|
||||||
|
params = params[:i] + params[i + 1:]
|
||||||
|
continue
|
||||||
|
params[i] = (name, value)
|
||||||
|
is_found = True
|
||||||
|
i = i + 1
|
||||||
|
if not is_found and value is not None:
|
||||||
|
params.append((name, value))
|
||||||
|
|
||||||
|
parts: list[str] = list(uri_p)
|
||||||
|
parts[4] = urlencode(params)
|
||||||
return urlunparse(parts)
|
return urlunparse(parts)
|
||||||
|
@ -21,7 +21,9 @@ This module should not import any other module from the application.
|
|||||||
"""
|
"""
|
||||||
import typing as t
|
import typing as t
|
||||||
|
|
||||||
from flask import Flask, abort
|
from flask import abort, Blueprint
|
||||||
|
|
||||||
|
from accounting.utils.user import get_current_user
|
||||||
|
|
||||||
|
|
||||||
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
|
def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
|
||||||
@ -75,17 +77,22 @@ def can_view() -> bool:
|
|||||||
def can_edit() -> bool:
|
def can_edit() -> bool:
|
||||||
"""Returns whether the current user can edit the account data.
|
"""Returns whether the current user can edit the account data.
|
||||||
|
|
||||||
|
The user has to log in.
|
||||||
|
|
||||||
:return: True if the current user can edit the accounting data, or False
|
:return: True if the current user can edit the accounting data, or False
|
||||||
otherwise.
|
otherwise.
|
||||||
"""
|
"""
|
||||||
|
if get_current_user() is None:
|
||||||
|
return False
|
||||||
return __can_edit_func()
|
return __can_edit_func()
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
|
def init_app(bp: Blueprint,
|
||||||
|
can_view_func: t.Callable[[], bool] | None = None,
|
||||||
can_edit_func: t.Callable[[], bool] | None = None) -> None:
|
can_edit_func: t.Callable[[], bool] | None = None) -> None:
|
||||||
"""Initializes the application.
|
"""Initializes the application.
|
||||||
|
|
||||||
:param app: The Flask application.
|
:param bp: The blueprint of the accounting application.
|
||||||
:param can_view_func: A callback that returns whether the current user can
|
:param can_view_func: A callback that returns whether the current user can
|
||||||
view the accounting data.
|
view the accounting data.
|
||||||
:param can_edit_func: A callback that returns whether the current user can
|
:param can_edit_func: A callback that returns whether the current user can
|
||||||
@ -97,5 +104,5 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
|
|||||||
__can_view_func = can_view_func
|
__can_view_func = can_view_func
|
||||||
if can_edit_func is not None:
|
if can_edit_func is not None:
|
||||||
__can_edit_func = can_edit_func
|
__can_edit_func = can_edit_func
|
||||||
app.jinja_env.globals["can_view_accounting"] = __can_view_func
|
bp.add_app_template_global(can_view, "accounting_can_view")
|
||||||
app.jinja_env.globals["can_edit_accounting"] = __can_edit_func
|
bp.add_app_template_global(can_edit, "accounting_can_edit")
|
||||||
|
@ -34,11 +34,22 @@ def parse_query_keywords(q: str | None) -> list[str]:
|
|||||||
if q == "":
|
if q == "":
|
||||||
return []
|
return []
|
||||||
keywords: list[str] = []
|
keywords: list[str] = []
|
||||||
while q is not None:
|
while True:
|
||||||
m: re.Match = re.match(r"(?:\"([^\"]+)\"|(\S+))(?:\s+(.+)|)$", q)
|
m: re.Match
|
||||||
if m.group(1) is not None:
|
m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
|
||||||
|
if m is not None:
|
||||||
keywords.append(m.group(1))
|
keywords.append(m.group(1))
|
||||||
else:
|
q = m.group(2)
|
||||||
keywords.append(m.group(2))
|
continue
|
||||||
q = m.group(3)
|
m = re.match(r"\"([^\"]+)\"?$", q)
|
||||||
|
if m is not None:
|
||||||
|
keywords.append(m.group(1))
|
||||||
|
break
|
||||||
|
m = re.match(r"(\S+)\s+(.+)$", q)
|
||||||
|
if m is not None:
|
||||||
|
keywords.append(m.group(1))
|
||||||
|
q = m.group(2)
|
||||||
|
continue
|
||||||
|
keywords.append(q)
|
||||||
|
break
|
||||||
return keywords
|
return keywords
|
||||||
|
@ -32,6 +32,6 @@ def new_id(cls: t.Type):
|
|||||||
:return: The generated new random ID.
|
:return: The generated new random ID.
|
||||||
"""
|
"""
|
||||||
while True:
|
while True:
|
||||||
new: int = 100000000 + randbelow(900000000)
|
obj_id: int = 100000000 + randbelow(900000000)
|
||||||
if db.session.get(cls, new) is None:
|
if db.session.get(cls, obj_id) is None:
|
||||||
return new
|
return obj_id
|
||||||
|
129
src/accounting/utils/user.py
Normal file
129
src/accounting/utils/user.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The user utilities.
|
||||||
|
|
||||||
|
This module should not import any other module from the application.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import typing as t
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import g
|
||||||
|
from flask_sqlalchemy.model import Model
|
||||||
|
|
||||||
|
T = t.TypeVar("T", bound=Model)
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractUserUtils(t.Generic[T], ABC):
|
||||||
|
"""The abstract user utilities."""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def cls(self) -> t.Type[T]:
|
||||||
|
"""Returns the user class.
|
||||||
|
|
||||||
|
:return: The user class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def pk_column(self) -> sa.Column:
|
||||||
|
"""Returns the primary key column.
|
||||||
|
|
||||||
|
:return: The primary key column.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def current_user(self) -> T | None:
|
||||||
|
"""Returns the currently logged-in user.
|
||||||
|
|
||||||
|
:return: The currently logged-in user, or None if the user has not
|
||||||
|
logged in
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_by_username(self, username: str) -> T | None:
|
||||||
|
"""Returns the user by her username.
|
||||||
|
|
||||||
|
:return: The user by her username, or None if the user was not found.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_pk(self, user: T) -> int:
|
||||||
|
"""Returns the primary key of the user.
|
||||||
|
|
||||||
|
:return: The primary key of the user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
__user_utils: AbstractUserUtils
|
||||||
|
"""The user utilities."""
|
||||||
|
user_cls: t.Type[Model] = Model
|
||||||
|
"""The user class."""
|
||||||
|
user_pk_column: sa.Column = sa.Column(sa.Integer)
|
||||||
|
"""The primary key column of the user class."""
|
||||||
|
|
||||||
|
|
||||||
|
def init_user_utils(utils: AbstractUserUtils) -> None:
|
||||||
|
"""Initializes the user utilities.
|
||||||
|
|
||||||
|
:param utils: The user utilities.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
global __user_utils, user_cls, user_pk_column
|
||||||
|
__user_utils = utils
|
||||||
|
user_cls = utils.cls
|
||||||
|
user_pk_column = utils.pk_column
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user_pk() -> int:
|
||||||
|
"""Returns the primary key value of the currently logged-in user.
|
||||||
|
|
||||||
|
:return: The primary key value of the currently logged-in user.
|
||||||
|
"""
|
||||||
|
return __user_utils.get_pk(get_current_user())
|
||||||
|
|
||||||
|
|
||||||
|
def has_user(username: str) -> bool:
|
||||||
|
"""Returns whether a user by the username exists.
|
||||||
|
|
||||||
|
:param username: The username.
|
||||||
|
:return: True if the user by the username exists, or False otherwise.
|
||||||
|
"""
|
||||||
|
return __user_utils.get_by_username(username) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_pk(username: str) -> int:
|
||||||
|
"""Returns the primary key value of the user by the username.
|
||||||
|
|
||||||
|
:param username: The username.
|
||||||
|
:return: The primary key value of the user by the username.
|
||||||
|
"""
|
||||||
|
return __user_utils.get_pk(__user_utils.get_by_username(username))
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_user() -> user_cls | None:
|
||||||
|
"""Returns the currently logged-in user. The result is cached in the
|
||||||
|
current request.
|
||||||
|
|
||||||
|
:return: The currently logged-in user.
|
||||||
|
"""
|
||||||
|
if not hasattr(g, "_accounting_user"):
|
||||||
|
setattr(g, "_accounting_user", __user_utils.current_user)
|
||||||
|
return getattr(g, "_accounting_user")
|
@ -28,7 +28,7 @@ from babel.messages.frontend import CommandLineInterface
|
|||||||
from opencc import OpenCC
|
from opencc import OpenCC
|
||||||
|
|
||||||
root_dir: Path = Path(__file__).parent.parent
|
root_dir: Path = Path(__file__).parent.parent
|
||||||
translation_dir: Path = root_dir / "tests" / "testsite" / "translations"
|
translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
|
||||||
domain: str = "messages"
|
domain: str = "messages"
|
||||||
|
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ def babel_extract() -> None:
|
|||||||
/ f"{domain}.po"
|
/ f"{domain}.po"
|
||||||
CommandLineInterface().run([
|
CommandLineInterface().run([
|
||||||
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
|
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
|
||||||
"-o", str(pot), str(Path("tests") / "testsite")])
|
"-o", str(pot), str(Path("tests") / "test_site")])
|
||||||
if not zh_hant.exists():
|
if not zh_hant.exists():
|
||||||
zh_hant.touch()
|
zh_hant.touch()
|
||||||
if not zh_hans.exists():
|
if not zh_hans.exists():
|
713
tests/test_account.py
Normal file
713
tests/test_account.py
Normal file
@ -0,0 +1,713 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The test for the account management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from click.testing import Result
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
|
from test_site import create_app
|
||||||
|
from testlib import get_client, set_locale
|
||||||
|
|
||||||
|
|
||||||
|
class AccountData:
|
||||||
|
"""The account data."""
|
||||||
|
|
||||||
|
def __init__(self, base_code: str, no: int, title: str):
|
||||||
|
"""Constructs the account data.
|
||||||
|
|
||||||
|
:param base_code: The base code.
|
||||||
|
:param no: The number.
|
||||||
|
:param title: The title.
|
||||||
|
"""
|
||||||
|
self.base_code: str = base_code
|
||||||
|
"""The base code."""
|
||||||
|
self.no: int = no
|
||||||
|
"""The number."""
|
||||||
|
self.title: str = title
|
||||||
|
"""The title."""
|
||||||
|
self.code: str = f"{self.base_code}-{self.no:03d}"
|
||||||
|
"""The code."""
|
||||||
|
|
||||||
|
|
||||||
|
cash: AccountData = AccountData("1111", 1, "Cash")
|
||||||
|
"""The cash account."""
|
||||||
|
bank: AccountData = AccountData("1113", 1, "Bank")
|
||||||
|
"""The bank account."""
|
||||||
|
stock: AccountData = AccountData("1121", 1, "Stock")
|
||||||
|
"""The stock account."""
|
||||||
|
loan: AccountData = AccountData("2112", 1, "Loan")
|
||||||
|
"""The loan account."""
|
||||||
|
PREFIX: str = "/accounting/accounts"
|
||||||
|
"""The URL prefix of the currency management."""
|
||||||
|
|
||||||
|
|
||||||
|
class AccountCommandTestCase(unittest.TestCase):
|
||||||
|
"""The account console command test case."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Sets up the test.
|
||||||
|
This is run once per test.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.app: Flask = create_app(is_testing=True)
|
||||||
|
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
|
result: Result
|
||||||
|
result = runner.invoke(args="init-db")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
if BaseAccount.query.first() is None:
|
||||||
|
result = runner.invoke(args="accounting-init-base")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
AccountL10n.query.delete()
|
||||||
|
Account.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def test_init(self) -> None:
|
||||||
|
"""Tests the "accounting-init-account" console command.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
result: Result = runner.invoke(args=["accounting-init-accounts",
|
||||||
|
"-u", "editor"])
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
with self.app.app_context():
|
||||||
|
bases: list[BaseAccount] = BaseAccount.query\
|
||||||
|
.filter(sa.func.char_length(BaseAccount.code) == 4).all()
|
||||||
|
accounts: list[Account] = Account.query.all()
|
||||||
|
l10n: list[AccountL10n] = AccountL10n.query.all()
|
||||||
|
self.assertEqual({x.code for x in bases},
|
||||||
|
{x.base_code for x in accounts})
|
||||||
|
self.assertEqual(len(accounts), len(bases))
|
||||||
|
self.assertEqual(len(l10n), len(bases) * 2)
|
||||||
|
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
|
||||||
|
for account in accounts:
|
||||||
|
base: BaseAccount = base_dict[account.base_code]
|
||||||
|
self.assertEqual(account.no, 1)
|
||||||
|
self.assertEqual(account.title_l10n, base.title_l10n)
|
||||||
|
self.assertEqual({x.locale: x.title for x in account.l10n},
|
||||||
|
{x.locale: x.title for x in base.l10n})
|
||||||
|
|
||||||
|
|
||||||
|
class AccountTestCase(unittest.TestCase):
|
||||||
|
"""The account test case."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Sets up the test.
|
||||||
|
This is run once per test.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.app: Flask = create_app(is_testing=True)
|
||||||
|
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import BaseAccount, Account, AccountL10n
|
||||||
|
result: Result
|
||||||
|
result = runner.invoke(args="init-db")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
if BaseAccount.query.first() is None:
|
||||||
|
result = runner.invoke(args="accounting-init-base")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
AccountL10n.query.delete()
|
||||||
|
Account.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
self.client, self.csrf_token = get_client(self, self.app, "editor")
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": cash.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"],
|
||||||
|
f"{PREFIX}/{cash.code}")
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": bank.base_code,
|
||||||
|
"title": bank.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"],
|
||||||
|
f"{PREFIX}/{bank.code}")
|
||||||
|
|
||||||
|
def test_nobody(self) -> None:
|
||||||
|
"""Test the permission as nobody.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
client, csrf_token = get_client(self, self.app, "nobody")
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = client.get(PREFIX)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{cash.code}")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/create")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{cash.code}/edit")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{cash.code}/update",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": f"{cash.title}-2"})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{cash.code}/delete",
|
||||||
|
data={"csrf_token": csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
bank_id: int = Account.find_by_code(bank.code).id
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"next": "/next",
|
||||||
|
f"{bank_id}-no": "5"})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_viewer(self) -> None:
|
||||||
|
"""Test the permission as viewer.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
client, csrf_token = get_client(self, self.app, "viewer")
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = client.get(PREFIX)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{cash.code}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/create")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{cash.code}/edit")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{cash.code}/update",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": f"{cash.title}-2"})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{cash.code}/delete",
|
||||||
|
data={"csrf_token": csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
bank_id: int = Account.find_by_code(bank.code).id
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"next": "/next",
|
||||||
|
f"{bank_id}-no": "5"})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_editor(self) -> None:
|
||||||
|
"""Test the permission as editor.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = self.client.get(PREFIX)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.get(f"{PREFIX}/{cash.code}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.get(f"{PREFIX}/create")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"],
|
||||||
|
f"{PREFIX}/{stock.code}")
|
||||||
|
|
||||||
|
response = self.client.get(f"{PREFIX}/{cash.code}/edit")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/{cash.code}/update",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": f"{cash.title}-2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/{cash.code}/delete",
|
||||||
|
data={"csrf_token": self.csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], PREFIX)
|
||||||
|
|
||||||
|
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
bank_id: int = Account.find_by_code(bank.code).id
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"next": "/next",
|
||||||
|
f"{bank_id}-no": "5"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], "/next")
|
||||||
|
|
||||||
|
def test_add(self) -> None:
|
||||||
|
"""Tests to add the currencies.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import Account
|
||||||
|
create_uri: str = f"{PREFIX}/create"
|
||||||
|
store_uri: str = f"{PREFIX}/store"
|
||||||
|
detail_uri: str = f"{PREFIX}/{stock.code}"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Account.query.all()},
|
||||||
|
{cash.code, bank.code})
|
||||||
|
|
||||||
|
# Missing CSRF token
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# CSRF token mismatch
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": f"{self.csrf_token}-2",
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# Empty base account code
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": " ",
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Non-existing base account
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": "9999",
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Unavailable base account
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": "1",
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Empty name
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": " "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Success, with spaces to be stripped
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": f" {stock.base_code} ",
|
||||||
|
"title": f" {stock.title} "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
# Success under the same base
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"],
|
||||||
|
f"{PREFIX}/{stock.base_code}-002")
|
||||||
|
|
||||||
|
# Success under the same base, with order in a mess.
|
||||||
|
with self.app.app_context():
|
||||||
|
stock_2: Account = Account.find_by_code(f"{stock.base_code}-002")
|
||||||
|
stock_2.no = 66
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"],
|
||||||
|
f"{PREFIX}/{stock.base_code}-067")
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Account.query.all()},
|
||||||
|
{cash.code, bank.code, stock.code,
|
||||||
|
f"{stock.base_code}-066",
|
||||||
|
f"{stock.base_code}-067"})
|
||||||
|
|
||||||
|
stock_account: Account = Account.find_by_code(stock.code)
|
||||||
|
self.assertEqual(stock_account.base_code, stock.base_code)
|
||||||
|
self.assertEqual(stock_account.title_l10n, stock.title)
|
||||||
|
|
||||||
|
def test_basic_update(self) -> None:
|
||||||
|
"""Tests the basic rules to update a user.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
detail_uri: str = f"{PREFIX}/{cash.code}"
|
||||||
|
edit_uri: str = f"{PREFIX}/{cash.code}/edit"
|
||||||
|
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
||||||
|
detail_c_uri: str = f"{PREFIX}/{stock.code}"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
# Success, with spaces to be stripped
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": f" {cash.base_code} ",
|
||||||
|
"title": f" {cash.title}-1 "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertEqual(cash_account.base_code, cash.base_code)
|
||||||
|
self.assertEqual(cash_account.title_l10n, f"{cash.title}-1")
|
||||||
|
|
||||||
|
# Empty base account code
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": " ",
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Non-existing base account
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": "9999",
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Unavailable base account
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": "1",
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Empty name
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": " "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Change the base account
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": stock.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_c_uri)
|
||||||
|
|
||||||
|
response = self.client.get(detail_uri)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
response = self.client.get(detail_c_uri)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_update_not_modified(self) -> None:
|
||||||
|
"""Tests that the data is not modified.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
detail_uri: str = f"{PREFIX}/{cash.code}"
|
||||||
|
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
||||||
|
response: httpx.Response
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": f" {cash.base_code} ",
|
||||||
|
"title": f" {cash.title} "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertIsNotNone(cash_account)
|
||||||
|
self.assertEqual(cash_account.created_at, cash_account.updated_at)
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": stock.title})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertIsNotNone(cash_account)
|
||||||
|
self.assertNotEqual(cash_account.created_at,
|
||||||
|
cash_account.updated_at)
|
||||||
|
|
||||||
|
def test_created_updated_by(self) -> None:
|
||||||
|
"""Tests the created-by and updated-by record.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
editor_username, editor2_username = "editor", "editor2"
|
||||||
|
client, csrf_token = get_client(self, self.app, editor2_username)
|
||||||
|
detail_uri: str = f"{PREFIX}/{cash.code}"
|
||||||
|
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertEqual(cash_account.created_by.username, editor_username)
|
||||||
|
self.assertEqual(cash_account.updated_by.username, editor_username)
|
||||||
|
|
||||||
|
response = client.post(update_uri,
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": f"{cash.title}-2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertEqual(cash_account.created_by.username,
|
||||||
|
editor_username)
|
||||||
|
self.assertEqual(cash_account.updated_by.username,
|
||||||
|
editor2_username)
|
||||||
|
|
||||||
|
def test_l10n(self) -> None:
|
||||||
|
"""Tests the localization.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
detail_uri: str = f"{PREFIX}/{cash.code}"
|
||||||
|
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertEqual(cash_account.title_l10n, cash.title)
|
||||||
|
self.assertEqual(cash_account.l10n, [])
|
||||||
|
|
||||||
|
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": f"{cash.title}-zh_Hant"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertEqual(cash_account.title_l10n, cash.title)
|
||||||
|
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
|
||||||
|
{("zh_Hant", f"{cash.title}-zh_Hant")})
|
||||||
|
|
||||||
|
set_locale(self, self.client, self.csrf_token, "en")
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": f"{cash.title}-2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
|
||||||
|
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
|
||||||
|
{("zh_Hant", f"{cash.title}-zh_Hant")})
|
||||||
|
|
||||||
|
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": cash.base_code,
|
||||||
|
"title": f"{cash.title}-zh_Hant-2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
cash_account: Account = Account.find_by_code(cash.code)
|
||||||
|
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
|
||||||
|
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
|
||||||
|
{("zh_Hant", f"{cash.title}-zh_Hant-2")})
|
||||||
|
|
||||||
|
def test_delete(self) -> None:
|
||||||
|
"""Tests to delete a currency.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Account
|
||||||
|
detail_uri: str = f"{PREFIX}/{cash.code}"
|
||||||
|
delete_uri: str = f"{PREFIX}/{cash.code}/delete"
|
||||||
|
list_uri: str = PREFIX
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Account.query.all()},
|
||||||
|
{cash.code, bank.code})
|
||||||
|
|
||||||
|
response = self.client.get(detail_uri)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
response = self.client.post(delete_uri,
|
||||||
|
data={"csrf_token": self.csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], list_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Account.query.all()},
|
||||||
|
{bank.code})
|
||||||
|
|
||||||
|
response = self.client.get(detail_uri)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
response = self.client.post(delete_uri,
|
||||||
|
data={"csrf_token": self.csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_reorder(self) -> None:
|
||||||
|
"""Tests to reorder the accounts under a same base account.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import Account
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
for i in range(2, 6):
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"base_code": "1111",
|
||||||
|
"title": "Title"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"],
|
||||||
|
f"{PREFIX}/1111-00{i}")
|
||||||
|
|
||||||
|
# Normal reorder
|
||||||
|
with self.app.app_context():
|
||||||
|
id_1: int = Account.find_by_code("1111-001").id
|
||||||
|
id_2: int = Account.find_by_code("1111-002").id
|
||||||
|
id_3: int = Account.find_by_code("1111-003").id
|
||||||
|
id_4: int = Account.find_by_code("1111-004").id
|
||||||
|
id_5: int = Account.find_by_code("1111-005").id
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"next": "/next",
|
||||||
|
f"{id_1}-no": "4",
|
||||||
|
f"{id_2}-no": "1",
|
||||||
|
f"{id_3}-no": "5",
|
||||||
|
f"{id_4}-no": "2",
|
||||||
|
f"{id_5}-no": "3"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"/next")
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
|
||||||
|
self.assertEqual(db.session.get(Account, id_2).code, "1111-001")
|
||||||
|
self.assertEqual(db.session.get(Account, id_3).code, "1111-005")
|
||||||
|
self.assertEqual(db.session.get(Account, id_4).code, "1111-002")
|
||||||
|
self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
|
||||||
|
|
||||||
|
# Malformed orders
|
||||||
|
with self.app.app_context():
|
||||||
|
db.session.get(Account, id_1).no = 3
|
||||||
|
db.session.get(Account, id_2).no = 4
|
||||||
|
db.session.get(Account, id_3).no = 6
|
||||||
|
db.session.get(Account, id_4).no = 8
|
||||||
|
db.session.get(Account, id_5).no = 9
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"next": "/next",
|
||||||
|
f"{id_2}-no": "3a",
|
||||||
|
f"{id_3}-no": "5",
|
||||||
|
f"{id_4}-no": "2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"/next")
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
|
||||||
|
self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
|
||||||
|
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
|
||||||
|
self.assertEqual(db.session.get(Account, id_4).code, "1111-001")
|
||||||
|
self.assertEqual(db.session.get(Account, id_5).code, "1111-005")
|
@ -14,7 +14,6 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
"""The test for the base account management.
|
"""The test for the base account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -25,12 +24,12 @@ from click.testing import Result
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.testing import FlaskCliRunner
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
from testlib import get_csrf_token
|
from test_site import create_app
|
||||||
from testsite import create_app
|
from testlib import get_client
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountTestCase(unittest.TestCase):
|
class BaseAccountCommandTestCase(unittest.TestCase):
|
||||||
"""The base account test case."""
|
"""The base account console command test case."""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
"""Sets up the test.
|
"""Sets up the test.
|
||||||
@ -38,24 +37,22 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
|
from accounting.models import BaseAccount, BaseAccountL10n
|
||||||
self.app: Flask = create_app(is_testing=True)
|
self.app: Flask = create_app(is_testing=True)
|
||||||
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
result: Result = runner.invoke(args="init-db")
|
result: Result = runner.invoke(args="init-db")
|
||||||
self.assertEqual(result.exit_code, 0)
|
self.assertEqual(result.exit_code, 0)
|
||||||
self.client: httpx.Client = httpx.Client(app=self.app,
|
BaseAccountL10n.query.delete()
|
||||||
base_url="https://testserver")
|
BaseAccount.query.delete()
|
||||||
self.client.headers["Referer"] = "https://testserver"
|
|
||||||
self.csrf_token: str = get_csrf_token(self, self.client, "/login")
|
|
||||||
|
|
||||||
def test_init(self) -> None:
|
def test_init(self) -> None:
|
||||||
"""Tests the "accounting-init-base" console command.
|
"""Tests the "accounting-init-base" console command.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.models import BaseAccountL10n
|
from accounting.models import BaseAccount, BaseAccountL10n
|
||||||
from accounting.models import BaseAccount
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
result: Result = runner.invoke(args="accounting-init-base")
|
result: Result = runner.invoke(args="accounting-init-base")
|
||||||
self.assertEqual(result.exit_code, 0)
|
self.assertEqual(result.exit_code, 0)
|
||||||
@ -69,46 +66,65 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
||||||
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
||||||
|
|
||||||
list_uri: str = "/accounting/base-accounts"
|
|
||||||
|
class BaseAccountTestCase(unittest.TestCase):
|
||||||
|
"""The base account test case."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Sets up the test.
|
||||||
|
This is run once per test.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import BaseAccount
|
||||||
|
self.app: Flask = create_app(is_testing=True)
|
||||||
|
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
result: Result = runner.invoke(args="init-db")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
if BaseAccount.query.first() is None:
|
||||||
|
result = runner.invoke(args="accounting-init-base")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
|
||||||
|
def test_nobody(self) -> None:
|
||||||
|
"""Test the permission as nobody.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
client, csrf_token = get_client(self, self.app, "nobody")
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
self.__logout()
|
response = client.get("/accounting/base-accounts")
|
||||||
response = self.client.get(list_uri)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
self.__logout()
|
response = client.get("/accounting/base-accounts/1111")
|
||||||
self.__login_as("viewer")
|
|
||||||
response = self.client.get(list_uri)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.__logout()
|
|
||||||
self.__login_as("editor")
|
|
||||||
response = self.client.get(list_uri)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.__logout()
|
|
||||||
self.__login_as("nobody")
|
|
||||||
response = self.client.get(list_uri)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def __logout(self) -> None:
|
def test_viewer(self) -> None:
|
||||||
"""Logs out the currently logged-in user.
|
"""Test the permission as viewer.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
response: httpx.Response = self.client.post(
|
client, csrf_token = get_client(self, self.app, "viewer")
|
||||||
"/logout", data={"csrf_token": self.csrf_token})
|
response: httpx.Response
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], "/")
|
|
||||||
|
|
||||||
def __login_as(self, username: str) -> None:
|
response = client.get("/accounting/base-accounts")
|
||||||
"""Logs in as a specific user.
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = client.get("/accounting/base-accounts/1111")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_editor(self) -> None:
|
||||||
|
"""Test the permission as editor.
|
||||||
|
|
||||||
:param username: The username.
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
response: httpx.Response = self.client.post(
|
client, csrf_token = get_client(self, self.app, "editor")
|
||||||
"/login", data={"csrf_token": self.csrf_token,
|
response: httpx.Response
|
||||||
"username": username})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
response = client.get("/accounting/base-accounts")
|
||||||
self.assertEqual(response.headers["Location"], "/")
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = client.get("/accounting/base-accounts/1111")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
574
tests/test_currency.py
Normal file
574
tests/test_currency.py
Normal file
@ -0,0 +1,574 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The test for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from click.testing import Result
|
||||||
|
from flask import Flask
|
||||||
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
|
from test_site import create_app
|
||||||
|
from testlib import get_client, set_locale
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyData:
|
||||||
|
"""The currency data."""
|
||||||
|
|
||||||
|
def __init__(self, code: str, name: str):
|
||||||
|
"""Constructs the currency data.
|
||||||
|
|
||||||
|
:param code: The code.
|
||||||
|
:param name: The name.
|
||||||
|
"""
|
||||||
|
self.code: str = code
|
||||||
|
"""The code."""
|
||||||
|
self.name: str = name
|
||||||
|
"""The name."""
|
||||||
|
|
||||||
|
|
||||||
|
zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
|
||||||
|
"""The first test currency."""
|
||||||
|
zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
|
||||||
|
"""The second test currency."""
|
||||||
|
zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
|
||||||
|
"""The third test currency."""
|
||||||
|
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
|
||||||
|
"""The fourth test currency."""
|
||||||
|
PREFIX: str = "/accounting/currencies"
|
||||||
|
"""The URL prefix of the currency management."""
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyCommandTestCase(unittest.TestCase):
|
||||||
|
"""The account console command test case."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Sets up the test.
|
||||||
|
This is run once per test.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.app: Flask = create_app(is_testing=True)
|
||||||
|
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import Currency, CurrencyL10n
|
||||||
|
result: Result
|
||||||
|
result = runner.invoke(args="init-db")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
CurrencyL10n.query.delete()
|
||||||
|
Currency.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
def test_init(self) -> None:
|
||||||
|
"""Tests the "accounting-init-currencies" console command.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Currency, CurrencyL10n
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
result: Result = runner.invoke(
|
||||||
|
args=["accounting-init-currencies", "-u", "editor"])
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
currencies: list[Currency] = Currency.query.all()
|
||||||
|
l10n: list[CurrencyL10n] = CurrencyL10n.query.all()
|
||||||
|
self.assertEqual(len(currencies), 2)
|
||||||
|
self.assertEqual(len(l10n), 2 * 2)
|
||||||
|
l10n_keys: set[str] = {f"{x.currency_code}-{x.locale}" for x in l10n}
|
||||||
|
for currency in currencies:
|
||||||
|
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
|
||||||
|
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyTestCase(unittest.TestCase):
|
||||||
|
"""The currency test case."""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
"""Sets up the test.
|
||||||
|
This is run once per test.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
self.app: Flask = create_app(is_testing=True)
|
||||||
|
|
||||||
|
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||||
|
with self.app.app_context():
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import Currency, CurrencyL10n
|
||||||
|
result: Result
|
||||||
|
result = runner.invoke(args="init-db")
|
||||||
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
CurrencyL10n.query.delete()
|
||||||
|
Currency.query.delete()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
self.client, self.csrf_token = get_client(self, self.app, "editor")
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zza.code,
|
||||||
|
"name": zza.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzb.code,
|
||||||
|
"name": zzb.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
|
||||||
|
|
||||||
|
def test_nobody(self) -> None:
|
||||||
|
"""Test the permission as nobody.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
client, csrf_token = get_client(self, self.app, "nobody")
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = client.get(PREFIX)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{zza.code}")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/create")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{zza.code}/edit")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{zza.code}/update",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"code": zzd.code,
|
||||||
|
"name": zzd.name})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{zzb.code}/delete",
|
||||||
|
data={"csrf_token": csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_viewer(self) -> None:
|
||||||
|
"""Test the permission as viewer.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
client, csrf_token = get_client(self, self.app, "viewer")
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = client.get(PREFIX)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{zza.code}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/create")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.get(f"{PREFIX}/{zza.code}/edit")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{zza.code}/update",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"code": zzd.code,
|
||||||
|
"name": zzd.name})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
response = client.post(f"{PREFIX}/{zzb.code}/delete",
|
||||||
|
data={"csrf_token": csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_editor(self) -> None:
|
||||||
|
"""Test the permission as editor.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
response = self.client.get(PREFIX)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.get(f"{PREFIX}/{zza.code}")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.get(f"{PREFIX}/create")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/store",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
|
||||||
|
|
||||||
|
response = self.client.get(f"{PREFIX}/{zza.code}/edit")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/{zza.code}/update",
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzd.code,
|
||||||
|
"name": zzd.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
|
||||||
|
|
||||||
|
response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
|
||||||
|
data={"csrf_token": self.csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], PREFIX)
|
||||||
|
|
||||||
|
def test_add(self) -> None:
|
||||||
|
"""Tests to add the currencies.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Currency
|
||||||
|
from test_site import db
|
||||||
|
create_uri: str = f"{PREFIX}/create"
|
||||||
|
store_uri: str = f"{PREFIX}/store"
|
||||||
|
detail_uri: str = f"{PREFIX}/{zzc.code}"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Currency.query.all()},
|
||||||
|
{zza.code, zzb.code})
|
||||||
|
|
||||||
|
# Missing CSRF token
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"code": zzc.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# CSRF token mismatch
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": f"{self.csrf_token}-2",
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# Empty code
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": " ",
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Blocked code, with spaces to be stripped
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": " create ",
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Bad code
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": " zzc ",
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Empty name
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": " "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
# Success, with spaces to be stripped
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": f" {zzc.code} ",
|
||||||
|
"name": f" {zzc.name} "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
# Duplicated code
|
||||||
|
response = self.client.post(store_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], create_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Currency.query.all()},
|
||||||
|
{zza.code, zzb.code, zzc.code})
|
||||||
|
|
||||||
|
zzc_currency: Currency = db.session.get(Currency, zzc.code)
|
||||||
|
self.assertEqual(zzc_currency.code, zzc.code)
|
||||||
|
self.assertEqual(zzc_currency.name_l10n, zzc.name)
|
||||||
|
|
||||||
|
def test_basic_update(self) -> None:
|
||||||
|
"""Tests the basic rules to update a user.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Currency
|
||||||
|
from test_site import db
|
||||||
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
|
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
|
||||||
|
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
||||||
|
detail_c_uri: str = f"{PREFIX}/{zzc.code}"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
# Success, with spaces to be stripped
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": f" {zza.code} ",
|
||||||
|
"name": f" {zza.name}-1 "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertEqual(zza_currency.code, zza.code)
|
||||||
|
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1")
|
||||||
|
|
||||||
|
# Empty code
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": " ",
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Blocked code, with spaces to be stripped
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": " create ",
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Bad code
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": "abc/def",
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Empty name
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": " "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Duplicated code
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzb.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], edit_uri)
|
||||||
|
|
||||||
|
# Change code
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zzc.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_c_uri)
|
||||||
|
|
||||||
|
response = self.client.get(detail_uri)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
response = self.client.get(detail_c_uri)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_update_not_modified(self) -> None:
|
||||||
|
"""Tests that the data is not modified.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Currency
|
||||||
|
from test_site import db
|
||||||
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
|
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
||||||
|
response: httpx.Response
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": f" {zza.code} ",
|
||||||
|
"name": f" {zza.name} "})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertIsNotNone(zza_currency)
|
||||||
|
self.assertEqual(zza_currency.created_at, zza_currency.updated_at)
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zza.code,
|
||||||
|
"name": zzc.name})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertIsNotNone(zza_currency)
|
||||||
|
self.assertNotEqual(zza_currency.created_at,
|
||||||
|
zza_currency.updated_at)
|
||||||
|
|
||||||
|
def test_created_updated_by(self) -> None:
|
||||||
|
"""Tests the created-by and updated-by record.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Currency
|
||||||
|
from test_site import db
|
||||||
|
editor_username, editor2_username = "editor", "editor2"
|
||||||
|
client, csrf_token = get_client(self, self.app, editor2_username)
|
||||||
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
|
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertEqual(zza_currency.created_by.username, editor_username)
|
||||||
|
self.assertEqual(zza_currency.updated_by.username, editor_username)
|
||||||
|
|
||||||
|
response = client.post(update_uri,
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"code": zza.code,
|
||||||
|
"name": f"{zza.name}-2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertEqual(zza_currency.created_by.username, editor_username)
|
||||||
|
self.assertEqual(zza_currency.updated_by.username, editor2_username)
|
||||||
|
|
||||||
|
def test_l10n(self) -> None:
|
||||||
|
"""Tests the localization.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from accounting.models import Currency
|
||||||
|
from test_site import db
|
||||||
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
|
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertEqual(zza_currency.name_l10n, zza.name)
|
||||||
|
self.assertEqual(zza_currency.l10n, [])
|
||||||
|
|
||||||
|
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zza.code,
|
||||||
|
"name": f"{zza.name}-zh_Hant"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertEqual(zza_currency.name_l10n, zza.name)
|
||||||
|
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
|
||||||
|
{("zh_Hant", f"{zza.name}-zh_Hant")})
|
||||||
|
|
||||||
|
set_locale(self, self.client, self.csrf_token, "en")
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zza.code,
|
||||||
|
"name": f"{zza.name}-2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
|
||||||
|
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
|
||||||
|
{("zh_Hant", f"{zza.name}-zh_Hant")})
|
||||||
|
|
||||||
|
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
||||||
|
|
||||||
|
response = self.client.post(update_uri,
|
||||||
|
data={"csrf_token": self.csrf_token,
|
||||||
|
"code": zza.code,
|
||||||
|
"name": f"{zza.name}-zh_Hant-2"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], detail_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
zza_currency: Currency = db.session.get(Currency, zza.code)
|
||||||
|
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
|
||||||
|
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
|
||||||
|
{("zh_Hant", f"{zza.name}-zh_Hant-2")})
|
||||||
|
|
||||||
|
def test_delete(self) -> None:
|
||||||
|
"""Tests to delete a currency.
|
||||||
|
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
from accounting.models import Currency
|
||||||
|
detail_uri: str = f"{PREFIX}/{zza.code}"
|
||||||
|
delete_uri: str = f"{PREFIX}/{zza.code}/delete"
|
||||||
|
list_uri: str = PREFIX
|
||||||
|
response: httpx.Response
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Currency.query.all()},
|
||||||
|
{zza.code, zzb.code})
|
||||||
|
|
||||||
|
response = self.client.get(detail_uri)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
response = self.client.post(delete_uri,
|
||||||
|
data={"csrf_token": self.csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.headers["Location"], list_uri)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
self.assertEqual({x.code for x in Currency.query.all()},
|
||||||
|
{zzb.code})
|
||||||
|
|
||||||
|
response = self.client.get(detail_uri)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
response = self.client.post(delete_uri,
|
||||||
|
data={"csrf_token": self.csrf_token})
|
||||||
|
self.assertEqual(response.status_code, 404)
|
@ -29,6 +29,8 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from flask_wtf import CSRFProtect
|
from flask_wtf import CSRFProtect
|
||||||
from sqlalchemy import Column
|
from sqlalchemy import Column
|
||||||
|
|
||||||
|
import accounting.utils.user
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("home", __name__)
|
bp: Blueprint = Blueprint("home", __name__)
|
||||||
babel_js: BabelJS = BabelJS()
|
babel_js: BabelJS = BabelJS()
|
||||||
csrf: CSRFProtect = CSRFProtect()
|
csrf: CSRFProtect = CSRFProtect()
|
||||||
@ -53,7 +55,6 @@ def create_app(is_testing: bool = False) -> Flask:
|
|||||||
})
|
})
|
||||||
if is_testing:
|
if is_testing:
|
||||||
app.config["TESTING"] = True
|
app.config["TESTING"] = True
|
||||||
app.config["SQLALCHEMY_ECHO"] = True
|
|
||||||
|
|
||||||
babel_js.init_app(app)
|
babel_js.init_app(app)
|
||||||
csrf.init_app(app)
|
csrf.init_app(app)
|
||||||
@ -68,7 +69,7 @@ def create_app(is_testing: bool = False) -> Flask:
|
|||||||
from . import auth
|
from . import auth
|
||||||
auth.init_app(app)
|
auth.init_app(app)
|
||||||
|
|
||||||
class UserUtils(accounting.AbstractUserUtils[auth.User]):
|
class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cls(self) -> t.Type[auth.User]:
|
def cls(self) -> t.Type[auth.User]:
|
||||||
@ -79,7 +80,7 @@ def create_app(is_testing: bool = False) -> Flask:
|
|||||||
return auth.User.id
|
return auth.User.id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_user(self) -> auth.User:
|
def current_user(self) -> auth.User | None:
|
||||||
return auth.current_user()
|
return auth.current_user()
|
||||||
|
|
||||||
def get_by_username(self, username: str) -> auth.User | None:
|
def get_by_username(self, username: str) -> auth.User | None:
|
||||||
@ -90,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask:
|
|||||||
return user.id
|
return user.id
|
||||||
|
|
||||||
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||||
and auth.current_user().username in ["viewer", "editor"]
|
and auth.current_user().username in ["viewer", "editor", "editor2"]
|
||||||
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||||
and auth.current_user().username == "editor"
|
and auth.current_user().username in ["editor", "editor2"]
|
||||||
accounting.init_app(app, user_utils=UserUtils(),
|
accounting.init_app(app, user_utils=UserUtils(),
|
||||||
can_view_func=can_view, can_edit_func=can_edit)
|
can_view_func=can_view, can_edit_func=can_edit)
|
||||||
|
|
||||||
@ -105,7 +106,7 @@ def init_db_command() -> None:
|
|||||||
"""Initializes the database."""
|
"""Initializes the database."""
|
||||||
db.create_all()
|
db.create_all()
|
||||||
from .auth import User
|
from .auth import User
|
||||||
for username in ["viewer", "editor", "nobody"]:
|
for username in ["viewer", "editor", "editor2", "nobody"]:
|
||||||
if User.query.filter(User.username == username).first() is None:
|
if User.query.filter(User.username == username).first() is None:
|
||||||
db.session.add(User(username=username))
|
db.session.add(User(username=username))
|
||||||
db.session.commit()
|
db.session.commit()
|
@ -58,7 +58,8 @@ def login() -> redirect:
|
|||||||
|
|
||||||
:return: The redirection to the home page.
|
:return: The redirection to the home page.
|
||||||
"""
|
"""
|
||||||
if request.form.get("username") not in ["viewer", "editor", "nobody"]:
|
if request.form.get("username") not in ["viewer", "editor", "editor2",
|
||||||
|
"nobody"]:
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
session["user"] = request.form.get("username")
|
session["user"] = request.form.get("username")
|
||||||
return redirect(url_for("home.home"))
|
return redirect(url_for("home.home"))
|
@ -29,6 +29,7 @@ First written: 2023/1/27
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
|
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
|
||||||
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
|
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
|
||||||
|
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
|
||||||
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -9,8 +9,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
||||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||||
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
|
"POT-Creation-Date: 2023-02-06 23:25+0800\n"
|
||||||
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
|
"PO-Revision-Date: 2023-02-06 23:26+0800\n"
|
||||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||||
"Language: zh_Hant\n"
|
"Language: zh_Hant\n"
|
||||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||||
@ -20,35 +20,41 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Generated-By: Babel 2.11.0\n"
|
"Generated-By: Babel 2.11.0\n"
|
||||||
|
|
||||||
#: tests/testsite/templates/base.html:23
|
#: tests/test_site/templates/base.html:23
|
||||||
msgid "en"
|
msgid "en"
|
||||||
msgstr "zh-Hant"
|
msgstr "zh-Hant"
|
||||||
|
|
||||||
#: tests/testsite/templates/base.html:43 tests/testsite/templates/home.html:24
|
#: tests/test_site/templates/base.html:43
|
||||||
|
#: tests/test_site/templates/home.html:24
|
||||||
msgid "Home"
|
msgid "Home"
|
||||||
msgstr "首頁"
|
msgstr "首頁"
|
||||||
|
|
||||||
#: tests/testsite/templates/base.html:68
|
#: tests/test_site/templates/base.html:68
|
||||||
msgid "Log Out"
|
msgid "Log Out"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: tests/testsite/templates/base.html:78 tests/testsite/templates/login.html:24
|
#: tests/test_site/templates/base.html:78
|
||||||
|
#: tests/test_site/templates/login.html:24
|
||||||
msgid "Log In"
|
msgid "Log In"
|
||||||
msgstr "登入"
|
msgstr "登入"
|
||||||
|
|
||||||
#: tests/testsite/templates/base.html:119
|
#: tests/test_site/templates/base.html:119
|
||||||
msgid "Error:"
|
msgid "Error:"
|
||||||
msgstr "錯誤:"
|
msgstr "錯誤:"
|
||||||
|
|
||||||
#: tests/testsite/templates/login.html:30
|
#: tests/test_site/templates/login.html:30
|
||||||
msgid "Viewer"
|
msgid "Viewer"
|
||||||
msgstr "讀報表者"
|
msgstr "讀報表者"
|
||||||
|
|
||||||
#: tests/testsite/templates/login.html:31
|
#: tests/test_site/templates/login.html:31
|
||||||
msgid "Editor"
|
msgid "Editor"
|
||||||
msgstr "記帳者"
|
msgstr "記帳者"
|
||||||
|
|
||||||
#: tests/testsite/templates/login.html:32
|
#: tests/test_site/templates/login.html:32
|
||||||
|
msgid "Editor2"
|
||||||
|
msgstr "記帳者2"
|
||||||
|
|
||||||
|
#: tests/test_site/templates/login.html:33
|
||||||
msgid "Nobody"
|
msgid "Nobody"
|
||||||
msgstr "沒有權限者"
|
msgstr "沒有權限者"
|
||||||
|
|
310
tests/test_utils.py
Normal file
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,10 +17,32 @@
|
|||||||
"""The common test libraries.
|
"""The common test libraries.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import typing as t
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
|
def get_client(test_case: TestCase, app: Flask, username: str) \
|
||||||
|
-> tuple[httpx.Client, str]:
|
||||||
|
"""Returns a user client.
|
||||||
|
|
||||||
|
:param test_case: The test case.
|
||||||
|
:param app: The Flask application.
|
||||||
|
:param username: The username.
|
||||||
|
:return: A tuple of the client and the CSRF token.
|
||||||
|
"""
|
||||||
|
client: httpx.Client = httpx.Client(app=app, base_url="https://testserver")
|
||||||
|
client.headers["Referer"] = "https://testserver"
|
||||||
|
csrf_token: str = get_csrf_token(test_case, client, "/login")
|
||||||
|
response: httpx.Response = client.post("/login",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"username": username})
|
||||||
|
test_case.assertEqual(response.status_code, 302)
|
||||||
|
test_case.assertEqual(response.headers["Location"], "/")
|
||||||
|
return client, csrf_token
|
||||||
|
|
||||||
|
|
||||||
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
||||||
@ -54,3 +76,21 @@ def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
|||||||
parser.feed(response.text)
|
parser.feed(response.text)
|
||||||
test_case.assertIsNotNone(parser.csrf_token)
|
test_case.assertIsNotNone(parser.csrf_token)
|
||||||
return parser.csrf_token
|
return parser.csrf_token
|
||||||
|
|
||||||
|
|
||||||
|
def set_locale(test_case: TestCase, client: httpx.Client, csrf_token: str,
|
||||||
|
locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
|
||||||
|
"""Sets the current locale.
|
||||||
|
|
||||||
|
:param test_case: The test case.
|
||||||
|
:param client: The test client.
|
||||||
|
:param csrf_token: The CSRF token.
|
||||||
|
:param locale: The locale.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
response: httpx.Response = client.post("/locale",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"locale": locale,
|
||||||
|
"next": "/next"})
|
||||||
|
test_case.assertEqual(response.status_code, 302)
|
||||||
|
test_case.assertEqual(response.headers["Location"], "/next")
|
||||||
|
Reference in New Issue
Block a user