Added the currency management.
This commit is contained in:
parent
7873e16cc3
commit
570c84c196
@ -63,6 +63,9 @@ 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")
|
||||||
|
38
src/accounting/currency/__init__.py
Normal file
38
src/accounting/currency/__init__.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import Flask, Blueprint
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||||
|
"""Initialize the application.
|
||||||
|
|
||||||
|
:param 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)
|
78
src/accounting/currency/commands.py
Normal file
78
src/accounting/currency/commands.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The console commands for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
from flask.cli import with_appcontext
|
||||||
|
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import Currency, CurrencyL10n
|
||||||
|
from accounting.utils.user import has_user, get_user_pk
|
||||||
|
|
||||||
|
CurrencyData = tuple[str, str, str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||||
|
value: str) -> str:
|
||||||
|
"""Validates the username for the click console command.
|
||||||
|
|
||||||
|
:param ctx: The console command context.
|
||||||
|
:param param: The console command option.
|
||||||
|
:param value: The username.
|
||||||
|
:raise click.BadParameter: When validation fails.
|
||||||
|
:return: The username.
|
||||||
|
"""
|
||||||
|
value = value.strip()
|
||||||
|
if value == "":
|
||||||
|
raise click.BadParameter("Username empty.")
|
||||||
|
if not has_user(value):
|
||||||
|
raise click.BadParameter(f"User {value} does not exist.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("accounting-init-currencies")
|
||||||
|
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||||
|
help="The username.", callback=__validate_username,
|
||||||
|
default=lambda: os.getlogin())
|
||||||
|
@with_appcontext
|
||||||
|
def init_currencies_command(username: str) -> None:
|
||||||
|
"""Initializes the currencies."""
|
||||||
|
data: list[CurrencyData] = [
|
||||||
|
("TWD", "New Taiwan dollar", "新臺幣", "新台币"),
|
||||||
|
("USD", "United States dollar", "美元", "美元"),
|
||||||
|
]
|
||||||
|
creator_pk: int = get_user_pk(username)
|
||||||
|
existing: list[Currency] = Currency.query.all()
|
||||||
|
existing_code: set[str] = {x.code for x in existing}
|
||||||
|
to_add: list[CurrencyData] = [x for x in data if x[0] not in existing_code]
|
||||||
|
if len(to_add) == 0:
|
||||||
|
click.echo("No more currency to add.")
|
||||||
|
return
|
||||||
|
|
||||||
|
db.session.bulk_save_objects(
|
||||||
|
[Currency(code=x[0], name_l10n=x[1],
|
||||||
|
created_by_id=creator_pk, updated_by_id=creator_pk)
|
||||||
|
for x in data])
|
||||||
|
db.session.bulk_save_objects(
|
||||||
|
[CurrencyL10n(currency_code=x[0], locale=y[0], name=y[1])
|
||||||
|
for x in data for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
48
src/accounting/currency/converters.py
Normal file
48
src/accounting/currency/converters.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The path converters for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from flask import abort
|
||||||
|
from werkzeug.routing import BaseConverter
|
||||||
|
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.models import Currency
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyConverter(BaseConverter):
|
||||||
|
"""The currency converter to convert the currency code and to the
|
||||||
|
corresponding currency in the routes."""
|
||||||
|
|
||||||
|
def to_python(self, value: str) -> Currency:
|
||||||
|
"""Converts a currency code to a currency.
|
||||||
|
|
||||||
|
:param value: The currency code.
|
||||||
|
:return: The corresponding currency.
|
||||||
|
"""
|
||||||
|
currency: Currency | None = db.session.get(Currency, value)
|
||||||
|
if currency is None:
|
||||||
|
abort(404)
|
||||||
|
return currency
|
||||||
|
|
||||||
|
def to_url(self, value: Currency) -> str:
|
||||||
|
"""Converts a currency to its code.
|
||||||
|
|
||||||
|
:param value: The currency.
|
||||||
|
:return: The code.
|
||||||
|
"""
|
||||||
|
return value.code
|
93
src/accounting/currency/forms.py
Normal file
93
src/accounting/currency/forms.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The forms for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask_wtf import FlaskForm
|
||||||
|
from wtforms import StringField, ValidationError
|
||||||
|
from wtforms.validators import DataRequired, Regexp, NoneOf
|
||||||
|
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Currency
|
||||||
|
from accounting.utils.strip_text import strip_text
|
||||||
|
from accounting.utils.user import get_current_user_pk
|
||||||
|
|
||||||
|
|
||||||
|
class CurrencyForm(FlaskForm):
|
||||||
|
"""The form to create or edit a currency."""
|
||||||
|
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
||||||
|
"""The reserved codes that are not available."""
|
||||||
|
|
||||||
|
class CodeUnique:
|
||||||
|
"""The validator to check if the code is unique."""
|
||||||
|
def __call__(self, form: CurrencyForm, field: StringField) -> None:
|
||||||
|
if field.data == "":
|
||||||
|
return
|
||||||
|
if form.obj_code is not None and form.obj_code == field.data:
|
||||||
|
return
|
||||||
|
if db.session.get(Currency, field.data) is not None:
|
||||||
|
raise ValidationError(lazy_gettext(
|
||||||
|
"Code conflicts with another currency."))
|
||||||
|
|
||||||
|
code = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
||||||
|
Regexp(r"^[A-Z]{3}$",
|
||||||
|
message=lazy_gettext(
|
||||||
|
"Code can only be composed of 3 upper-cased"
|
||||||
|
" letters.")),
|
||||||
|
NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
|
||||||
|
"This code is not available.")),
|
||||||
|
CodeUnique()])
|
||||||
|
"""The code. It may not conflict with another currency."""
|
||||||
|
name = StringField(
|
||||||
|
filters=[strip_text],
|
||||||
|
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
||||||
|
"""The name."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.obj_code: str | None = None
|
||||||
|
"""The current code of the currency, or None when adding a new
|
||||||
|
currency."""
|
||||||
|
|
||||||
|
def populate_obj(self, obj: Currency) -> None:
|
||||||
|
"""Populates the form data into a currency object.
|
||||||
|
|
||||||
|
:param obj: The currency object.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
is_new: bool = obj.code is None
|
||||||
|
obj.code = self.code.data
|
||||||
|
obj.name = self.name.data
|
||||||
|
if is_new:
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.created_by_id = current_user_pk
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
|
||||||
|
def post_update(self, obj) -> None:
|
||||||
|
"""The post-processing after the update.
|
||||||
|
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
current_user_pk: int = get_current_user_pk()
|
||||||
|
obj.updated_by_id = current_user_pk
|
||||||
|
obj.updated_at = sa.func.now()
|
44
src/accounting/currency/query.py
Normal file
44
src/accounting/currency/query.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The currency query.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from flask import request
|
||||||
|
|
||||||
|
from accounting.models import Currency, CurrencyL10n
|
||||||
|
from accounting.utils.query import parse_query_keywords
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_query() -> list[Currency]:
|
||||||
|
"""Returns the base accounts, optionally filtered by the query.
|
||||||
|
|
||||||
|
:return: The base accounts.
|
||||||
|
"""
|
||||||
|
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||||
|
if len(keywords) == 0:
|
||||||
|
return Currency.query.order_by(Currency.code).all()
|
||||||
|
conditions: list[sa.BinaryExpression] = []
|
||||||
|
for k in keywords:
|
||||||
|
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
||||||
|
.filter(CurrencyL10n.name.contains(k)).all()
|
||||||
|
l10n_matches: set[str] = {x.account_code for x in l10n}
|
||||||
|
conditions.append(sa.or_(Currency.code.contains(k),
|
||||||
|
Currency.name_l10n.contains(k),
|
||||||
|
Currency.code.in_(l10n_matches)))
|
||||||
|
return Currency.query.filter(*conditions)\
|
||||||
|
.order_by(Currency.code).all()
|
178
src/accounting/currency/views.py
Normal file
178
src/accounting/currency/views.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
# The Mia! Accounting Flask Project.
|
||||||
|
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
||||||
|
|
||||||
|
# Copyright (c) 2023 imacat.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""The views for the currency management.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlencode, parse_qsl
|
||||||
|
|
||||||
|
from flask import Blueprint, render_template, redirect, session, request, \
|
||||||
|
flash, url_for
|
||||||
|
from werkzeug.datastructures import ImmutableMultiDict
|
||||||
|
|
||||||
|
from accounting.database import db
|
||||||
|
from accounting.locale import lazy_gettext
|
||||||
|
from accounting.models import Currency
|
||||||
|
from accounting.utils.next_url import inherit_next, or_next
|
||||||
|
from accounting.utils.pagination import Pagination
|
||||||
|
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||||
|
from .forms import CurrencyForm
|
||||||
|
|
||||||
|
bp: Blueprint = Blueprint("currency", __name__)
|
||||||
|
"""The view blueprint for the currency management."""
|
||||||
|
api_bp: Blueprint = Blueprint("currency-api", __name__)
|
||||||
|
"""The view blueprint for the currency management API."""
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("", endpoint="list")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def list_currencies() -> str:
|
||||||
|
"""Lists the currencies.
|
||||||
|
|
||||||
|
:return: The currency list.
|
||||||
|
"""
|
||||||
|
from .query import get_currency_query
|
||||||
|
currencies: list[Currency] = get_currency_query()
|
||||||
|
pagination: Pagination = Pagination[Currency](currencies)
|
||||||
|
return render_template("accounting/currency/list.html",
|
||||||
|
list=pagination.list, pagination=pagination)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/create", endpoint="create")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_add_currency_form() -> str:
|
||||||
|
"""Shows the form to add a currency.
|
||||||
|
|
||||||
|
:return: The form to add a currency.
|
||||||
|
"""
|
||||||
|
if "form" in session:
|
||||||
|
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = CurrencyForm()
|
||||||
|
return render_template("accounting/currency/create.html",
|
||||||
|
form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/store", endpoint="store")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def add_currency() -> redirect:
|
||||||
|
"""Adds a currency.
|
||||||
|
|
||||||
|
:return: The redirection to the currency detail on success, or the currency
|
||||||
|
creation form on error.
|
||||||
|
"""
|
||||||
|
form = CurrencyForm(request.form)
|
||||||
|
if not form.validate():
|
||||||
|
for key in form.errors:
|
||||||
|
for error in form.errors[key]:
|
||||||
|
flash(error, "error")
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(url_for("accounting.currency.create")))
|
||||||
|
currency: Currency = Currency()
|
||||||
|
form.populate_obj(currency)
|
||||||
|
db.session.add(currency)
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The currency is added successfully"), "success")
|
||||||
|
return redirect(inherit_next(url_for("accounting.currency.detail",
|
||||||
|
currency=currency)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<currency:currency>", endpoint="detail")
|
||||||
|
@has_permission(can_view)
|
||||||
|
def show_currency_detail(currency: Currency) -> str:
|
||||||
|
"""Shows the currency detail.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The detail.
|
||||||
|
"""
|
||||||
|
return render_template("accounting/currency/detail.html", obj=currency)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/<currency:currency>/edit", endpoint="edit")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def show_currency_edit_form(currency: Currency) -> str:
|
||||||
|
"""Shows the form to edit a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The form to edit the currency.
|
||||||
|
"""
|
||||||
|
form: CurrencyForm
|
||||||
|
if "form" in session:
|
||||||
|
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||||
|
del session["form"]
|
||||||
|
form.validate()
|
||||||
|
else:
|
||||||
|
form = CurrencyForm(obj=currency)
|
||||||
|
return render_template("accounting/currency/edit.html",
|
||||||
|
currency=currency, form=form)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<currency:currency>/update", endpoint="update")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def update_currency(currency: Currency) -> redirect:
|
||||||
|
"""Updates a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The redirection to the currency detail on success, or the currency
|
||||||
|
edit form on error.
|
||||||
|
"""
|
||||||
|
form = CurrencyForm(request.form)
|
||||||
|
form.obj_code = currency.code
|
||||||
|
if not form.validate():
|
||||||
|
for key in form.errors:
|
||||||
|
for error in form.errors[key]:
|
||||||
|
flash(error, "error")
|
||||||
|
session["form"] = urlencode(list(request.form.items()))
|
||||||
|
return redirect(inherit_next(url_for("accounting.currency.edit",
|
||||||
|
currency=currency)))
|
||||||
|
with db.session.no_autoflush:
|
||||||
|
form.populate_obj(currency)
|
||||||
|
if not currency.is_modified:
|
||||||
|
flash(lazy_gettext("The currency was not modified."), "success")
|
||||||
|
return redirect(inherit_next(url_for("accounting.currency.detail",
|
||||||
|
currency=currency)))
|
||||||
|
form.post_update(currency)
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The currency is updated successfully."), "success")
|
||||||
|
return redirect(inherit_next(url_for("accounting.currency.detail",
|
||||||
|
currency=currency)))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def delete_currency(currency: Currency) -> redirect:
|
||||||
|
"""Deletes a currency.
|
||||||
|
|
||||||
|
:param currency: The currency.
|
||||||
|
:return: The redirection to the currency list on success, or the currency
|
||||||
|
detail on error.
|
||||||
|
"""
|
||||||
|
currency.delete()
|
||||||
|
db.session.commit()
|
||||||
|
flash(lazy_gettext("The currency is deleted successfully."), "success")
|
||||||
|
return redirect(or_next(url_for("accounting.currency.list")))
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.get("/exists-code", endpoint="exists")
|
||||||
|
@has_permission(can_edit)
|
||||||
|
def exists_code() -> dict[str, bool]:
|
||||||
|
"""Validates whether a currency code exists.
|
||||||
|
|
||||||
|
:return: Whether the currency code exists.
|
||||||
|
"""
|
||||||
|
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
|
@ -313,3 +313,117 @@ class AccountL10n(db.Model):
|
|||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||||
title = db.Column(db.String, nullable=False)
|
title = db.Column(db.String, nullable=False)
|
||||||
db.UniqueConstraint(account_id, locale)
|
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)
|
||||||
|
@ -56,6 +56,23 @@
|
|||||||
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;
|
||||||
|
174
src/accounting/static/js/currency-form.js
Normal file
174
src/accounting/static/js/currency-form.js
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
/* The Mia! Accounting Flask Project
|
||||||
|
* currency-form.js: The JavaScript for the currency form
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2023 imacat.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2023/2/6
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
document.getElementById("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;
|
||||||
|
}
|
28
src/accounting/templates/accounting/currency/create.html
Normal file
28
src/accounting/templates/accounting/currency/create.html
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
create.html: The currency creation form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/6
|
||||||
|
#}
|
||||||
|
{% extends "accounting/currency/include/form.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
|
||||||
|
|
||||||
|
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}
|
90
src/accounting/templates/accounting/currency/detail.html
Normal file
90
src/accounting/templates/accounting/currency/detail.html
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
detail.html: The currency detail
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/6
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group mb-3">
|
||||||
|
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|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 %}
|
30
src/accounting/templates/accounting/currency/edit.html
Normal file
30
src/accounting/templates/accounting/currency/edit.html
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
edit.html: The currency edit form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/6
|
||||||
|
#}
|
||||||
|
{% extends "accounting/currency/include/form.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|inherit_next }}{% endblock %}
|
||||||
|
|
||||||
|
{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block original_code %}{{ currency.code }}{% endblock %}
|
@ -0,0 +1,68 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
form.html: The currency form
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/6
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block accounting_scripts %}
|
||||||
|
<script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group btn-actions mb-3">
|
||||||
|
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
||||||
|
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||||
|
{{ A_("Back") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="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 %}
|
60
src/accounting/templates/accounting/currency/list.html
Normal file
60
src/accounting/templates/accounting/currency/list.html
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{#
|
||||||
|
The Mia! Accounting Flask Project
|
||||||
|
list.html: The currency list
|
||||||
|
|
||||||
|
Copyright (c) 2023 imacat.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2023/2/6
|
||||||
|
#}
|
||||||
|
{% extends "accounting/base.html" %}
|
||||||
|
|
||||||
|
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group mb-2">
|
||||||
|
{% if can_edit_accounting() %}
|
||||||
|
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|append_next }}">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
{{ A_("New") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search">
|
||||||
|
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
||||||
|
<label for="search-input" class="search-label">
|
||||||
|
<button type="submit">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
{{ A_("Search") }}
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if list %}
|
||||||
|
{% include "accounting/include/pagination.html" %}
|
||||||
|
|
||||||
|
<div class="list-group">
|
||||||
|
{% for item in list %}
|
||||||
|
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|append_next }}">
|
||||||
|
{{ item }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ A_("There is no data.") }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -38,6 +38,12 @@ First written: 2023/1/26
|
|||||||
{{ A_("Base Accounts") }}
|
{{ A_("Base Accounts") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
|
||||||
|
<i class="fa-solid fa-list"></i>
|
||||||
|
{{ A_("Currencies") }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
560
tests/test_currency.py
Normal file
560
tests/test_currency.py
Normal file
@ -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)
|
@ -17,6 +17,7 @@
|
|||||||
"""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
|
||||||
|
|
||||||
@ -75,3 +76,21 @@ def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
|||||||
parser.feed(response.text)
|
parser.feed(response.text)
|
||||||
test_case.assertIsNotNone(parser.csrf_token)
|
test_case.assertIsNotNone(parser.csrf_token)
|
||||||
return parser.csrf_token
|
return parser.csrf_token
|
||||||
|
|
||||||
|
|
||||||
|
def set_locale(test_case: TestCase, client: httpx.Client, csrf_token: str,
|
||||||
|
locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
|
||||||
|
"""Sets the current locale.
|
||||||
|
|
||||||
|
:param test_case: The test case.
|
||||||
|
:param client: The test client.
|
||||||
|
:param csrf_token: The CSRF token.
|
||||||
|
:param locale: The locale.
|
||||||
|
:return: None.
|
||||||
|
"""
|
||||||
|
response: httpx.Response = client.post("/locale",
|
||||||
|
data={"csrf_token": csrf_token,
|
||||||
|
"locale": locale,
|
||||||
|
"next": "/next"})
|
||||||
|
test_case.assertEqual(response.status_code, 302)
|
||||||
|
test_case.assertEqual(response.headers["Location"], "/next")
|
||||||
|
Loading…
Reference in New Issue
Block a user