Compare commits

..

22 Commits

Author SHA1 Message Date
16e2a146db Fixed the documentation in the Account data model. 2023-02-07 11:30:25 +08:00
f7ce94902f Revised the AccountTestCase test case, added the test_add, test_basic_update, test_update_not_modified, test_created_updated_by, test_l10n, and test_delete test to replace the simple test_change_base test. 2023-02-07 11:29:09 +08:00
5cf3cb1e11 Added the "is_modified" pseudo property to the Account data model, and applied it to the update_account view, to count the localized titles for modification. 2023-02-07 11:14:15 +08:00
a78057a8c3 Renamed the variable in the test_created_updated_by test of the CurrencyTestCase test case. 2023-02-07 09:47:32 +08:00
0491614ae4 Added the PREFIX constant to simplify the CurrencyTestCase test case. 2023-02-07 09:46:54 +08:00
fb9ff1d7ff Added to validate if the base account is available in the AccountForm form with the BaseAccountAvailable validator. 2023-02-07 09:30:06 +08:00
be10984cbb Fixed the documentation of the BaseAccountExists validator. 2023-02-07 09:28:10 +08:00
7b2089bdfb Revised the currency test cases. 2023-02-07 08:24:24 +08:00
be8dc21c5a Revised the code in the test_l10n test of the CurrencyTestCase test case. 2023-02-07 00:38:41 +08:00
2f8c6f6981 Removed the redundant unique constraint from the AccountL10n and CurrencyL10n data models. 2023-02-07 00:24:36 +08:00
cdd010427b Added documentation to the columns of the AccountL10n data model. 2023-02-07 00:23:45 +08:00
d78b941674 Applied the delete method of the Account data model to the delete_account view, to make things easier. 2023-02-07 00:22:23 +08:00
570c84c196 Added the currency management. 2023-02-07 00:13:33 +08:00
7873e16cc3 Added the editor2 user to the test site. 2023-02-06 23:28:21 +08:00
52351c52bc Revised the imports in test_base_account.py and test_account.py. 2023-02-06 21:45:56 +08:00
591fb4a7ab Replaced the UserClient class and the get_user_client function with the get_client function in the tests, for simplicity. 2023-02-06 21:45:28 +08:00
2a6c5de6d6 Removed the unused clients from the setUp method of the BaseAccountTestCase test case. 2023-02-06 21:37:41 +08:00
6b94cfb908 Removed excess blank lines in test_account.py and test_base_account.py. 2023-02-06 19:57:19 +08:00
eb90e83c98 Removed an unused import from the "accounting" module. 2023-02-06 19:31:06 +08:00
6bf18be455 Revised the coding style in the title setter of the Account data model. 2023-02-06 11:42:22 +08:00
895bca2508 Fixed the documentation of the list_accounts view. 2023-02-06 11:07:18 +08:00
6af29e7df7 Updated the icon to create a new account in the account list. 2023-02-06 10:08:50 +08:00
27 changed files with 2146 additions and 168 deletions

View File

@ -20,7 +20,6 @@
import typing as t
from flask import Flask, Blueprint
from flask_sqlalchemy.model import Model
from accounting.utils.user import AbstractUserUtils
@ -64,6 +63,9 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import account
account.init_app(app, bp)
from . import currency
currency.init_app(app, bp)
from .utils.next_url import append_next, inherit_next, or_next
bp.add_app_template_filter(append_next, "append_next")
bp.add_app_template_filter(inherit_next, "inherit_next")

View File

