60 Commits

Author SHA1 Message Date
50f8f06687 Revised the translation. 2023-02-06 09:50:03 +08:00
cd5b1b97fd Added a different the page title of the search result in the base account list and account list, to be clear. 2023-02-06 09:47:19 +08:00
b7dd53d2f9 Added a complex query to the test_malformed test of the QueryKeywordParserTestCase test case. 2023-02-04 14:54:32 +08:00
b07b0e3be4 Added a complex query to the test_default test of the QueryKeywordParserTestCase test case. 2023-02-04 14:53:18 +08:00
e7fb2288ce Revised the parse_query_keywords utility to handle the case with an open double quotation mark without its corresponding close double quotation mark. 2023-02-04 14:51:09 +08:00
17ba7659b6 Removed the CSRF token from the NextUriTestCase test case, for simplicity. 2023-02-04 14:38:25 +08:00
2c8d5e7c8a Revised the translation. 2023-02-04 13:27:04 +08:00
e2f707f696 Replaced gettext with pgettext in the Pagination utility. 2023-02-04 13:26:58 +08:00
b5c0d0b7b3 Added the pgettext function to the "accounting.locale" module. 2023-02-04 13:26:32 +08:00
7fe2bb6135 Removed an excess blank line from the "accounting.utils.pagination" module. 2023-02-04 12:57:38 +08:00
4d870f1dcc Added the page size to the public properties of the Pagination utility. It is used in the pagination template. 2023-02-04 12:55:30 +08:00
16b2eb1c93 Renamed the page_links and page_sizes properties to pages and page_size_options in the Pagination utility. 2023-02-04 12:51:30 +08:00
fd63149066 Revised the pagination utility to handle the empty data. better 2023-02-04 12:19:30 +08:00
a7a432914d Added the empty condition in the __get_page_sizes method of the Pagination utility. 2023-02-04 11:37:00 +08:00
1a44f08b90 Revised the empty condition in the __get_page_links method of the Pagination utility. 2023-02-04 11:36:42 +08:00
3e68cfe690 Removed incorrect documentation in the Pagination utility. 2023-02-04 11:31:09 +08:00
809f2b6df3 Changed the page number and page size properties to private in the Pagination utility. 2023-02-04 11:26:33 +08:00
c286aa8b8b Added the missing parameter in the __uri_set method of the Pagination utility. 2023-02-04 11:24:10 +08:00
1326d9538c Added the missing is_found = True in the __uri_set method of the Pagination utility. 2023-02-04 11:21:22 +08:00
b9cecf343a Added the generic type to the pagination utility in the PaginationTestCase test case. 2023-02-04 11:09:20 +08:00
3d9e6c10da Removed the invalid page number handler in the __set_list method of the Pagination utility. The invalid page numbers are handled and redirected in the __get_page_no method now. 2023-02-04 11:07:04 +08:00
5090e59bb1 Added to redirect when the page size is invalid in the Pagination utility. 2023-02-04 10:55:49 +08:00
62697fb782 Added the exception to the documentation of the constructor of the Pagination utility. 2023-02-04 10:51:07 +08:00
8c462e7b2c Replaced the messy __get_base_uri_params __uri_set_params methods with the unified __uri_set method in the Pagination utility. 2023-02-04 10:49:35 +08:00
90a8229db9 Revised the Pagination so that the page size and page number that are the same as the default values are redirected and removed, too. 2023-02-04 10:37:39 +08:00
8be44ccf5f Renamed the is_needed property to is_paged in the Pagination utility. 2023-02-04 10:26:28 +08:00
511328a0bd Renamed the PageLink class to Link in the "accounting.utils.pagination" module. 2023-02-04 10:18:22 +08:00
0d8cf85ec0 Removed an excess blank line in test_utils.py. 2023-02-04 09:51:19 +08:00
6e212f0e33 Revised the Pagination utility to handle the malformed and illegal page number and page size values. 2023-02-04 09:34:52 +08:00
2fbe137243 Added test_utils.py with the NextUriTestCase, QueryKeywordParserTestCase, and PaginationTestCase test cases for the independent utilities. 2023-02-04 08:12:24 +08:00
f4e2c21ece Added test_temp.py to .gitignore, for temporary tests that should not be committed. 2023-02-04 08:11:52 +08:00
fff07a2552 Removed node_models from .gitignore. 2023-02-03 23:06:28 +08:00
975b00bce9 Advanced to version 0.1.1. 2023-02-03 17:14:47 +08:00
d648538fbb Added onupdate="CASCADE" to the foreign keys. 2023-02-03 17:14:32 +08:00
dde9c38bb8 Fixed the primary key of the Account data model to be not auto-incrementing. 2023-02-03 13:32:19 +08:00
fecf33baa8 Updated the minimal python version to 3.11, as for the use of the typing.Self type hint. 2023-02-03 13:01:03 +08:00
cea2a44226 Added the order and sorting routes to the test_nobody, test_viewer, and test_editor tests of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
b5d87d2387 Revised to allow the viewers to view the account order page. 2023-02-03 12:57:53 +08:00
784e7bde49 Added the test_reorder test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
60280f415d Shortened the variable names in the test_change_base test of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
f32d268494 Revised the order and sorting routes from "/base/" to "/bases/". 2023-02-03 12:57:53 +08:00
1c1be87f3e Revised the accounting reordering to handle the cases with only one account or no account. 2023-02-03 12:57:53 +08:00
589da0c1c6 Renamed "sorting" to "reorder", and the "sort-form" route to "order". 2023-02-03 12:57:53 +08:00
8363ce6602 Fixed the endpoint name in the account detail template. 2023-02-03 12:57:53 +08:00
6a83f95c9f Added the test_change_base test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
7dc754174c Revised the documentation of the views. 2023-02-03 12:57:53 +08:00
5238168b2d Added support to sort the accounts under the same base account. 2023-02-03 12:57:53 +08:00
eeb05b8616 Removed the unique constraint in the Account data model. 2023-02-03 12:57:53 +08:00
9920377266 Added a missing semicolon in account-form.js. 2023-02-03 12:57:53 +08:00
9f9c40c30e Revised the code to find the next number in the populate_obj method of the AccountForm form. 2023-02-03 12:57:53 +08:00
d368c5e062 Renamed the variable in the new_id function from "new" to "obj_id", to be clear. 2023-02-03 12:57:53 +08:00
4aed2f6ba7 Renamed the "testsite" application to "test_site". 2023-02-03 12:57:53 +08:00
6876fdf75e Added the test_editor test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
d9624c7be6 Revised the AccountTestCase test case for simplicity. 2023-02-03 12:57:53 +08:00
8364025668 Split the BaseAccountTestCase into BaseAccountCommandTestCase and BaseAccountTestCase, and rewrote the BaseAccountTestCase for simplicity. 2023-02-03 12:57:53 +08:00
dd3690dd6a Added the AccountTestCase test case with the test_nobody and test_viewer tests. 2023-02-03 12:57:53 +08:00
3312c835fd Added the AccountCommandTestCase test case. 2023-02-03 12:57:53 +08:00
fce9d04896 Removed SQLALCHEMY_ECHO from the test site. 2023-02-03 12:57:53 +08:00
c68786f78a Revised the import in the test_init test of the BaseAccountTestCase test case. 2023-02-03 12:57:53 +08:00
581e803707 Moved the user utilities from the "accounting.database" module to the "accounting.utils.users" module, and simplified its use. 2023-02-03 12:57:53 +08:00
36 changed files with 1598 additions and 297 deletions

