Compare commits

..

No commits in common. "16e2a146db3af685fda24865b98a46384aee6e66" and "50f8f06687df7111633d10c4367581d765757036" have entirely different histories.

27 changed files with 163 additions and 2141 deletions

View File

@ -20,6 +20,7 @@
import typing as t import typing as t
from flask import Flask, Blueprint from flask import Flask, Blueprint
from flask_sqlalchemy.model import Model
from accounting.utils.user import AbstractUserUtils from accounting.utils.user import AbstractUserUtils
@ -63,9 +64,6 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
from . import account from . import account
account.init_app(app, bp) account.init_app(app, bp)
from . import currency
currency.init_app(app, bp)
from .utils.next_url import append_next, inherit_next, or_next 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(append_next, "append_next")
bp.add_app_template_filter(inherit_next, "inherit_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: class BaseAccountExists:
"""The validator to check if the base account exists.""" """The validator to check if the base account code exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None: def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "": if field.data == "":
@ -42,25 +42,13 @@ class BaseAccountExists:
"The base account does not exist.")) "The base account does not exist."))
class BaseAccountAvailable:
"""The validator to check if the base account is available."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data == "":
return
if len(field.data) != 4:
raise ValidationError(lazy_gettext(
"The base account is not available."))
class AccountForm(FlaskForm): class AccountForm(FlaskForm):
"""The form to create or edit an account.""" """The form to create or edit an account."""
base_code = StringField( base_code = StringField(
filters=[strip_text], filters=[strip_text],
validators=[ validators=[
DataRequired(lazy_gettext("Please select the base account.")), DataRequired(lazy_gettext("Please select the base account.")),
BaseAccountExists(), BaseAccountExists()])
BaseAccountAvailable()])
"""The code of the base account.""" """The code of the base account."""
title = StringField( title = StringField(
filters=[strip_text], filters=[strip_text],

View File

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

View File

@ -1,38 +0,0 @@
# 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

@ -1,78 +0,0 @@
# 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

@ -1,48 +0,0 @@
# 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

@ -1,93 +0,0 @@
# 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

@ -1,44 +0,0 @@
# 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

@ -1,178 +0,0 @@
# 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,14 +187,16 @@ class Account(db.Model):
if l10n.locale == current_locale: if l10n.locale == current_locale:
l10n.title = value l10n.title = value
return return
self.l10n.append(AccountL10n(locale=current_locale, title=value)) self.l10n.append(AccountL10n(
locale=current_locale, title=value))
@classmethod @classmethod
def find_by_code(cls, code: str) -> t.Self | None: def find_by_code(cls, code: str) -> t.Self | None:
"""Finds an account by its code. """Finds an accounting account by its code.
:param code: The code. :param code: The code.
:return: The account, or None if this account does not exist. :return: The accounting account, or None if this account does not
exist.
""" """
m = re.match("^([1-9]{4})-([0-9]{3})$", code) m = re.match("^([1-9]{4})-([0-9]{3})$", code)
if m is None: if m is None:
@ -291,21 +293,8 @@ class Account(db.Model):
""" """
return cls.find_by_code(cls.__NET_CHANGE) return cls.find_by_code(cls.__NET_CHANGE)
@property
def is_modified(self) -> bool:
"""Returns whether a product account was modified.
:return: True if modified, or False otherwise.
"""
if db.session.is_modified(self):
return True
for l10n in self.l10n:
if db.session.is_modified(l10n):
return True
return False
def delete(self) -> None: def delete(self) -> None:
"""Deletes this account. """Deletes this accounting account.
:return: None. :return: None.
""" """
@ -317,128 +306,11 @@ 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"
"""The table name."""
account_id = db.Column(db.Integer, account_id = db.Column(db.Integer,
db.ForeignKey(Account.id, onupdate="CASCADE", db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) nullable=False, primary_key=True)
"""The account ID."""
account = db.relationship(Account, back_populates="l10n") account = db.relationship(Account, back_populates="l10n")
"""The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
title = db.Column(db.String, nullable=False) title = db.Column(db.String, nullable=False)
"""The localized title.""" db.UniqueConstraint(account_id, locale)
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,23 +56,6 @@
overflow-y: scroll; 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) */ /* The Material Design text field (floating form control in Bootstrap) */
.material-text-field { .material-text-field {
position: relative; position: relative;

View File

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

View File

@ -1,28 +0,0 @@
{#
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

@ -1,90 +0,0 @@
{#
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

@ -1,30 +0,0 @@
{#
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

@ -1,68 +0,0 @@
{#
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

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

View File

@ -14,10 +14,10 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The test for the account management. """The test for the account management.
""" """
import time
import unittest import unittest
import httpx import httpx
@ -26,40 +26,8 @@ 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 UserClient, get_user_client
from test_site import create_app 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): class AccountCommandTestCase(unittest.TestCase):
@ -141,24 +109,26 @@ class AccountTestCase(unittest.TestCase):
Account.query.delete() Account.query.delete()
db.session.commit() db.session.commit()
self.client, self.csrf_token = get_client(self, self.app, "editor") 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: httpx.Response
response = self.client.post(f"{PREFIX}/store", response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": cash.base_code, "base_code": "1111",
"title": cash.title}) "title": "1111 title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{cash.code}") "/accounting/accounts/1111-001")
response = self.client.post(f"{PREFIX}/store", response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": bank.base_code, "base_code": "1112",
"title": bank.title}) "title": "1112 title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{bank.code}") "/accounting/accounts/1112-001")
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.
@ -166,47 +136,47 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
client, csrf_token = get_client(self, self.app, "nobody")
response: httpx.Response response: httpx.Response
nobody: UserClient = get_user_client(self, self.app, "nobody")
response = client.get(PREFIX) response = nobody.client.get("/accounting/accounts")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}") response = nobody.client.get("/accounting/accounts/1111-001")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/create") response = nobody.client.get("/accounting/accounts/create")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store", response = nobody.client.post("/accounting/accounts/store",
data={"csrf_token": csrf_token, data={"csrf_token": nobody.csrf_token,
"base_code": stock.base_code, "base_code": "1113",
"title": stock.title}) "title": "1113 title"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit") response = nobody.client.get("/accounting/accounts/1111-001/edit")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update", response = nobody.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": csrf_token, data={"csrf_token": nobody.csrf_token,
"base_code": cash.base_code, "base_code": "1111",
"title": f"{cash.title}-2"}) "title": "1111 title #2"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete", response = nobody.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": csrf_token}) data={"csrf_token": nobody.csrf_token})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}") response = nobody.client.get("/accounting/accounts/bases/1111")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
with self.app.app_context(): with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id account_id: int = Account.find_by_code("1112-001").id
response = client.post(f"{PREFIX}/bases/{bank.base_code}", response = nobody.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": csrf_token, data={"csrf_token": nobody.csrf_token,
"next": "/next", "next": "/next",
f"{bank_id}-no": "5"}) f"{account_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None: def test_viewer(self) -> None:
@ -215,47 +185,47 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
client, csrf_token = get_client(self, self.app, "viewer")
response: httpx.Response response: httpx.Response
viewer: UserClient = get_user_client(self, self.app, "viewer")
response = client.get(PREFIX) response = viewer.client.get("/accounting/accounts")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/{cash.code}") response = viewer.client.get("/accounting/accounts/1111-001")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.get(f"{PREFIX}/create") response = viewer.client.get("/accounting/accounts/create")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/store", response = viewer.client.post("/accounting/accounts/store",
data={"csrf_token": csrf_token, data={"csrf_token": viewer.csrf_token,
"base_code": stock.base_code, "base_code": "1113",
"title": stock.title}) "title": "1113 title"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/{cash.code}/edit") response = viewer.client.get("/accounting/accounts/1111-001/edit")
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/update", response = viewer.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": csrf_token, data={"csrf_token": viewer.csrf_token,
"base_code": cash.base_code, "base_code": "1111",
"title": f"{cash.title}-2"}) "title": "1111 title #2"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.post(f"{PREFIX}/{cash.code}/delete", response = viewer.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": csrf_token}) data={"csrf_token": viewer.csrf_token})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(f"{PREFIX}/bases/{cash.base_code}") response = viewer.client.get("/accounting/accounts/bases/1111")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
with self.app.app_context(): with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id account_id: int = Account.find_by_code("1112-001").id
response = client.post(f"{PREFIX}/bases/{bank.base_code}", response = viewer.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": csrf_token, data={"csrf_token": viewer.csrf_token,
"next": "/next", "next": "/next",
f"{bank_id}-no": "5"}) f"{account_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor(self) -> None: def test_editor(self) -> None:
@ -266,382 +236,107 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account from accounting.models import Account
response: httpx.Response response: httpx.Response
response = self.client.get(PREFIX) response = self.client.get("/accounting/accounts")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{cash.code}") response = self.client.get("/accounting/accounts/1111-001")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create") response = self.client.get("/accounting/accounts/create")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store", response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": stock.base_code, "base_code": "1113",
"title": stock.title}) "title": "1113 title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.code}") "/accounting/accounts/1113-001")
response = self.client.get(f"{PREFIX}/{cash.code}/edit") response = self.client.get("/accounting/accounts/1111-001/edit")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{cash.code}/update", response = self.client.post("/accounting/accounts/1111-001/update",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": cash.base_code, "base_code": "1111",
"title": f"{cash.title}-2"}) "title": "1111 title #2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}") self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
response = self.client.post(f"{PREFIX}/{cash.code}/delete", response = self.client.post("/accounting/accounts/1111-001/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX) self.assertEqual(response.headers["Location"],
"/accounting/accounts")
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}") response = self.client.get("/accounting/accounts/bases/1111")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
with self.app.app_context(): with self.app.app_context():
bank_id: int = Account.find_by_code(bank.code).id account_id: int = Account.find_by_code("1112-001").id
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}", response = self.client.post("/accounting/accounts/bases/1112",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": "/next",
f"{bank_id}-no": "5"}) f"{account_id}-no": "5"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], "/next") self.assertEqual(response.headers["Location"], "/next")
def test_add(self) -> None: def test_change_base(self) -> None:
"""Tests to add the currencies. """Tests to change the base account.
:return: None. :return: None.
""" """
from accounting.database import db from accounting.database import db
from accounting.models import Account 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: httpx.Response
with self.app.app_context(): response = self.client.post("/accounting/accounts/store",
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"base_code": stock.base_code,
"title": stock.title})
self.assertEqual(response.status_code, 400)
# Empty base account code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": " ", "base_code": "1111",
"title": stock.title}) "title": "Title #1"})
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.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-002") "/accounting/accounts/1111-002")
# Success under the same base, with order in a mess. response = self.client.post("/accounting/accounts/store",
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, data={"csrf_token": self.csrf_token,
"base_code": stock.base_code, "base_code": "1111",
"title": stock.title}) "title": "Title #1"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/{stock.base_code}-067") "/accounting/accounts/1111-003")
with self.app.app_context(): response = self.client.post("/accounting/accounts/store",
self.assertEqual({x.code for x in Account.query.all()},
{cash.code, bank.code, stock.code,
f"{stock.base_code}-066",
f"{stock.base_code}-067"})
stock_account: Account = Account.find_by_code(stock.code)
self.assertEqual(stock_account.base_code, stock.base_code)
self.assertEqual(stock_account.title_l10n, stock.title)
def test_basic_update(self) -> None:
"""Tests the basic rules to update a user.
:return: None.
"""
from accounting.models import Account
detail_uri: str = f"{PREFIX}/{cash.code}"
edit_uri: str = f"{PREFIX}/{cash.code}/edit"
update_uri: str = f"{PREFIX}/{cash.code}/update"
detail_c_uri: str = f"{PREFIX}/{stock.code}"
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": f" {cash.base_code} ", "base_code": "1112",
"title": f" {cash.title}-1 "}) "title": "Title #1"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-002")
with self.app.app_context(): with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code) id_1: int = Account.find_by_code("1111-001").id
self.assertEqual(cash_account.base_code, cash.base_code) id_2: int = Account.find_by_code("1111-002").id
self.assertEqual(cash_account.title_l10n, f"{cash.title}-1") 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
# Empty base account code response = self.client.post("/accounting/accounts/1111-002/update",
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": " ", "base_code": "1112",
"title": stock.title}) "title": "Account #1"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"],
"/accounting/accounts/1112-003")
# 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(): with self.app.app_context():
cash_account: Account = Account.find_by_code(cash.code) self.assertEqual(db.session.get(Account, id_1).code, "1111-001")
self.assertIsNotNone(cash_account) self.assertEqual(db.session.get(Account, id_2).code, "1112-003")
self.assertEqual(cash_account.created_at, cash_account.updated_at) self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
self.assertEqual(db.session.get(Account, id_4).code, "1112-001")
response = self.client.post(update_uri, self.assertEqual(db.session.get(Account, id_5).code, "1112-002")
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: def test_reorder(self) -> None:
"""Tests to reorder the accounts under a same base account. """Tests to reorder the accounts under a same base account.
@ -653,13 +348,13 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
for i in range(2, 6): for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store", response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"base_code": "1111", "base_code": "1111",
"title": "Title"}) "title": "Title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}") f"/accounting/accounts/1111-00{i}")
# Normal reorder # Normal reorder
with self.app.app_context(): with self.app.app_context():
@ -669,7 +364,7 @@ class AccountTestCase(unittest.TestCase):
id_4: int = Account.find_by_code("1111-004").id id_4: int = Account.find_by_code("1111-004").id
id_5: int = Account.find_by_code("1111-005").id id_5: int = Account.find_by_code("1111-005").id
response = self.client.post(f"{PREFIX}/bases/1111", response = self.client.post("/accounting/accounts/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": "/next",
f"{id_1}-no": "4", f"{id_1}-no": "4",
@ -696,7 +391,7 @@ class AccountTestCase(unittest.TestCase):
db.session.get(Account, id_5).no = 9 db.session.get(Account, id_5).no = 9
db.session.commit() db.session.commit()
response = self.client.post(f"{PREFIX}/bases/1111", response = self.client.post("/accounting/accounts/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": "/next",
f"{id_2}-no": "3a", f"{id_2}-no": "3a",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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