@ -32,7 +32,7 @@ from accounting.utils.user import get_current_user_pk
class BaseAccountExists:
"""The validator to check if the base account code exists."""
"""The validator to check if the base account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "":
@ -42,13 +42,25 @@ class BaseAccountExists:
"The base account does not exist."))
class BaseAccountAvailable:
"""The validator to check if the base account is available."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "":
return
if len(field.data) != 4:
raise ValidationError(lazy_gettext(
"The base account is not available."))
class AccountForm(FlaskForm):
"""The form to create or edit an account."""
base_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext("Please select the base account.")),
BaseAccountExists()])
BaseAccountExists(),
BaseAccountAvailable()])
"""The code of the base account."""
title = StringField(
filters=[strip_text],

View File

@ -38,7 +38,7 @@ bp: Blueprint = Blueprint("account", __name__)
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_accounts() -> str:
"""Lists the base accounts.
"""Lists the accounts.
:return: The account list.
"""
@ -139,7 +139,7 @@ def update_account(account: Account) -> redirect:
account=account)))
with db.session.no_autoflush:
form.populate_obj(account)
if not db.session.is_modified(account):
if not account.is_modified:
flash(lazy_gettext("The account was not modified."), "success")
return redirect(inherit_next(url_for("accounting.account.detail",
account=account)))
@ -159,9 +159,7 @@ def delete_account(account: Account) -> redirect:
:return: The redirection to the account list on success, or the account
detail on error.
"""
for l10n in account.l10n:
db.session.delete(l10n)
db.session.delete(account)
account.delete()
sort_accounts_in(account.base_code, account.id)
db.session.commit()
flash(lazy_gettext("The account is deleted successfully."), "success")

View File

@ -0,0 +1,38 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The currency management.
"""
from flask import Flask, Blueprint
def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:param app: The Flask application.
:return: None.
"""
from .converters import CurrencyConverter
app.url_map.converters["currency"] = CurrencyConverter
from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -0,0 +1,78 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The console commands for the currency management.
"""
import os
import click
from flask.cli import with_appcontext
from accounting.database import db
from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import has_user, get_user_pk
CurrencyData = tuple[str, str, str, str]
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None:
"""Initializes the currencies."""
data: list[CurrencyData] = [
("TWD", "New Taiwan dollar", "新臺幣", "新台币"),
("USD", "United States dollar", "美元", "美元"),
]
creator_pk: int = get_user_pk(username)
existing: list[Currency] = Currency.query.all()
existing_code: set[str] = {x.code for x in existing}
to_add: list[CurrencyData] = [x for x in data if x[0] not in existing_code]
if len(to_add) == 0:
click.echo("No more currency to add.")
return
db.session.bulk_save_objects(
[Currency(code=x[0], name_l10n=x[1],
created_by_id=creator_pk, updated_by_id=creator_pk)
for x in data])
db.session.bulk_save_objects(
[CurrencyL10n(currency_code=x[0], locale=y[0], name=y[1])
for x in data for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")

View File

@ -0,0 +1,48 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The path converters for the currency management.
"""
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.database import db
from accounting.models import Currency
class CurrencyConverter(BaseConverter):
"""The currency converter to convert the currency code and to the
corresponding currency in the routes."""
def to_python(self, value: str) -> Currency:
"""Converts a currency code to a currency.
:param value: The currency code.
:return: The corresponding currency.
"""
currency: Currency | None = db.session.get(Currency, value)
if currency is None:
abort(404)
return currency
def to_url(self, value: Currency) -> str:
"""Converts a currency to its code.
:param value: The currency.
:return: The code.
"""
return value.code

View File

@ -0,0 +1,93 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The forms for the currency management.
"""
from __future__ import annotations
import sqlalchemy as sa
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf
from accounting.database import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
class CurrencyForm(FlaskForm):
"""The form to create or edit a currency."""
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
"""The reserved codes that are not available."""
class CodeUnique:
"""The validator to check if the code is unique."""
def __call__(self, form: CurrencyForm, field: StringField) -> None:
if field.data == "":
return
if form.obj_code is not None and form.obj_code == field.data:
return
if db.session.get(Currency, field.data) is not None:
raise ValidationError(lazy_gettext(
"Code conflicts with another currency."))
code = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
Regexp(r"^[A-Z]{3}$",
message=lazy_gettext(
"Code can only be composed of 3 upper-cased"
" letters.")),
NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
"This code is not available.")),
CodeUnique()])
"""The code. It may not conflict with another currency."""
name = StringField(
filters=[strip_text],
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
"""The name."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.obj_code: str | None = None
"""The current code of the currency, or None when adding a new
currency."""
def populate_obj(self, obj: Currency) -> None:
"""Populates the form data into a currency object.
:param obj: The currency object.
:return: None.
"""
is_new: bool = obj.code is None
obj.code = self.code.data
obj.name = self.name.data
if is_new:
current_user_pk: int = get_current_user_pk()
obj.created_by_id = current_user_pk
obj.updated_by_id = current_user_pk
def post_update(self, obj) -> None:
"""The post-processing after the update.
:return: None
"""
current_user_pk: int = get_current_user_pk()
obj.updated_by_id = current_user_pk
obj.updated_at = sa.func.now()

View File

@ -0,0 +1,44 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The currency query.
"""
import sqlalchemy as sa
from flask import request
from accounting.models import Currency, CurrencyL10n
from accounting.utils.query import parse_query_keywords
def get_currency_query() -> list[Currency]:
"""Returns the base accounts, optionally filtered by the query.
:return: The base accounts.
"""
keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0:
return Currency.query.order_by(Currency.code).all()
conditions: list[sa.BinaryExpression] = []
for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\
.filter(CurrencyL10n.name.contains(k)).all()
l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.contains(k),
Currency.name_l10n.contains(k),
Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\
.order_by(Currency.code).all()

View File

@ -0,0 +1,178 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The views for the currency management.
"""
from urllib.parse import urlencode, parse_qsl
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting.database import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.next_url import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view, can_edit
from .forms import CurrencyForm
bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management."""
api_bp: Blueprint = Blueprint("currency-api", __name__)
"""The view blueprint for the currency management API."""
@bp.get("", endpoint="list")
@has_permission(can_view)
def list_currencies() -> str:
"""Lists the currencies.
:return: The currency list.
"""
from .query import get_currency_query
currencies: list[Currency] = get_currency_query()
pagination: Pagination = Pagination[Currency](currencies)
return render_template("accounting/currency/list.html",
list=pagination.list, pagination=pagination)
@bp.get("/create", endpoint="create")
@has_permission(can_edit)
def show_add_currency_form() -> str:
"""Shows the form to add a currency.
:return: The form to add a currency.
"""
if "form" in session:
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = CurrencyForm()
return render_template("accounting/currency/create.html",
form=form)
@bp.post("/store", endpoint="store")
@has_permission(can_edit)
def add_currency() -> redirect:
"""Adds a currency.
:return: The redirection to the currency detail on success, or the currency
creation form on error.
"""
form = CurrencyForm(request.form)
if not form.validate():
for key in form.errors:
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.create")))
currency: Currency = Currency()
form.populate_obj(currency)
db.session.add(currency)
db.session.commit()
flash(lazy_gettext("The currency is added successfully"), "success")
return redirect(inherit_next(url_for("accounting.currency.detail",
currency=currency)))
@bp.get("/<currency:currency>", endpoint="detail")
@has_permission(can_view)
def show_currency_detail(currency: Currency) -> str:
"""Shows the currency detail.
:param currency: The currency.
:return: The detail.
"""
return render_template("accounting/currency/detail.html", obj=currency)
@bp.get("/<currency:currency>/edit", endpoint="edit")
@has_permission(can_edit)
def show_currency_edit_form(currency: Currency) -> str:
"""Shows the form to edit a currency.
:param currency: The currency.
:return: The form to edit the currency.
"""
form: CurrencyForm
if "form" in session:
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
del session["form"]
form.validate()
else:
form = CurrencyForm(obj=currency)
return render_template("accounting/currency/edit.html",
currency=currency, form=form)
@bp.post("/<currency:currency>/update", endpoint="update")
@has_permission(can_edit)
def update_currency(currency: Currency) -> redirect:
"""Updates a currency.
:param currency: The currency.
:return: The redirection to the currency detail on success, or the currency
edit form on error.
"""
form = CurrencyForm(request.form)
form.obj_code = currency.code
if not form.validate():
for key in form.errors:
for error in form.errors[key]:
flash(error, "error")
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.currency.edit",
currency=currency)))
with db.session.no_autoflush:
form.populate_obj(currency)
if not currency.is_modified:
flash(lazy_gettext("The currency was not modified."), "success")
return redirect(inherit_next(url_for("accounting.currency.detail",
currency=currency)))
form.post_update(currency)
db.session.commit()
flash(lazy_gettext("The currency is updated successfully."), "success")
return redirect(inherit_next(url_for("accounting.currency.detail",
currency=currency)))
@bp.post("/<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect:
"""Deletes a currency.
:param currency: The currency.
:return: The redirection to the currency list on success, or the currency
detail on error.
"""
currency.delete()
db.session.commit()
flash(lazy_gettext("The currency is deleted successfully."), "success")
return redirect(or_next(url_for("accounting.currency.list")))
@api_bp.get("/exists-code", endpoint="exists")
@has_permission(can_edit)
def exists_code() -> dict[str, bool]:
"""Validates whether a currency code exists.
:return: Whether the currency code exists.
"""
return {"exists": db.session.get(Currency, request.args["q"]) is not None}

View File

@ -187,16 +187,14 @@ class Account(db.Model):
if l10n.locale == current_locale:
l10n.title = value
return
self.l10n.append(AccountL10n(
locale=current_locale, title=value))
self.l10n.append(AccountL10n(locale=current_locale, title=value))
@classmethod
def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an accounting account by its code.
"""Finds an account by its code.
:param code: The code.
:return: The accounting account, or None if this account does not
exist.
:return: The account, or None if this account does not exist.
"""
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
if m is None:
@ -293,8 +291,21 @@ class Account(db.Model):
"""
return cls.find_by_code(cls.__NET_CHANGE)
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes this accounting account.
"""Deletes this account.
:return: None.
"""
@ -306,11 +317,128 @@ class Account(db.Model):
class AccountL10n(db.Model):
"""A localized account title."""
__tablename__ = "accounting_accounts_l10n"
"""The table name."""
account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The account ID."""
account = db.relationship(Account, back_populates="l10n")
"""The account."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
title = db.Column(db.String, nullable=False)
db.UniqueConstraint(account_id, locale)
"""The localized title."""
class Currency(db.Model):
"""A currency."""
__tablename__ = "accounting_currencies"
"""The table name."""
code = db.Column(db.String, nullable=False, primary_key=True)
"""The code."""
name_l10n = db.Column("name", db.String, nullable=False)
"""The name."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""
l10n = db.relationship("CurrencyL10n", back_populates="currency",
lazy=False)
"""The localized names."""
def __str__(self) -> str:
"""Returns the string representation of the currency.
:return: The string representation of the currency.
"""
return F"{self.name} ({self.code})"
@property
def name(self) -> str:
"""Returns the name in the current locale.
:return: The name in the current locale.
"""
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
return self.name_l10n
for l10n in self.l10n:
if l10n.locale == current_locale:
return l10n.name
return self.name_l10n
@name.setter
def name(self, value: str) -> None:
"""Sets the name in the current locale.
:param value: The new name.
:return: None.
"""
if self.name_l10n is None:
self.name_l10n = value
return
current_locale = str(get_locale())
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
self.name_l10n = value
return
for l10n in self.l10n:
if l10n.locale == current_locale:
l10n.name = value
return
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None:
"""Deletes the currency.
:return: None.
"""
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
cls: t.Type[t.Self] = self.__class__
cls.query.filter(cls.code == self.code).delete()
class CurrencyL10n(db.Model):
"""A localized currency name."""
__tablename__ = "accounting_currencies_l10n"
"""The table name."""
currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The currency code."""
currency = db.relationship(Currency, back_populates="l10n")
"""The currency."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
name = db.Column(db.String, nullable=False)
"""The localized name."""

View File

@ -56,6 +56,23 @@
overflow-y: scroll;
}
/** The currency management. */
.currency {
padding: 2em 1.5em;
margin: 1em;
background-color: #E9ECEF;
border-radius: 0.3em;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.currency .currency-name {
font-size: 1.8rem;
font-weight: bolder;
}
.currency .currency-code {
font-size: 1.4rem;
color: #373b3e;
}
/* The Material Design text field (floating form control in Bootstrap) */
.material-text-field {
position: relative;

View File

@ -0,0 +1,174 @@
/* The Mia! Accounting Flask Project
* currency-form.js: The JavaScript for the currency form
*/
/* Copyright (c) 2023 imacat.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/6
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
document.getElementById("currency-code")
.onchange = validateCode;
document.getElementById("currency-name")
.onchange = validateName;
document.getElementById("currency-form")
.onsubmit = validateForm;
});
/**
* The asynchronous validation result
* @type {object}
* @private
*/
let isAsyncValid = {};
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
isAsyncValid = {
"code": false,
"_sync": false,
};
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateName() && isValid;
isAsyncValid["_sync"] = isValid;
submitFormIfAllAsyncValid();
return false;
}
/**
* Submits the form if the whole form passed the asynchronous
* validations.
*
* @private
*/
function submitFormIfAllAsyncValid() {
let isValid = true;
Object.keys(isAsyncValid).forEach(function (key) {
isValid = isAsyncValid[key] && isValid;
});
if (isValid) {
document.getElementById("currency-form").submit()
}
}
/**
* Validates the code.
*
* @param changeEvent {Event} the change event, if invoked from onchange
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateCode(changeEvent = null) {
const key = "code";
const isSubmission = changeEvent === null;
let hasAsyncValidation = false;
const field = document.getElementById("currency-code");
const error = document.getElementById("currency-code-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the code.");
return false;
}
const blocklist = JSON.parse(field.dataset.blocklist);
if (blocklist.includes(field.value)) {
field.classList.add("is-invalid");
error.innerText = A_("This code is not available.");
return false;
}
if (!field.value.match(/^[A-Z]{3}$/)) {
field.classList.add("is-invalid");
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
return false;
}
const original = field.dataset.original;
if (original === "" || field.value !== original) {
hasAsyncValidation = true;
validateAsyncCodeIsDuplicated(isSubmission, key);
}
if (!hasAsyncValidation) {
isAsyncValid[key] = true;
field.classList.remove("is-invalid");
error.innerText = "";
}
return true;
}
/**
* Validates asynchronously whether the code is duplicated.
* The boolean validation result is stored in isAsyncValid[key].
*
* @param isSubmission {boolean} whether this is invoked from a form submission
* @param key {string} the key to store the result in isAsyncValid
* @private
*/
function validateAsyncCodeIsDuplicated(isSubmission, key) {
const field = document.getElementById("currency-code");
const error = document.getElementById("currency-code-error");
const url = field.dataset.existsUrl;
const onLoad = function () {
if (this.status === 200) {
const result = JSON.parse(this.responseText);
if (result["exists"]) {
field.classList.add("is-invalid");
error.innerText = _("Code conflicts with another currency.");
if (isSubmission) {
isAsyncValid[key] = false;
}
return;
}
field.classList.remove("is-invalid");
error.innerText = "";
if (isSubmission) {
isAsyncValid[key] = true;
submitFormIfAllAsyncValid();
}
}
};
const request = new XMLHttpRequest();
request.onload = onLoad;
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
request.send();
}
/**
* Validates the name.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateName() {
const field = document.getElementById("currency-name");
const error = document.getElementById("currency-name-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the name.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -28,7 +28,7 @@ First written: 2023/1/30
<div class="btn-group mb-2">
{% if can_edit_accounting() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
<i class="fa-solid fa-user-plus"></i>
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The currency creation form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/currency/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}

View File

@ -0,0 +1,90 @@
{#
The Mia! Accounting Flask Project
detail.html: The currency detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if can_edit_accounting() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
{% endif %}
{% if can_edit_accounting() %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if can_edit_accounting() %}
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if can_edit_accounting() %}
<form id="delete-form" action="{{ url_for("accounting.currency.delete", currency=obj) }}" 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 %}
<div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Currency Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this currency?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="currency col-sm-6">
<div class="currency-name">{{ obj.name }}</div>
<div class="currency-code">{{ obj.code }}</div>
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,30 @@
{#
The Mia! Accounting Flask Project
edit.html: The currency edit form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/currency/include/form.html" %}
{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %}
{% block original_code %}{{ currency.code }}{% endblock %}

View File

@ -0,0 +1,68 @@
{#
The Mia! Accounting Flask Project
form.html: The currency form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group btn-actions mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<form id="currency-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="currency-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
<label class="form-label" for="currency-code">{{ A_("Code") }}</label>
<div id="currency-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="currency-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
<label class="form-label" for="currency-name">{{ A_("Name") }}</label>
<div id="currency-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,60 @@
{#
The Mia! Accounting Flask Project
list.html: The currency list
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/6
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
{% if can_edit_accounting() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search">
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="search-input" class="search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|append_next }}">
{{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -38,6 +38,12 @@ First written: 2023/1/26
{{ A_("Base Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
<i class="fa-solid fa-list"></i>
{{ A_("Currencies") }}
</a>
</li>
</ul>
</li>
{% endif %}

View File

@ -14,10 +14,10 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The test for the account management.
"""
import time
import unittest
import httpx
@ -26,8 +26,40 @@ from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import UserClient, get_user_client
from test_site import create_app
from testlib import get_client, set_locale
class AccountData:
"""The account data."""
def __init__(self, base_code: str, no: int, title: str):
"""Constructs the account data.
:param base_code: The base code.
:param no: The number.
:param title: The title.
"""
self.base_code: str = base_code
"""The base code."""
self.no: int = no
"""The number."""
self.title: str = title
"""The title."""
self.code: str = f"{self.base_code}-{self.no:03d}"
"""The code."""
cash: AccountData = AccountData("1111", 1, "Cash")
"""The cash account."""
bank: AccountData = AccountData("1113", 1, "Bank")
"""The bank account."""
stock: AccountData = AccountData("1121", 1, "Stock")
"""The stock account."""
loan: AccountData = AccountData("2112", 1, "Loan")
"""The loan account."""
PREFIX: str = "/accounting/accounts"
"""The URL prefix of the currency management."""
class AccountCommandTestCase(unittest.TestCase):
@ -109,26 +141,24 @@ class AccountTestCase(unittest.TestCase):
Account.query.delete()
db.session.commit()
editor: UserClient = get_user_client(self, self.app, "editor")
self.client: httpx.Client = editor.client
self.csrf_token: str = editor.csrf_token
self.client, self.csrf_token = get_client(self, self.app, "editor")
response: httpx.Response
response = self.client.post("/accounting/accounts/store",
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "1111 title"})
"base_code": cash.base_code,
"title": cash.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
f"{PREFIX}/{cash.code}")
response = self.client.post("/accounting/accounts/store",
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "1112 title"})
"base_code": bank.base_code,
"title": bank.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-001")
f"{PREFIX}/{bank.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
@ -136,47 +166,47 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self, self.app, "nobody")
response: httpx.Response
nobody: UserClient = get_user_client(self, self.app, "nobody")
response = nobody.client.get("/accounting/accounts")
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/1111-001")
response = client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/create")
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = nobody.client.post("/accounting/accounts/store",
data={"csrf_token": nobody.csrf_token,
"base_code": "1113",
"title": "1113 title"})
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/1111-001/edit")
response = client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 403)
response = nobody.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": nobody.csrf_token,
"base_code": "1111",
"title": "1111 title #2"})
response = client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 403)
response = nobody.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": nobody.csrf_token})
response = client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/accounts/bases/1111")
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 403)
with self.app.app_context():
account_id: int = Account.find_by_code("1112-001").id
bank_id: int = Account.find_by_code(bank.code).id
response = nobody.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": nobody.csrf_token,
"next": "/next",
f"{account_id}-no": "5"})
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token,
"next": "/next",
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
@ -185,47 +215,47 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self, self.app, "viewer")
response: httpx.Response
viewer: UserClient = get_user_client(self, self.app, "viewer")
response = viewer.client.get("/accounting/accounts")
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = viewer.client.get("/accounting/accounts/1111-001")
response = client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 200)
response = viewer.client.get("/accounting/accounts/create")
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = viewer.client.post("/accounting/accounts/store",
data={"csrf_token": viewer.csrf_token,
"base_code": "1113",
"title": "1113 title"})
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 403)
response = viewer.client.get("/accounting/accounts/1111-001/edit")
response = client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 403)
response = viewer.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": viewer.csrf_token,
"base_code": "1111",
"title": "1111 title #2"})
response = client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 403)
response = viewer.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": viewer.csrf_token})
response = client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
response = viewer.client.get("/accounting/accounts/bases/1111")
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
account_id: int = Account.find_by_code("1112-001").id
bank_id: int = Account.find_by_code(bank.code).id
response = viewer.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": viewer.csrf_token,
"next": "/next",
f"{account_id}-no": "5"})
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": csrf_token,
"next": "/next",
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
@ -236,107 +266,382 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account
response: httpx.Response
response = self.client.get("/accounting/accounts")
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get("/accounting/accounts/1111-001")
response = self.client.get(f"{PREFIX}/{cash.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get("/accounting/accounts/create")
response = self.client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post("/accounting/accounts/store",
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1113",
"title": "1113 title"})
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1113-001")
f"{PREFIX}/{stock.code}")
response = self.client.get("/accounting/accounts/1111-001/edit")
response = self.client.get(f"{PREFIX}/{cash.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post("/accounting/accounts/1111-001/update",
response = self.client.post(f"{PREFIX}/{cash.code}/update",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "1111 title #2"})
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
response = self.client.post("/accounting/accounts/1111-001/delete",
response = self.client.post(f"{PREFIX}/{cash.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts")
self.assertEqual(response.headers["Location"], PREFIX)
response = self.client.get("/accounting/accounts/bases/1111")
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
account_id: int = Account.find_by_code("1112-001").id
bank_id: int = Account.find_by_code(bank.code).id
response = self.client.post("/accounting/accounts/bases/1112",
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{account_id}-no": "5"})
f"{bank_id}-no": "5"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/next")
def test_change_base(self) -> None:
"""Tests to change the base account.
def test_add(self) -> None:
"""Tests to add the currencies.
:return: None.
"""
from accounting.database import db
from accounting.models import Account
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{stock.code}"
response: httpx.Response
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title #1"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-002")
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title #1"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-003")
# Missing CSRF token
response = self.client.post(store_uri,
data={"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
response = self.client.post("/accounting/accounts/store",
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
# Empty base account code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "Title #1"})
"base_code": " ",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Non-existing base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Unavailable base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {stock.base_code} ",
"title": f" {stock.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Success under the same base
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-002")
f"{PREFIX}/{stock.base_code}-002")
# Success under the same base, with order in a mess.
with self.app.app_context():
stock_2: Account = Account.find_by_code(f"{stock.base_code}-002")
stock_2.no = 66
db.session.commit()
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-067")
with self.app.app_context():
id_1: int = Account.find_by_code("1111-001").id
id_2: int = Account.find_by_code("1111-002").id
id_3: int = Account.find_by_code("1111-003").id
id_4: int = Account.find_by_code("1112-001").id
id_5: int = Account.find_by_code("1112-002").id
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code, stock.code,
f"{stock.base_code}-066",
f"{stock.base_code}-067"})
response = self.client.post("/accounting/accounts/1111-002/update",
stock_account: Account = Account.find_by_code(stock.code)
self.assertEqual(stock_account.base_code, stock.base_code)
self.assertEqual(stock_account.title_l10n, stock.title)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
edit_uri: str = f"{PREFIX}/{cash.code}/edit"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_c_uri: str = f"{PREFIX}/{stock.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "Account #1"})
"base_code": f" {cash.base_code} ",
"title": f" {cash.title}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-003")
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
self.assertEqual(db.session.get(Account, id_1).code, "1111-001")
self.assertEqual(db.session.get(Account, id_2).code, "1112-003")
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
self.assertEqual(db.session.get(Account, id_4).code, "1112-001")
self.assertEqual(db.session.get(Account, id_5).code, "1112-002")
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.base_code, cash.base_code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-1")
# Empty base account code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Unavailable base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
"""Tests that the data is not modified.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
time.sleep(1)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ",
"title": f" {cash.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
self.assertEqual(cash_account.created_at, cash_account.updated_at)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertIsNotNone(cash_account)
self.assertNotEqual(cash_account.created_at,
cash_account.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
:return: None.
"""
from accounting.models import Account
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self, self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username, editor_username)
self.assertEqual(cash_account.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.created_by.username,
editor_username)
self.assertEqual(cash_account.updated_by.username,
editor2_username)
def test_l10n(self) -> None:
"""Tests the localization.
:return: None
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
update_uri: str = f"{PREFIX}/{cash.code}/update"
response: httpx.Response
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual(cash_account.l10n, [])
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, cash.title)
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": cash.base_code,
"title": f"{cash.title}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code)
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
{("zh_Hant", f"{cash.title}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
delete_uri: str = f"{PREFIX}/{cash.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{bank.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 404)
def test_reorder(self) -> None:
"""Tests to reorder the accounts under a same base account.
@ -348,13 +653,13 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response
for i in range(2, 6):
response = self.client.post("/accounting/accounts/store",
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"/accounting/accounts/1111-00{i}")
f"{PREFIX}/1111-00{i}")
# Normal reorder
with self.app.app_context():
@ -364,7 +669,7 @@ class AccountTestCase(unittest.TestCase):
id_4: int = Account.find_by_code("1111-004").id
id_5: int = Account.find_by_code("1111-005").id
response = self.client.post("/accounting/accounts/bases/1111",
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{id_1}-no": "4",
@ -391,7 +696,7 @@ class AccountTestCase(unittest.TestCase):
db.session.get(Account, id_5).no = 9
db.session.commit()
response = self.client.post("/accounting/accounts/bases/1111",
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": "/next",
f"{id_2}-no": "3a",

View File

@ -14,7 +14,6 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The test for the base account management.
"""
@ -25,8 +24,8 @@ from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import UserClient, get_user_client
from test_site import create_app
from testlib import get_client
class BaseAccountCommandTestCase(unittest.TestCase):
@ -88,22 +87,18 @@ class BaseAccountTestCase(unittest.TestCase):
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
self.viewer: UserClient = get_user_client(self, self.app, "viewer")
self.editor: UserClient = get_user_client(self, self.app, "editor")
self.nobody: UserClient = get_user_client(self, self.app, "nobody")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self, self.app, "nobody")
response: httpx.Response
nobody: UserClient = get_user_client(self, self.app, "nobody")
response = nobody.client.get("/accounting/base-accounts")
response = client.get("/accounting/base-accounts")
self.assertEqual(response.status_code, 403)
response = nobody.client.get("/accounting/base-accounts/1111")
response = client.get("/accounting/base-accounts/1111")
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
@ -111,13 +106,13 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self, self.app, "viewer")
response: httpx.Response
viewer: UserClient = get_user_client(self, self.app, "viewer")
response = viewer.client.get("/accounting/base-accounts")
response = client.get("/accounting/base-accounts")
self.assertEqual(response.status_code, 200)
response = viewer.client.get("/accounting/base-accounts/1111")
response = client.get("/accounting/base-accounts/1111")
self.assertEqual(response.status_code, 200)
def test_editor(self) -> None:
@ -125,11 +120,11 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self, self.app, "editor")
response: httpx.Response
editor: UserClient = get_user_client(self, self.app, "editor")
response = editor.client.get("/accounting/base-accounts")
response = client.get("/accounting/base-accounts")
self.assertEqual(response.status_code, 200)
response = editor.client.get("/accounting/base-accounts/1111")
response = client.get("/accounting/base-accounts/1111")
self.assertEqual(response.status_code, 200)

574
tests/test_currency.py Normal file
View File

@ -0,0 +1,574 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The test for the currency management.
"""
import time
import unittest
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import create_app
from testlib import get_client, set_locale
class CurrencyData:
"""The currency data."""
def __init__(self, code: str, name: str):
"""Constructs the currency data.
:param code: The code.
:param name: The name.
"""
self.code: str = code
"""The code."""
self.name: str = name
"""The name."""
zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
"""The first test currency."""
zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
"""The second test currency."""
zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
"""The third test currency."""
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
"""The fourth test currency."""
PREFIX: str = "/accounting/currencies"
"""The URL prefix of the currency management."""
class CurrencyCommandTestCase(unittest.TestCase):
"""The account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.database import db
from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-currencies" console command.
:return: None.
"""
from accounting.models import Currency, CurrencyL10n
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(
args=["accounting-init-currencies", "-u", "editor"])
self.assertEqual(result.exit_code, 0)
currencies: list[Currency] = Currency.query.all()
l10n: list[CurrencyL10n] = CurrencyL10n.query.all()
self.assertEqual(len(currencies), 2)
self.assertEqual(len(l10n), 2 * 2)
l10n_keys: set[str] = {f"{x.currency_code}-{x.locale}" for x in l10n}
for currency in currencies:
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
class CurrencyTestCase(unittest.TestCase):
"""The currency test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_app(is_testing=True)
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.database import db
from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
self.client, self.csrf_token = get_client(self, self.app, "editor")
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zza.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzb.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self, self.app, "nobody")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
client, csrf_token = get_client(self, self.app, "viewer")
response: httpx.Response
response = client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store",
data={"csrf_token": csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": csrf_token})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
"""Test the permission as editor.
:return: None.
"""
response: httpx.Response
response = self.client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{zza.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
response = self.client.get(f"{PREFIX}/{zza.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{zza.code}/update",
data={"csrf_token": self.csrf_token,
"code": zzd.code,
"name": zzd.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
def test_add(self) -> None:
"""Tests to add the currencies.
:return: None.
"""
from accounting.models import Currency
from test_site import db
create_uri: str = f"{PREFIX}/create"
store_uri: str = f"{PREFIX}/store"
detail_uri: str = f"{PREFIX}/{zzc.code}"
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 400)
# Empty code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Bad code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " zzc ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zzc.code} ",
"name": f" {zzc.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Duplicated code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code, zzc.code})
zzc_currency: Currency = db.session.get(Currency, zzc.code)
self.assertEqual(zzc_currency.code, zzc.code)
self.assertEqual(zzc_currency.name_l10n, zzc.name)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
:return: None.
"""
from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}"
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
update_uri: str = f"{PREFIX}/{zza.code}/update"
detail_c_uri: str = f"{PREFIX}/{zzc.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.code, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1")
# Empty code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Bad code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": "abc/def",
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Duplicated code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzb.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zzc.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
"""Tests that the data is not modified.
:return: None.
"""
from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
time.sleep(1)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {zza.code} ",
"name": f" {zza.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
self.assertEqual(zza_currency.created_at, zza_currency.updated_at)
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": zzc.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertIsNotNone(zza_currency)
self.assertNotEqual(zza_currency.created_at,
zza_currency.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
:return: None.
"""
from accounting.models import Currency
from test_site import db
editor_username, editor2_username = "editor", "editor2"
client, csrf_token = get_client(self, self.app, editor2_username)
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor_username)
response = client.post(update_uri,
data={"csrf_token": csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.created_by.username, editor_username)
self.assertEqual(zza_currency.updated_by.username, editor2_username)
def test_l10n(self) -> None:
"""Tests the localization.
:return: None
"""
from accounting.models import Currency
from test_site import db
detail_uri: str = f"{PREFIX}/{zza.code}"
update_uri: str = f"{PREFIX}/{zza.code}/update"
response: httpx.Response
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual(zza_currency.l10n, [])
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, zza.name)
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant")})
set_locale(self, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": zza.code,
"name": f"{zza.name}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
zza_currency: Currency = db.session.get(Currency, zza.code)
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
{("zh_Hant", f"{zza.name}-zh_Hant-2")})
def test_delete(self) -> None:
"""Tests to delete a currency.
:return: None.
"""
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{zza.code}"
delete_uri: str = f"{PREFIX}/{zza.code}/delete"
list_uri: str = PREFIX
response: httpx.Response
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zza.code, zzb.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{zzb.code})
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 404)

View File

@ -91,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask:
return user.id
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor"]
and auth.current_user().username in ["viewer", "editor", "editor2"]
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username == "editor"
and auth.current_user().username in ["editor", "editor2"]
accounting.init_app(app, user_utils=UserUtils(),
can_view_func=can_view, can_edit_func=can_edit)
@ -106,7 +106,7 @@ def init_db_command() -> None:
"""Initializes the database."""
db.create_all()
from .auth import User
for username in ["viewer", "editor", "nobody"]:
for username in ["viewer", "editor", "editor2", "nobody"]:
if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username))
db.session.commit()

View File

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

View File

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

View File

@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
"POT-Creation-Date: 2023-02-06 23:25+0800\n"
"PO-Revision-Date: 2023-02-06 23:26+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -51,6 +51,10 @@ msgid "Editor"
msgstr "記帳者"
#: tests/test_site/templates/login.html:32
msgid "Editor2"
msgstr "記帳者2"
#: tests/test_site/templates/login.html:33
msgid "Nobody"
msgstr "沒有權限者"

View File

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