2
.gitignore vendored
View File

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

View File

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

View File

@ -18,55 +18,11 @@
""" """
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 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 +43,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,

View File

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

View File

@ -18,15 +18,17 @@
""" """
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:
@ -67,14 +69,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 +89,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 +129,48 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
for i in range(len(accounts)): for i in range(len(accounts)):
if accounts[i].no != i + 1: if accounts[i].no != i + 1:
accounts[i].no = i + 1 accounts[i].no = i + 1
class AccountReorderForm:
"""The form to reorder the accounts."""
def __init__(self, base: BaseAccount):
"""Constructs the form to reorder the accounts under a base account.
:param base: The base account.
"""
self.base: BaseAccount = base
self.is_modified: bool = False
def save_order(self) -> None:
"""Saves the order of the account.
:return:
"""
accounts: list[Account] = self.base.accounts
# Collects the specified order.
orders: dict[Account, int] = {}
for account in accounts:
if f"{account.id}-no" in request.form:
try:
orders[account] = int(request.form[f"{account.id}-no"])
except ValueError:
pass
# Missing and invalid orders are appended to the end.
missing: list[Account] = [x for x in accounts if x not in orders]
if len(missing) > 0:
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
for account in missing:
orders[account] = next_no
# Sort by the specified order first, and their original order.
accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code))
# Update the orders.
with db.session.no_autoflush:
for i in range(len(accounts)):
if accounts[i].no != i + 1:
accounts[i].no = i + 1
self.is_modified = True

