diff --git a/src/accounting/__init__.py b/src/accounting/__init__.py index 390ce9c..9dfed9b 100644 --- a/src/accounting/__init__.py +++ b/src/accounting/__init__.py @@ -63,6 +63,9 @@ def init_app(app: Flask, user_utils: AbstractUserUtils, from . import account account.init_app(app, bp) + from . import currency + currency.init_app(app, bp) + from .utils.next_url import append_next, inherit_next, or_next bp.add_app_template_filter(append_next, "append_next") bp.add_app_template_filter(inherit_next, "inherit_next") diff --git a/src/accounting/currency/__init__.py b/src/accounting/currency/__init__.py new file mode 100644 index 0000000..50407ff --- /dev/null +++ b/src/accounting/currency/__init__.py @@ -0,0 +1,38 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The currency management. + +""" +from flask import Flask, Blueprint + + +def init_app(app: Flask, bp: Blueprint) -> None: + """Initialize the application. + + :param bp: The blueprint of the accounting application. + :param app: The Flask application. + :return: None. + """ + from .converters import CurrencyConverter + app.url_map.converters["currency"] = CurrencyConverter + + from .views import bp as currency_bp, api_bp as currency_api_bp + bp.register_blueprint(currency_bp, url_prefix="/currencies") + bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies") + + from .commands import init_currencies_command + app.cli.add_command(init_currencies_command) diff --git a/src/accounting/currency/commands.py b/src/accounting/currency/commands.py new file mode 100644 index 0000000..e3321f6 --- /dev/null +++ b/src/accounting/currency/commands.py @@ -0,0 +1,78 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The console commands for the currency management. + +""" +import os + +import click +from flask.cli import with_appcontext + +from accounting.database import db +from accounting.models import Currency, CurrencyL10n +from accounting.utils.user import has_user, get_user_pk + +CurrencyData = tuple[str, str, str, str] + + +def __validate_username(ctx: click.core.Context, param: click.core.Option, + value: str) -> str: + """Validates the username for the click console command. + + :param ctx: The console command context. + :param param: The console command option. + :param value: The username. + :raise click.BadParameter: When validation fails. + :return: The username. + """ + value = value.strip() + if value == "": + raise click.BadParameter("Username empty.") + if not has_user(value): + raise click.BadParameter(f"User {value} does not exist.") + return value + + +@click.command("accounting-init-currencies") +@click.option("-u", "--username", metavar="USERNAME", prompt=True, + help="The username.", callback=__validate_username, + default=lambda: os.getlogin()) +@with_appcontext +def init_currencies_command(username: str) -> None: + """Initializes the currencies.""" + data: list[CurrencyData] = [ + ("TWD", "New Taiwan dollar", "新臺幣", "新台币"), + ("USD", "United States dollar", "美元", "美元"), + ] + creator_pk: int = get_user_pk(username) + existing: list[Currency] = Currency.query.all() + existing_code: set[str] = {x.code for x in existing} + to_add: list[CurrencyData] = [x for x in data if x[0] not in existing_code] + if len(to_add) == 0: + click.echo("No more currency to add.") + return + + db.session.bulk_save_objects( + [Currency(code=x[0], name_l10n=x[1], + created_by_id=creator_pk, updated_by_id=creator_pk) + for x in data]) + db.session.bulk_save_objects( + [CurrencyL10n(currency_code=x[0], locale=y[0], name=y[1]) + for x in data for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))]) + db.session.commit() + + click.echo(F"{len(to_add)} added. Currencies initialized.") diff --git a/src/accounting/currency/converters.py b/src/accounting/currency/converters.py new file mode 100644 index 0000000..d696b65 --- /dev/null +++ b/src/accounting/currency/converters.py @@ -0,0 +1,48 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The path converters for the currency management. + +""" +from flask import abort +from werkzeug.routing import BaseConverter + +from accounting.database import db +from accounting.models import Currency + + +class CurrencyConverter(BaseConverter): + """The currency converter to convert the currency code and to the + corresponding currency in the routes.""" + + def to_python(self, value: str) -> Currency: + """Converts a currency code to a currency. + + :param value: The currency code. + :return: The corresponding currency. + """ + currency: Currency | None = db.session.get(Currency, value) + if currency is None: + abort(404) + return currency + + def to_url(self, value: Currency) -> str: + """Converts a currency to its code. + + :param value: The currency. + :return: The code. + """ + return value.code diff --git a/src/accounting/currency/forms.py b/src/accounting/currency/forms.py new file mode 100644 index 0000000..f8cbff0 --- /dev/null +++ b/src/accounting/currency/forms.py @@ -0,0 +1,93 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The forms for the currency management. + +""" +from __future__ import annotations + +import sqlalchemy as sa +from flask_wtf import FlaskForm +from wtforms import StringField, ValidationError +from wtforms.validators import DataRequired, Regexp, NoneOf + +from accounting.database import db +from accounting.locale import lazy_gettext +from accounting.models import Currency +from accounting.utils.strip_text import strip_text +from accounting.utils.user import get_current_user_pk + + +class CurrencyForm(FlaskForm): + """The form to create or edit a currency.""" + CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"] + """The reserved codes that are not available.""" + + class CodeUnique: + """The validator to check if the code is unique.""" + def __call__(self, form: CurrencyForm, field: StringField) -> None: + if field.data == "": + return + if form.obj_code is not None and form.obj_code == field.data: + return + if db.session.get(Currency, field.data) is not None: + raise ValidationError(lazy_gettext( + "Code conflicts with another currency.")) + + code = StringField( + filters=[strip_text], + validators=[DataRequired(lazy_gettext("Please fill in the code.")), + Regexp(r"^[A-Z]{3}$", + message=lazy_gettext( + "Code can only be composed of 3 upper-cased" + " letters.")), + NoneOf(CODE_BLOCKLIST, message=lazy_gettext( + "This code is not available.")), + CodeUnique()]) + """The code. It may not conflict with another currency.""" + name = StringField( + filters=[strip_text], + validators=[DataRequired(lazy_gettext("Please fill in the name."))]) + """The name.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.obj_code: str | None = None + """The current code of the currency, or None when adding a new + currency.""" + + def populate_obj(self, obj: Currency) -> None: + """Populates the form data into a currency object. + + :param obj: The currency object. + :return: None. + """ + is_new: bool = obj.code is None + obj.code = self.code.data + obj.name = self.name.data + if is_new: + current_user_pk: int = get_current_user_pk() + obj.created_by_id = current_user_pk + obj.updated_by_id = current_user_pk + + def post_update(self, obj) -> None: + """The post-processing after the update. + + :return: None + """ + current_user_pk: int = get_current_user_pk() + obj.updated_by_id = current_user_pk + obj.updated_at = sa.func.now() diff --git a/src/accounting/currency/query.py b/src/accounting/currency/query.py new file mode 100644 index 0000000..4321f07 --- /dev/null +++ b/src/accounting/currency/query.py @@ -0,0 +1,44 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The currency query. + +""" +import sqlalchemy as sa +from flask import request + +from accounting.models import Currency, CurrencyL10n +from accounting.utils.query import parse_query_keywords + + +def get_currency_query() -> list[Currency]: + """Returns the base accounts, optionally filtered by the query. + + :return: The base accounts. + """ + keywords: list[str] = parse_query_keywords(request.args.get("q")) + if len(keywords) == 0: + return Currency.query.order_by(Currency.code).all() + conditions: list[sa.BinaryExpression] = [] + for k in keywords: + l10n: list[CurrencyL10n] = CurrencyL10n.query\ + .filter(CurrencyL10n.name.contains(k)).all() + l10n_matches: set[str] = {x.account_code for x in l10n} + conditions.append(sa.or_(Currency.code.contains(k), + Currency.name_l10n.contains(k), + Currency.code.in_(l10n_matches))) + return Currency.query.filter(*conditions)\ + .order_by(Currency.code).all() diff --git a/src/accounting/currency/views.py b/src/accounting/currency/views.py new file mode 100644 index 0000000..ba76169 --- /dev/null +++ b/src/accounting/currency/views.py @@ -0,0 +1,178 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 + +# Copyright (c) 2023 imacat. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The views for the currency management. + +""" +from urllib.parse import urlencode, parse_qsl + +from flask import Blueprint, render_template, redirect, session, request, \ + flash, url_for +from werkzeug.datastructures import ImmutableMultiDict + +from accounting.database import db +from accounting.locale import lazy_gettext +from accounting.models import Currency +from accounting.utils.next_url import inherit_next, or_next +from accounting.utils.pagination import Pagination +from accounting.utils.permission import has_permission, can_view, can_edit +from .forms import CurrencyForm + +bp: Blueprint = Blueprint("currency", __name__) +"""The view blueprint for the currency management.""" +api_bp: Blueprint = Blueprint("currency-api", __name__) +"""The view blueprint for the currency management API.""" + + +@bp.get("", endpoint="list") +@has_permission(can_view) +def list_currencies() -> str: + """Lists the currencies. + + :return: The currency list. + """ + from .query import get_currency_query + currencies: list[Currency] = get_currency_query() + pagination: Pagination = Pagination[Currency](currencies) + return render_template("accounting/currency/list.html", + list=pagination.list, pagination=pagination) + + +@bp.get("/create", endpoint="create") +@has_permission(can_edit) +def show_add_currency_form() -> str: + """Shows the form to add a currency. + + :return: The form to add a currency. + """ + if "form" in session: + form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"]))) + del session["form"] + form.validate() + else: + form = CurrencyForm() + return render_template("accounting/currency/create.html", + form=form) + + +@bp.post("/store", endpoint="store") +@has_permission(can_edit) +def add_currency() -> redirect: + """Adds a currency. + + :return: The redirection to the currency detail on success, or the currency + creation form on error. + """ + form = CurrencyForm(request.form) + if not form.validate(): + for key in form.errors: + for error in form.errors[key]: + flash(error, "error") + session["form"] = urlencode(list(request.form.items())) + return redirect(inherit_next(url_for("accounting.currency.create"))) + currency: Currency = Currency() + form.populate_obj(currency) + db.session.add(currency) + db.session.commit() + flash(lazy_gettext("The currency is added successfully"), "success") + return redirect(inherit_next(url_for("accounting.currency.detail", + currency=currency))) + + +@bp.get("/", 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("//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("//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("//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} diff --git a/src/accounting/models.py b/src/accounting/models.py index 0362b5c..e3d846a 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -313,3 +313,117 @@ class AccountL10n(db.Model): locale = db.Column(db.String, nullable=False, primary_key=True) title = db.Column(db.String, nullable=False) 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.""" + db.UniqueConstraint(currency_code, locale) diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 72154ee..6bc8a18 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -56,6 +56,23 @@ overflow-y: scroll; } +/** The currency management. */ +.currency { + padding: 2em 1.5em; + margin: 1em; + background-color: #E9ECEF; + border-radius: 0.3em; + box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19); +} +.currency .currency-name { + font-size: 1.8rem; + font-weight: bolder; +} +.currency .currency-code { + font-size: 1.4rem; + color: #373b3e; +} + /* The Material Design text field (floating form control in Bootstrap) */ .material-text-field { position: relative; diff --git a/src/accounting/static/js/currency-form.js b/src/accounting/static/js/currency-form.js new file mode 100644 index 0000000..9864a8f --- /dev/null +++ b/src/accounting/static/js/currency-form.js @@ -0,0 +1,174 @@ +/* The Mia! Accounting Flask Project + * currency-form.js: The JavaScript for the currency form + */ + +/* Copyright (c) 2023 imacat. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* Author: imacat@mail.imacat.idv.tw (imacat) + * First written: 2023/2/6 + */ + +// Initializes the page JavaScript. +document.addEventListener("DOMContentLoaded", function () { + document.getElementById("currency-code") + .onchange = validateCode; + document.getElementById("currency-name") + .onchange = validateName; + document.getElementById("currency-form") + .onsubmit = validateForm; +}); + +/** + * The asynchronous validation result + * @type {object} + * @private + */ +let isAsyncValid = {}; + +/** + * Validates the form. + * + * @returns {boolean} true if valid, or false otherwise + * @private + */ +function validateForm() { + isAsyncValid = { + "code": false, + "_sync": false, + }; + let isValid = true; + isValid = validateCode() && isValid; + isValid = validateName() && isValid; + isAsyncValid["_sync"] = isValid; + submitFormIfAllAsyncValid(); + return false; +} + +/** + * Submits the form if the whole form passed the asynchronous + * validations. + * + * @private + */ +function submitFormIfAllAsyncValid() { + let isValid = true; + Object.keys(isAsyncValid).forEach(function (key) { + isValid = isAsyncValid[key] && isValid; + }); + if (isValid) { + document.getElementById("currency-form").submit() + } +} + +/** + * Validates the code. + * + * @param changeEvent {Event} the change event, if invoked from onchange + * @returns {boolean} true if valid, or false otherwise + * @private + */ +function validateCode(changeEvent = null) { + const key = "code"; + const isSubmission = changeEvent === null; + let hasAsyncValidation = false; + const field = document.getElementById("currency-code"); + const error = document.getElementById("currency-code-error"); + field.value = field.value.trim(); + if (field.value === "") { + field.classList.add("is-invalid"); + error.innerText = A_("Please fill in the code."); + return false; + } + const blocklist = JSON.parse(field.dataset.blocklist); + if (blocklist.includes(field.value)) { + field.classList.add("is-invalid"); + error.innerText = A_("This code is not available."); + return false; + } + if (!field.value.match(/^[A-Z]{3}$/)) { + field.classList.add("is-invalid"); + error.innerText = A_("Code can only be composed of 3 upper-cased letters."); + return false; + } + const original = field.dataset.original; + if (original === "" || field.value !== original) { + hasAsyncValidation = true; + validateAsyncCodeIsDuplicated(isSubmission, key); + } + if (!hasAsyncValidation) { + isAsyncValid[key] = true; + field.classList.remove("is-invalid"); + error.innerText = ""; + } + return true; +} + +/** + * Validates asynchronously whether the code is duplicated. + * The boolean validation result is stored in isAsyncValid[key]. + * + * @param isSubmission {boolean} whether this is invoked from a form submission + * @param key {string} the key to store the result in isAsyncValid + * @private + */ +function validateAsyncCodeIsDuplicated(isSubmission, key) { + const field = document.getElementById("currency-code"); + const error = document.getElementById("currency-code-error"); + const url = field.dataset.existsUrl; + const onLoad = function () { + if (this.status === 200) { + const result = JSON.parse(this.responseText); + if (result["exists"]) { + field.classList.add("is-invalid"); + error.innerText = _("Code conflicts with another currency."); + if (isSubmission) { + isAsyncValid[key] = false; + } + return; + } + field.classList.remove("is-invalid"); + error.innerText = ""; + if (isSubmission) { + isAsyncValid[key] = true; + submitFormIfAllAsyncValid(); + } + } + }; + const request = new XMLHttpRequest(); + request.onload = onLoad; + request.open("GET", url + "?q=" + encodeURIComponent(field.value)); + request.send(); +} + +/** + * Validates the name. + * + * @returns {boolean} true if valid, or false otherwise + * @private + */ +function validateName() { + const field = document.getElementById("currency-name"); + const error = document.getElementById("currency-name-error"); + field.value = field.value.trim(); + if (field.value === "") { + field.classList.add("is-invalid"); + error.innerText = A_("Please fill in the name."); + return false; + } + field.classList.remove("is-invalid"); + error.innerText = ""; + return true; +} diff --git a/src/accounting/templates/accounting/currency/create.html b/src/accounting/templates/accounting/currency/create.html new file mode 100644 index 0000000..7664bff --- /dev/null +++ b/src/accounting/templates/accounting/currency/create.html @@ -0,0 +1,28 @@ +{# +The Mia! Accounting Flask Project +create.html: The currency creation form + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/2/6 +#} +{% extends "accounting/currency/include/form.html" %} + +{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %} + +{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %} + +{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %} diff --git a/src/accounting/templates/accounting/currency/detail.html b/src/accounting/templates/accounting/currency/detail.html new file mode 100644 index 0000000..25dbbde --- /dev/null +++ b/src/accounting/templates/accounting/currency/detail.html @@ -0,0 +1,90 @@ +{# +The Mia! Accounting Flask Project +detail.html: The currency detail + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/2/6 +#} +{% extends "accounting/base.html" %} + +{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %} + +{% block content %} + +
+ + + {{ A_("Back") }} + + {% if can_edit_accounting() %} + + + {{ A_("Settings") }} + + {% endif %} + {% if can_edit_accounting() %} + + {% endif %} +
+ +{% if can_edit_accounting() %} +
+ + + +
+{% endif %} + +{% if can_edit_accounting() %} +
+ + {% if "next" in request.args %} + + {% endif %} + +
+{% endif %} + +
+
{{ obj.name }}
+
{{ obj.code }}
+
+
{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}
+
{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}
+
+
+ +{% endblock %} diff --git a/src/accounting/templates/accounting/currency/edit.html b/src/accounting/templates/accounting/currency/edit.html new file mode 100644 index 0000000..05a231a --- /dev/null +++ b/src/accounting/templates/accounting/currency/edit.html @@ -0,0 +1,30 @@ +{# +The Mia! Accounting Flask Project +edit.html: The currency edit form + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/2/6 +#} +{% extends "accounting/currency/include/form.html" %} + +{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %} + +{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|inherit_next }}{% endblock %} + +{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %} + +{% block original_code %}{{ currency.code }}{% endblock %} diff --git a/src/accounting/templates/accounting/currency/include/form.html b/src/accounting/templates/accounting/currency/include/form.html new file mode 100644 index 0000000..81f1b46 --- /dev/null +++ b/src/accounting/templates/accounting/currency/include/form.html @@ -0,0 +1,68 @@ +{# +The Mia! Accounting Flask Project +form.html: The currency form + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/2/6 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + +{% endblock %} + +{% block content %} + + + +
+ {{ form.csrf_token }} + {% if "next" in request.args %} + + {% endif %} +
+ + +
{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}
+
+ +
+ + +
{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}
+
+ +
+ +
+ +
+ +
+
+ +{% endblock %} diff --git a/src/accounting/templates/accounting/currency/list.html b/src/accounting/templates/accounting/currency/list.html new file mode 100644 index 0000000..4bb3c53 --- /dev/null +++ b/src/accounting/templates/accounting/currency/list.html @@ -0,0 +1,60 @@ +{# +The Mia! Accounting Flask Project +list.html: The currency list + + Copyright (c) 2023 imacat. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +Author: imacat@mail.imacat.idv.tw (imacat) +First written: 2023/2/6 +#} +{% extends "accounting/base.html" %} + +{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %} + +{% block content %} + +
+ {% if can_edit_accounting() %} + + + {{ A_("New") }} + + {% endif %} + +
+ +{% if list %} + {% include "accounting/include/pagination.html" %} + +
+ {% for item in list %} + + {{ item }} + + {% endfor %} +
+{% else %} +

{{ A_("There is no data.") }}

+{% endif %} + +{% endblock %} diff --git a/src/accounting/templates/accounting/include/nav.html b/src/accounting/templates/accounting/include/nav.html index e52175d..0a98d84 100644 --- a/src/accounting/templates/accounting/include/nav.html +++ b/src/accounting/templates/accounting/include/nav.html @@ -38,6 +38,12 @@ First written: 2023/1/26 {{ A_("Base Accounts") }} +
  • + + + {{ A_("Currencies") }} + +
  • {% endif %} diff --git a/tests/test_currency.py b/tests/test_currency.py new file mode 100644 index 0000000..f0b5a7e --- /dev/null +++ b/tests/test_currency.py @@ -0,0 +1,560 @@ +# 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 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("/accounting/currencies/store", + data={"csrf_token": self.csrf_token, + "code": "ZZA", + "name": "Testing Dollar #A"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + "/accounting/currencies/ZZA") + + response = self.client.post("/accounting/currencies/store", + data={"csrf_token": self.csrf_token, + "code": "ZZB", + "name": "Testing Dollar #B"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + "/accounting/currencies/ZZB") + + 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("/accounting/currencies") + self.assertEqual(response.status_code, 403) + + response = client.get("/accounting/currencies/ZZA") + self.assertEqual(response.status_code, 403) + + response = client.get("/accounting/currencies/create") + self.assertEqual(response.status_code, 403) + + response = client.post("/accounting/currencies/store", + data={"csrf_token": csrf_token, + "code": "ZZC", + "name": "Testing Dollar #C"}) + self.assertEqual(response.status_code, 403) + + response = client.get("/accounting/currencies/ZZA/edit") + self.assertEqual(response.status_code, 403) + + response = client.post("/accounting/currencies/ZZA/update", + data={"csrf_token": csrf_token, + "code": "ZZD", + "name": "Testing Dollar #D"}) + self.assertEqual(response.status_code, 403) + + response = client.post("/accounting/currencies/ZZB/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("/accounting/currencies") + self.assertEqual(response.status_code, 200) + + response = client.get("/accounting/currencies/ZZA") + self.assertEqual(response.status_code, 200) + + response = client.get("/accounting/currencies/create") + self.assertEqual(response.status_code, 403) + + response = client.post("/accounting/currencies/store", + data={"csrf_token": csrf_token, + "code": "ZZC", + "name": "Testing Dollar #C"}) + self.assertEqual(response.status_code, 403) + + response = client.get("/accounting/currencies/ZZA/edit") + self.assertEqual(response.status_code, 403) + + response = client.post("/accounting/currencies/ZZA/update", + data={"csrf_token": csrf_token, + "code": "ZZD", + "name": "Testing Dollar #D"}) + self.assertEqual(response.status_code, 403) + + response = client.post("/accounting/currencies/ZZB/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("/accounting/currencies") + self.assertEqual(response.status_code, 200) + + response = self.client.get("/accounting/currencies/ZZA") + self.assertEqual(response.status_code, 200) + + response = self.client.get("/accounting/currencies/create") + self.assertEqual(response.status_code, 200) + + response = self.client.post("/accounting/currencies/store", + data={"csrf_token": self.csrf_token, + "code": "ZZC", + "name": "Testing Dollar #C"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + "/accounting/currencies/ZZC") + + response = self.client.get("/accounting/currencies/ZZA/edit") + self.assertEqual(response.status_code, 200) + + response = self.client.post("/accounting/currencies/ZZA/update", + data={"csrf_token": self.csrf_token, + "code": "ZZD", + "name": "Testing Dollar #D"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + "/accounting/currencies/ZZD") + + response = self.client.post("/accounting/currencies/ZZB/delete", + data={"csrf_token": self.csrf_token}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + "/accounting/currencies") + + def test_add(self) -> None: + """Tests to add the currencies. + + :return: None. + """ + from accounting.models import Currency + from test_site import db + zzc_code, zzc_name = "ZZC", "Testing Dollar #C" + create_uri: str = "/accounting/currencies/create" + store_uri: str = "/accounting/currencies/store" + response: httpx.Response + + with self.app.app_context(): + self.assertEqual({x.code for x in Currency.query.all()}, + {"ZZA", "ZZB"}) + + # 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"], + f"/accounting/currencies/{zzc_code}") + + # 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", "ZZB", zzc_code}) + + zzc: Currency = db.session.get(Currency, zzc_code) + self.assertEqual(zzc.code, zzc_code) + self.assertEqual(zzc.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 + zza_code: str = "ZZA" + zzc_code, zzc_name = "ZZC", "Testing Dollar #C" + edit_uri: str = f"/accounting/currencies/{zza_code}/edit" + update_uri: str = f"/accounting/currencies/{zza_code}/update" + 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" {zzc_name} "}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + f"/accounting/currencies/{zza_code}") + + with self.app.app_context(): + zza: Currency = db.session.get(Currency, zza_code) + self.assertEqual(zza.code, zza_code) + self.assertEqual(zza.name_l10n, zzc_name) + + # 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", + "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"], + f"/accounting/currencies/{zzc_code}") + + response = self.client.get(f"/accounting/currencies/{zza_code}") + self.assertEqual(response.status_code, 404) + + response = self.client.get(f"/accounting/currencies/{zzc_code}") + 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 + zza_code, zza_name = "ZZA", "Testing Dollar #A" + detail_uri: str = f"/accounting/currencies/{zza_code}" + update_uri: str = f"/accounting/currencies/{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 = db.session.get(Currency, zza_code) + self.assertIsNotNone(zza) + self.assertEqual(zza.created_at, zza.updated_at) + + response = self.client.post(update_uri, + data={"csrf_token": self.csrf_token, + "code": zza_code, + "name": "Testing Dollar #C"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], detail_uri) + + with self.app.app_context(): + zza: Currency = db.session.get(Currency, zza_code) + self.assertIsNotNone(zza) + self.assertNotEqual(zza.created_at, zza.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 + zza_code, zza_name = "ZZA", "Testing Dollar #A" + editor_username, editor2_username = "editor", "editor2" + client, csrf_token = get_client(self, self.app, editor2_username) + response: httpx.Response + + with self.app.app_context(): + currency: Currency = db.session.get(Currency, zza_code) + self.assertEqual(currency.created_by.username, editor_username) + self.assertEqual(currency.updated_by.username, editor_username) + + response = client.post(f"/accounting/currencies/{zza_code}/update", + data={"csrf_token": csrf_token, + "code": zza_code, + "name": f"{zza_name}-2"}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + f"/accounting/currencies/{zza_code}") + + with self.app.app_context(): + currency: Currency = db.session.get(Currency, zza_code) + self.assertEqual(currency.created_by.username, editor_username) + self.assertEqual(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 + zza_code, zza_name = "ZZA", "Testing Dollar #A" + + with self.app.app_context(): + zza: Currency = db.session.get(Currency, zza_code) + self.assertEqual(zza.name_l10n, zza_name) + self.assertEqual(zza.l10n, []) + + set_locale(self, self.client, self.csrf_token, "zh_Hant") + + response = self.client.post( + f"/accounting/currencies/{zza_code}/update", + 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"], + f"/accounting/currencies/{zza_code}") + + with self.app.app_context(): + zza: Currency = db.session.get(Currency, zza_code) + self.assertEqual(zza.name_l10n, zza_name) + self.assertEqual({(x.locale, x.name) for x in zza.l10n}, + {("zh_Hant", f"{zza_name}-zh_Hant")}) + + set_locale(self, self.client, self.csrf_token, "en") + + response = self.client.post( + f"/accounting/currencies/{zza_code}/update", + 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"], + f"/accounting/currencies/{zza_code}") + + with self.app.app_context(): + zza: Currency = db.session.get(Currency, zza_code) + self.assertEqual(zza.name_l10n, f"{zza_name}-2") + self.assertEqual({(x.locale, x.name) for x in zza.l10n}, + {("zh_Hant", f"{zza_name}-zh_Hant")}) + + set_locale(self, self.client, self.csrf_token, "zh_Hant") + + response = self.client.post( + f"/accounting/currencies/{zza_code}/update", + 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"], + f"/accounting/currencies/{zza_code}") + + with self.app.app_context(): + zza: Currency = db.session.get(Currency, zza_code) + self.assertEqual(zza.name_l10n, f"{zza_name}-2") + self.assertEqual({(x.locale, x.name) for x in zza.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 + zza_code, zzb_code = "ZZA", "ZZB" + 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(f"/accounting/currencies/{zza_code}") + self.assertEqual(response.status_code, 200) + response = self.client.post( + f"/accounting/currencies/{zza_code}/delete", + data={"csrf_token": self.csrf_token}) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers["Location"], + "/accounting/currencies") + + with self.app.app_context(): + self.assertEqual({x.code for x in Currency.query.all()}, + {zzb_code}) + + response = self.client.get(f"/accounting/currencies/{zza_code}") + self.assertEqual(response.status_code, 404) + response = self.client.post( + f"/accounting/currencies/{zza_code}/delete", + data={"csrf_token": self.csrf_token}) + self.assertEqual(response.status_code, 404) diff --git a/tests/testlib.py b/tests/testlib.py index 95f5ff9..8b812f1 100644 --- a/tests/testlib.py +++ b/tests/testlib.py @@ -17,6 +17,7 @@ """The common test libraries. """ +import typing as t from html.parser import HTMLParser from unittest import TestCase @@ -75,3 +76,21 @@ def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str: parser.feed(response.text) test_case.assertIsNotNone(parser.csrf_token) return parser.csrf_token + + +def set_locale(test_case: TestCase, client: httpx.Client, csrf_token: str, + locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None: + """Sets the current locale. + + :param test_case: The test case. + :param client: The test client. + :param csrf_token: The CSRF token. + :param locale: The locale. + :return: None. + """ + response: httpx.Response = client.post("/locale", + data={"csrf_token": csrf_token, + "locale": locale, + "next": "/next"}) + test_case.assertEqual(response.status_code, 302) + test_case.assertEqual(response.headers["Location"], "/next")