View File

@ -29,7 +29,7 @@ from accounting.models import Account, BaseAccount
from accounting.utils.next_url import inherit_next, or_next from accounting.utils.next_url import inherit_next, or_next
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit from accounting.utils.permission import can_view, has_permission, can_edit
from .forms import AccountForm, sort_accounts_in from .forms import AccountForm, sort_accounts_in, AccountReorderForm
bp: Blueprint = Blueprint("account", __name__) bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management.""" """The view blueprint for the account management."""
@ -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.
""" """
@ -152,6 +155,7 @@ 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.
""" """
@ -162,3 +166,33 @@ def delete_account(account: Account) -> redirect:
db.session.commit() db.session.commit()
flash(lazy_gettext("The account is deleted successfully."), "success") flash(lazy_gettext("The account is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.account.list"))) return redirect(or_next(url_for("accounting.account.list")))
@bp.get("/bases/<baseAccount:base>", endpoint="order")
@has_permission(can_view)
def show_account_order(base: BaseAccount) -> str:
"""Shows the order of the accounts under a same base account.
:param base: The base account.
:return: The order of the accounts under the base account.
"""
return render_template("accounting/account/order.html", base=base)
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect:
"""Reorders the accounts under a base account.
:param base: The base account.
:return: The redirection to the incoming account or the account list. The
reordering operation does not fail.
"""
form: AccountReorderForm = AccountReorderForm(base)
form.save_order()
if not form.is_modified:
flash(lazy_gettext("The order was not modified."), "success")
return redirect(or_next(url_for("accounting.account.list")))
db.session.commit()
flash(lazy_gettext("The order is updated successfully."), "success")
return redirect(or_next(url_for("accounting.account.list")))

View File

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

View File

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

View File

@ -39,6 +39,17 @@ def gettext(string, **variables) -> str:
return domain.gettext(string, **variables) return domain.gettext(string, **variables)
def pgettext(context, string, **variables) -> str:
"""A replacement of the Babel gettext() function..
:param context: The context.
:param string: The message to translate.
:param variables: The variable substitution.
:return: The translated message.
"""
return domain.pgettext(context, string, **variables)
def lazy_gettext(string, **variables) -> LazyString: def lazy_gettext(string, **variables) -> LazyString:
"""A replacement of the Babel lazy_gettext() function.. """A replacement of the Babel lazy_gettext() function..

View File

@ -25,10 +25,8 @@ from flask import current_app
from flask_babel import get_locale from flask_babel import get_locale
from sqlalchemy import text from sqlalchemy import text
from accounting.database import db, user_utils from accounting.database import db
from accounting.utils.user import user_cls, user_pk_column
user_cls: db.Model = user_utils.cls
user_pk_column: db.Column = user_utils.pk_column
class BaseAccount(db.Model): class BaseAccount(db.Model):
@ -71,7 +69,9 @@ class BaseAccountL10n(db.Model):
"""A localized base account title.""" """A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n" __tablename__ = "accounting_base_accounts_l10n"
"""The table name.""" """The table name."""
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code, account_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code,
onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) nullable=False, primary_key=True)
"""The code of the account.""" """The code of the account."""
@ -87,9 +87,11 @@ class Account(db.Model):
"""An account.""" """An account."""
__tablename__ = "accounting_accounts" __tablename__ = "accounting_accounts"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True) id = db.Column(db.Integer, nullable=False, primary_key=True,
autoincrement=False)
"""The account ID.""" """The account ID."""
base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code, base_code = db.Column(db.String,
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False) nullable=False)
"""The code of the base account.""" """The code of the base account."""
@ -104,7 +106,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 +116,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 +126,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,"""
@ -301,7 +306,8 @@ 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, account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) nullable=False, primary_key=True)
account = db.relationship(Account, back_populates="l10n") account = db.relationship(Account, back_populates="l10n")

View File

@ -23,7 +23,7 @@
// 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("account-base-code")
.onchange = validateBase; .onchange = validateBase;
document.getElementById("account-title") document.getElementById("account-title")

View File

@ -0,0 +1,39 @@
/* The Mia! Accounting Flask Project
* account-order.js: The JavaScript for the account order
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/2
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
const list = document.getElementById("account-order-list");
if (list !== null) {
const onReorder = function () {
const accounts = Array.from(list.children);
for (let i = 0; i < accounts.length; i++) {
const no = document.getElementById("account-order-" + accounts[i].dataset.id + "-no");
const code = document.getElementById("account-order-" + accounts[i].dataset.id + "-code");
no.value = String(i + 1);
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
}
};
initializeDragAndDropReordering(list, onReorder);
}
});

View File

@ -0,0 +1,108 @@
/* The Mia! Accounting Flask Project
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/3
*/
/**
* Initializes the drag-and-drop reordering on a list.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
*/
function initializeDragAndDropReordering(list, onReorder) {
initializeMouseDragAndDropReordering(list, onReorder);
initializeTouchDragAndDropReordering(list, onReorder);
}
/**
* Initializes the drag-and-drop reordering with mouse.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
* @private
*/
function initializeMouseDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
let dragged = null;
items.forEach(function (item) {
item.draggable = true;
item.addEventListener("dragstart", function () {
dragged = item;
dragged.classList.add("list-group-item-dark");
});
item.addEventListener("dragover", function () {
onDragOver(dragged, item);
onReorder();
});
item.addEventListener("dragend", function () {
dragged.classList.remove("list-group-item-dark");
dragged = null;
});
});
}
/**
* Initializes the drag-and-drop reordering with touch devices.
*
* @param list {HTMLElement} the list to be reordered
* @param onReorder {(function())|*} The callback to reorder the items
* @private
*/
function initializeTouchDragAndDropReordering(list, onReorder) {
const items = Array.from(list.children);
items.forEach(function (item) {
item.addEventListener("touchstart", function () {
item.classList.add("list-group-item-dark");
});
item.addEventListener("touchmove", function (event) {
const touch = event.targetTouches[0];
const target = document.elementFromPoint(touch.pageX, touch.pageY);
onDragOver(item, target);
onReorder();
});
item.addEventListener("touchend", function () {
item.classList.remove("list-group-item-dark");
});
});
}
/**
* Handles when an item is dragged over the other item.
*
* @param dragged {Element} the item that was dragged
* @param target {Element} the other item that was dragged over
*/
function onDragOver(dragged, target) {
if (target.parentElement !== dragged.parentElement || target === dragged) {
return;
}
let isBefore = false;
for (let p = target; p !== null; p = p.previousSibling) {
if (p === dragged) {
isBefore = true;
}
}
if (isBefore) {
target.parentElement.insertBefore(dragged, target.nextSibling);
} else {
target.parentElement.insertBefore(dragged, target);
}
}

View File

@ -35,6 +35,12 @@ First written: 2023/1/31
<i class="fa-solid fa-gear"></i> <i class="fa-solid fa-gear"></i>
{{ A_("Settings") }} {{ A_("Settings") }}
</a> </a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
{{ A_("Order") }}
</a>
{% if can_edit_accounting() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal"> <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
{{ A_("Delete") }} {{ A_("Delete") }}

View File

@ -21,7 +21,7 @@ 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 %}

View File

@ -0,0 +1,84 @@
{#
The Mia! Accounting Flask Project
order.html: The order of the accounts under a same base account
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/2
#}
{% extends "accounting/account/include/form.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/account-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("The Accounts of %(base)s", base=base) }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
{% if base.accounts|length > 1 and can_edit_accounting() %}
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<ul id="account-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
{% for account in base.accounts|sort(attribute="no") %}
<li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}">
<input id="account-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
<div>
<span id="account-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 material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% elif base.accounts %}
<ul class="list-group mb-3">
{% for account in base.accounts|sort(attribute="no") %}
<li class="list-group-item">
{{ account }}
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -21,7 +21,7 @@ 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 %}

View File

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

View File

@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-01 19:51+0800\n" "POT-Creation-Date: 2023-02-06 09:47+0800\n"
"PO-Revision-Date: 2023-02-01 19:52+0800\n" "PO-Revision-Date: 2023-02-06 09:48+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,49 @@ 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:50
#: 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:55
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:167
msgid "The account is deleted successfully." msgid "The account is deleted successfully."
msgstr "科目刪掉了" msgstr "科目刪掉了"
#: src/accounting/account/views.py:194
msgid "The order was not modified."
msgstr "順序未異動。"
#: src/accounting/account/views.py:197
msgid "The order is updated 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,6 +72,7 @@ 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
msgid "Back" msgid "Back"
msgstr "回上頁" msgstr "回上頁"
@ -66,37 +81,36 @@ msgstr "回上頁"
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
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
msgid "Cancel" msgid "Cancel"
msgstr "取消" msgstr "取消"
#: src/accounting/templates/accounting/account/detail.html:71 #: src/accounting/templates/accounting/account/detail.html:77
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
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
msgid "Updated" msgid "Updated"
msgstr "更新" msgstr "更新"
@ -105,6 +119,12 @@ 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
#, 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 "科目管理"
@ -120,10 +140,21 @@ 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
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
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 +171,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 "選擇基本科目"
@ -169,11 +196,13 @@ msgstr "科目"
msgid "Base Accounts" msgid "Base Accounts"
msgstr "基本科目" msgstr "基本科目"
#: src/accounting/utils/pagination.py:146 #: src/accounting/utils/pagination.py:206
msgctxt "Pagination|"
msgid "Previous" msgid "Previous"
msgstr "一頁" msgstr "一頁"
#: src/accounting/utils/pagination.py:194 #: src/accounting/utils/pagination.py:255
msgctxt "Pagination|"
msgid "Next" msgid "Next"
msgstr "下一頁" msgstr "下一頁"

View File

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

View File

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

View File

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

View File

@ -0,0 +1,116 @@
# 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_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model)
class AbstractUserUtils(t.Generic[T], ABC):
"""The abstract user utilities."""
@property
@abstractmethod
def cls(self) -> t.Type[T]:
"""Returns the user class.
:return: The user class.
"""
@property
@abstractmethod
def pk_column(self) -> sa.Column:
"""Returns the primary key column.
:return: The primary key column.
"""
@property
@abstractmethod
def current_user(self) -> T:
"""Returns the current user.
:return: The current user.
"""
@abstractmethod
def get_by_username(self, username: str) -> T | None:
"""Returns the user by her username.
:return: The user by her username, or None if the user was not found.
"""
@abstractmethod
def get_pk(self, user: T) -> int:
"""Returns the primary key of the user.
:return: The primary key of the user.
"""
__user_utils: AbstractUserUtils
"""The user utilities."""
user_cls: t.Type[Model]
"""The user class."""
user_pk_column: sa.Column
"""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(__user_utils.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))

View File

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

408
tests/test_account.py Normal file
View File

@ -0,0 +1,408 @@
# 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 unittest
import httpx
import sqlalchemy as sa
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import UserClient, get_user_client
from test_site import create_app
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()
editor: UserClient = get_user_client(self, self.app, "editor")
self.client: httpx.Client = editor.client
self.csrf_token: str = editor.csrf_token
response: httpx.Response
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "1111 title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "1112 title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-001")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
from accounting.models import Account
response: httpx.Response
nobody: UserClient = get_user_client(self, self.app, "nobody")
response = nobody.client.get("/accounting/accounts")
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/1111-001")
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/create")
self.assertEqual(response.status_code, 403)
response = nobody.client.post("/accounting/accounts/store",
data={"csrf_token": nobody.csrf_token,
"base_code": "1113",
"title": "1113 title"})
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/1111-001/edit")
self.assertEqual(response.status_code, 403)
response = nobody.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": nobody.csrf_token,
"base_code": "1111",
"title": "1111 title #2"})
self.assertEqual(response.status_code, 403)
response = nobody.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": nobody.csrf_token})
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/bases/1111")
self.assertEqual(response.status_code, 403)
with self.app.app_context():
account_id: int = Account.find_by_code("1112-001").id
response = nobody.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": nobody.csrf_token,
"next": "/next",
f"{account_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
response: httpx.Response
viewer: UserClient = get_user_client(self, self.app, "viewer")
response = viewer.client.get("/accounting/accounts")
self.assertEqual(response.status_code, 200)
response = viewer.client.get("/accounting/accounts/1111-001")
self.assertEqual(response.status_code, 200)
response = viewer.client.get("/accounting/accounts/create")
self.assertEqual(response.status_code, 403)
response = viewer.client.post("/accounting/accounts/store",
data={"csrf_token": viewer.csrf_token,
"base_code": "1113",
"title": "1113 title"})
self.assertEqual(response.status_code, 403)
response = viewer.client.get("/accounting/accounts/1111-001/edit")
self.assertEqual(response.status_code, 403)
response = viewer.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": viewer.csrf_token,
"base_code": "1111",
"title": "1111 title #2"})
self.assertEqual(response.status_code, 403)
response = viewer.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": viewer.csrf_token})
self.assertEqual(response.status_code, 403)
response = viewer.client.get("/accounting/accounts/bases/1111")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
account_id: int = Account.find_by_code("1112-001").id
response = viewer.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": viewer.csrf_token,
"next": "/next",
f"{account_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("/accounting/accounts")
self.assertEqual(response.status_code, 200)
response = self.client.get("/accounting/accounts/1111-001")
self.assertEqual(response.status_code, 200)
response = self.client.get("/accounting/accounts/create")
self.assertEqual(response.status_code, 200)
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1113",
"title": "1113 title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1113-001")
response = self.client.get("/accounting/accounts/1111-001/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "1111 title #2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
response = self.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts")
response = self.client.get("/accounting/accounts/bases/1111")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
account_id: int = Account.find_by_code("1112-001").id
response = self.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{account_id}-no": "5"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/next")
def test_change_base(self) -> None:
"""Tests to change the base account.
:return: None.
"""
from accounting.database import db
from accounting.models import Account
response: httpx.Response
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title #1"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-002")
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title #1"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-003")
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "Title #1"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-002")
with self.app.app_context():
id_1: int = Account.find_by_code("1111-001").id
id_2: int = Account.find_by_code("1111-002").id
id_3: int = Account.find_by_code("1111-003").id
id_4: int = Account.find_by_code("1112-001").id
id_5: int = Account.find_by_code("1112-002").id
response = self.client.post("/accounting/accounts/1111-002/update",
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "Account #1"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-003")
with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-001")
self.assertEqual(db.session.get(Account, id_2).code, "1112-003")
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
self.assertEqual(db.session.get(Account, id_4).code, "1112-001")
self.assertEqual(db.session.get(Account, id_5).code, "1112-002")
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("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"/accounting/accounts/1111-00{i}")
# 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("/accounting/accounts/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("/accounting/accounts/bases/1111",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{id_2}-no": "3a",
f"{id_3}-no": "5",
f"{id_4}-no": "2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"/next")
with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
self.assertEqual(db.session.get(Account, id_4).code, "1111-001")
self.assertEqual(db.session.get(Account, id_5).code, "1111-005")

View File

@ -25,12 +25,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 testlib import UserClient, get_user_client
from testsite import create_app from test_site import create_app
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 +38,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 +67,69 @@ 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)
self.viewer: UserClient = get_user_client(self, self.app, "viewer")
self.editor: UserClient = get_user_client(self, self.app, "editor")
self.nobody: UserClient = get_user_client(self, self.app, "nobody")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
response: httpx.Response response: httpx.Response
nobody: UserClient = get_user_client(self, self.app, "nobody")
self.__logout() response = nobody.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 = nobody.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( response: httpx.Response
"/logout", data={"csrf_token": self.csrf_token}) viewer: UserClient = get_user_client(self, self.app, "viewer")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/")
def __login_as(self, username: str) -> None: response = viewer.client.get("/accounting/base-accounts")
"""Logs in as a specific user. self.assertEqual(response.status_code, 200)
response = viewer.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( response: httpx.Response
"/login", data={"csrf_token": self.csrf_token, editor: UserClient = get_user_client(self, self.app, "editor")
"username": username})
self.assertEqual(response.status_code, 302) response = editor.client.get("/accounting/base-accounts")
self.assertEqual(response.headers["Location"], "/") self.assertEqual(response.status_code, 200)
response = editor.client.get("/accounting/base-accounts/1111")
self.assertEqual(response.status_code, 200)

View File

@ -29,6 +29,8 @@ from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from sqlalchemy import Column from sqlalchemy import Column
import accounting.utils.user
bp: Blueprint = Blueprint("home", __name__) bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS() babel_js: BabelJS = BabelJS()
csrf: CSRFProtect = CSRFProtect() csrf: CSRFProtect = CSRFProtect()
@ -53,7 +55,6 @@ def create_app(is_testing: bool = False) -> Flask:
}) })
if is_testing: if is_testing:
app.config["TESTING"] = True app.config["TESTING"] = True
app.config["SQLALCHEMY_ECHO"] = True
babel_js.init_app(app) babel_js.init_app(app)
csrf.init_app(app) csrf.init_app(app)
@ -68,7 +69,7 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth from . import auth
auth.init_app(app) auth.init_app(app)
class UserUtils(accounting.AbstractUserUtils[auth.User]): class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
@property @property
def cls(self) -> t.Type[auth.User]: def cls(self) -> t.Type[auth.User]:

View File

@ -20,35 +20,37 @@ 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 "Nobody" msgid "Nobody"
msgstr "沒有權限者" msgstr "沒有權限者"

310
tests/test_utils.py Normal file
View File

@ -0,0 +1,310 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/3
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The test for the independent utilities.
"""
import unittest
from urllib.parse import quote_plus
import httpx
from flask import Flask, request
from accounting.utils.next_url import append_next, inherit_next, or_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords
from test_site import create_app, csrf
class NextUriTestCase(unittest.TestCase):
"""The test case for the next URI utilities."""
def test_next_uri(self) -> None:
"""Tests the next URI utilities.
:return: None.
"""
app: Flask = create_app(is_testing=True)
target: str = "/target"
@app.route("/test-next", methods=["GET", "POST"])
@csrf.exempt
def test_next_view() -> str:
"""The test view with the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(target),
f"{target}?next={quote_plus(current_uri)}")
next_uri: str = request.form["next"] if request.method == "POST" \
else request.args["next"]
self.assertEqual(inherit_next(target),
f"{target}?next={quote_plus(next_uri)}")
self.assertEqual(or_next(target), next_uri)
return ""
@app.route("/test-no-next", methods=["GET", "POST"])
@csrf.exempt
def test_no_next_view() -> str:
"""The test view without the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(target),
f"{target}?next={quote_plus(current_uri)}")
self.assertEqual(inherit_next(target), target)
self.assertEqual(or_next(target), target)
return ""
client: httpx.Client = httpx.Client(app=app,
base_url="https://testserver")
client.headers["Referer"] = "https://testserver"
response: httpx.Response
# With the next URI
response = client.get("/test-next?next=/next&q=abc&page-no=4")
self.assertEqual(response.status_code, 200)
response = client.post("/test-next", data={"next": "/next",
"name": "viewer"})
self.assertEqual(response.status_code, 200)
# Without the next URI
response = client.get("/test-no-next?q=abc&page-no=4")
self.assertEqual(response.status_code, 200)
response = client.post("/test-no-next", data={"name": "viewer"})
self.assertEqual(response.status_code, 200)
class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser."""
def test_default(self) -> None:
"""Tests the query keyword parser.
:return: None.
"""
self.assertEqual(parse_query_keywords("coffee"), ["coffee"])
self.assertEqual(parse_query_keywords("coffee tea"), ["coffee", "tea"])
self.assertEqual(parse_query_keywords("\"coffee\" \"tea cake\""),
["coffee", "tea cake"])
self.assertEqual(parse_query_keywords("\"coffee tea\" cheese "
"\"cake candy\" sugar"),
["coffee tea", "cheese", "cake candy", "sugar"])
def test_malformed(self) -> None:
"""Tests the malformed query.
:return: None.
"""
self.assertEqual(parse_query_keywords("coffee \"tea cake"),
["coffee", "tea cake"])
self.assertEqual(parse_query_keywords("coffee te\"a ca\"ke"),
["coffee", "te\"a", "ca\"ke"])
self.assertEqual(parse_query_keywords("coffee\" tea cake\""),
["coffee\"", "tea", "cake\""])
def test_empty(self) -> None:
"""Tests the empty query.
:return: None.
"""
self.assertEqual(parse_query_keywords(None), [])
self.assertEqual(parse_query_keywords(""), [])
class PaginationTestCase(unittest.TestCase):
"""The test case for pagination."""
class Params:
"""The testing parameters."""
def __init__(self, items: list[int], is_reversed: bool | None,
result: list[int], is_paged: bool):
"""Constructs the expected pagination.
:param items: All the items in the list.
:param is_reversed: Whether the default page is the last page.
:param result: The expected items on the page.
:param is_paged: Whether the pagination is needed.
"""
self.items: list[int] = items
self.is_reversed: bool | None = is_reversed
self.result: list[int] = result
self.is_paged: bool = is_paged
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
self.params = self.Params([], None, [], True)
@self.app.get("/test-pagination")
def test_pagination_view() -> str:
"""The test view with the pagination."""
pagination: Pagination
if self.params.is_reversed is not None:
pagination = Pagination[int](
self.params.items, is_reversed=self.params.is_reversed)
else:
pagination = Pagination[int](self.params.items)
self.assertEqual(pagination.is_paged, self.params.is_paged)
self.assertEqual(pagination.list, self.params.result)
return ""
self.client = httpx.Client(app=self.app, base_url="https://testserver")
self.client.headers["Referer"] = "https://testserver"
def __test_success(self, query: str, items: range,
result: range, is_paged: bool = True,
is_reversed: bool | None = None) -> None:
"""Tests the pagination.
:param query: The query string.
:param items: The original items.
:param result: The expected page content.
:param is_paged: Whether the pagination is needed.
:param is_reversed: Whether the list is reversed.
:return: None.
"""
target: str = "/test-pagination"
if query != "":
target = f"{target}?{query}"
self.params = self.Params(list(items), is_reversed,
list(result), is_paged)
response: httpx.Response = self.client.get(target)
self.assertEqual(response.status_code, 200)
def __test_malformed(self, query: str, items: range, redirect_to: str,
is_reversed: bool | None = None) -> None:
"""Tests the pagination.
:param query: The query string.
:param items: The original items.
:param redirect_to: The expected target query of the redirection.
:param is_reversed: Whether the list is reversed.
:return: None.
"""
target: str = "/test-pagination"
self.params = self.Params(list(items), is_reversed, [], True)
response: httpx.Response = self.client.get(f"{target}?{query}")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{target}?{redirect_to}")
def test_default(self) -> None:
"""Tests the default pagination.
:return: None.
"""
# The default first page
self.__test_success("", range(1, 687), range(1, 11))
# Some page in the middle
self.__test_success("page-no=37", range(1, 687), range(361, 371))
# The last page
self.__test_success("page-no=69", range(1, 687), range(681, 687))
def test_page_size(self) -> None:
"""Tests the pagination with a different page size.
:return: None.
"""
# The default page with a different page size
self.__test_success("page-size=15", range(1, 687), range(1, 16))
# Some page with a different page size
self.__test_success("page-no=37&page-size=15", range(1, 687),
range(541, 556))
# The last page with a different page size.
self.__test_success("page-no=46&page-size=15", range(1, 687),
range(676, 687))
def test_not_needed(self) -> None:
"""Tests the pagination that is not needed.
:return: None.
"""
# Empty list
self.__test_success("", range(0, 0), range(0, 0), is_paged=False)
# A list that fits in one page
self.__test_success("", range(1, 4), range(1, 4), is_paged=False)
# A large page size that fits in everything
self.__test_success("page-size=1000", range(1, 687), range(1, 687),
is_paged=False)
def test_reversed(self) -> None:
"""Tests the default page on a reversed list.
:return: None.
"""
# The default page
self.__test_success("", range(1, 687), range(681, 687),
is_reversed=True)
# The default page with a different page size
self.__test_success("page-size=15", range(1, 687), range(676, 687),
is_reversed=True)
def test_last_page(self) -> None:
"""Tests the calculation of the items on the last page.
:return: None.
"""
# The last page that fits in one page
self.__test_success("page-no=69", range(1, 691), range(681, 691))
# A danging item in the last page
self.__test_success("page-no=70", range(1, 692), range(691, 692))
def test_malformed(self) -> None:
"""Tests the malformed pagination parameters.
:return: None.
"""
# A malformed page size
self.__test_malformed("q=word&page-size=100a&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F")
# A default page size
self.__test_malformed(f"q=word&page-size={DEFAULT_PAGE_SIZE}"
"&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F")
# An invalid page size
self.__test_malformed("q=word&page-size=0&page-no=37&next=%2F",
range(1, 691), "q=word&page-no=37&next=%2F")
# A malformed page number
self.__test_malformed("q=word&page-size=15&page-no=37a&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F")
# A default page number
self.__test_malformed("q=word&page-size=15&page-no=1&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F")
# A default page number, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=46&next=%2F",
range(1, 691), "q=word&page-size=15&next=%2F",
is_reversed=True)
# A page number beyond the last page
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
range(1, 691),
"q=word&page-size=15&page-no=46&next=%2F")
# A page number beyond the last page, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=100&next=%2F",
range(1, 691),
"q=word&page-size=15&next=%2F", is_reversed=True)
# A page number before the first page
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
range(1, 691),
"q=word&page-size=15&next=%2F")
# A page number before the first page, on a reversed list
self.__test_malformed("q=word&page-size=15&page-no=0&next=%2F",
range(1, 691),
"q=word&page-size=15&page-no=1&next=%2F",
is_reversed=True)

View File

@ -21,6 +21,40 @@ from html.parser import HTMLParser
from unittest import TestCase from unittest import TestCase
import httpx import httpx
from flask import Flask
class UserClient:
"""A user client."""
def __init__(self, client: httpx.Client, csrf_token: str):
"""Constructs a user client.
:param client: The client.
:param csrf_token: The CSRF token.
"""
self.client: httpx.Client = client
self.csrf_token: str = csrf_token
def get_user_client(test_case: TestCase, app: Flask, username: str) \
-> UserClient:
"""Returns a user client.
:param test_case: The test case.
:param app: The Flask application.
:param username: The username.
:return: The user client.
"""
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 UserClient(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: