Compare commits
No commits in common. "16e2a146db3af685fda24865b98a46384aee6e66" and "50f8f06687df7111633d10c4367581d765757036" have entirely different histories.
16e2a146db
...
50f8f06687
@ -20,6 +20,7 @@
|
|||||||
import typing as t
|
import typing as t
|
||||||
|
|
||||||
from flask import Flask, Blueprint
|
from flask import Flask, Blueprint
|
||||||
|
from flask_sqlalchemy.model import Model
|
||||||
|
|
||||||
from accounting.utils.user import AbstractUserUtils
|
from accounting.utils.user import AbstractUserUtils
|
||||||
|
|
||||||
@ -63,9 +64,6 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
|||||||
from . import account
|
from . import account
|
||||||
account.init_app(app, bp)
|
account.init_app(app, bp)
|
||||||
|
|
||||||
from . import currency
|
|
||||||
currency.init_app(app, bp)
|
|
||||||
|
|
||||||
from .utils.next_url import append_next, inherit_next, or_next
|
from .utils.next_url import append_next, inherit_next, or_next
|
||||||
bp.add_app_template_filter(append_next, "append_next")
|
bp.add_app_template_filter(append_next, "append_next")
|
||||||
bp.add_app_template_filter(inherit_next, "inherit_next")
|
bp.add_app_template_filter(inherit_next, "inherit_next")
|
||||||
|
@ -32,7 +32,7 @@ from accounting.utils.user import get_current_user_pk
|
|||||||
|
|
||||||
|
|
||||||
class BaseAccountExists:
|
class BaseAccountExists:
|
||||||
"""The validator to check if the base account exists."""
|
"""The validator to check if the base account code exists."""
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||||
if field.data == "":
|
if field.data == "":
|
||||||
@ -42,25 +42,13 @@ class BaseAccountExists:
|
|||||||
"The base account does not exist."))
|
"The base account does not exist."))
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountAvailable:
|
|
||||||
"""The validator to check if the base account is available."""
|
|
||||||
|
|
||||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
|
||||||
if field.data == "":
|
|
||||||
return
|
|
||||||
if len(field.data) != 4:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"The base account is not available."))
|
|
||||||
|
|
||||||
|
|
||||||
class AccountForm(FlaskForm):
|
class AccountForm(FlaskForm):
|
||||||
"""The form to create or edit an account."""
|
"""The form to create or edit an account."""
|
||||||
base_code = StringField(
|
base_code = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
validators=[
|
validators=[
|
||||||
DataRequired(lazy_gettext("Please select the base account.")),
|
DataRequired(lazy_gettext("Please select the base account.")),
|
||||||
BaseAccountExists(),
|
BaseAccountExists()])
|
||||||
BaseAccountAvailable()])
|
|
||||||
"""The code of the base account."""
|
"""The code of the base account."""
|
||||||
title = StringField(
|
title = StringField(
|
||||||
filters=[strip_text],
|
filters=[strip_text],
|
||||||
|
@ -38,7 +38,7 @@ bp: Blueprint = Blueprint("account", __name__)
|
|||||||
@bp.get("", endpoint="list")
|
@bp.get("", endpoint="list")
|
||||||
@has_permission(can_view)
|
@has_permission(can_view)
|
||||||
def list_accounts() -> str:
|
def list_accounts() -> str:
|
||||||
"""Lists the accounts.
|
"""Lists the base accounts.
|
||||||
|
|
||||||
:return: The account list.
|
:return: The account list.
|
||||||
"""
|
"""
|
||||||
@ -139,7 +139,7 @@ def update_account(account: Account) -> redirect:
|
|||||||
account=account)))
|
account=account)))
|
||||||
with db.session.no_autoflush:
|
with db.session.no_autoflush:
|
||||||
form.populate_obj(account)
|
form.populate_obj(account)
|
||||||
if not account.is_modified:
|
if not db.session.is_modified(account):
|
||||||
flash(lazy_gettext("The account was not modified."), "success")
|
flash(lazy_gettext("The account was not modified."), "success")
|
||||||
return redirect(inherit_next(url_for("accounting.account.detail",
|
return redirect(inherit_next(url_for("accounting.account.detail",
|
||||||
account=account)))
|
account=account)))
|
||||||
@ -159,7 +159,9 @@ def delete_account(account: Account) -> redirect:
|
|||||||
:return: The redirection to the account list on success, or the account
|
:return: The redirection to the account list on success, or the account
|
||||||
detail on error.
|
detail on error.
|
||||||
"""
|
"""
|
||||||
account.delete()
|
for l10n in account.l10n:
|
||||||
|
db.session.delete(l10n)
|
||||||
|
db.session.delete(account)
|
||||||
sort_accounts_in(account.base_code, account.id)
|
sort_accounts_in(account.base_code, account.id)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
flash(lazy_gettext("The account is deleted successfully."), "success")
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The currency management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from flask import Flask, Blueprint
|
|
||||||
|
|
||||||
|
|
||||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
|
||||||
"""Initialize the application.
|
|
||||||
|
|
||||||
:param bp: The blueprint of the accounting application.
|
|
||||||
:param app: The Flask application.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from .converters import CurrencyConverter
|
|
||||||
app.url_map.converters["currency"] = CurrencyConverter
|
|
||||||
|
|
||||||
from .views import bp as currency_bp, api_bp as currency_api_bp
|
|
||||||
bp.register_blueprint(currency_bp, url_prefix="/currencies")
|
|
||||||
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
|
|
||||||
|
|
||||||
from .commands import init_currencies_command
|
|
||||||
app.cli.add_command(init_currencies_command)
|
|
@ -1,78 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The console commands for the currency management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
import click
|
|
||||||
from flask.cli import with_appcontext
|
|
||||||
|
|
||||||
from accounting.database import db
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
|
||||||
from accounting.utils.user import has_user, get_user_pk
|
|
||||||
|
|
||||||
CurrencyData = tuple[str, str, str, str]
|
|
||||||
|
|
||||||
|
|
||||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
|
||||||
value: str) -> str:
|
|
||||||
"""Validates the username for the click console command.
|
|
||||||
|
|
||||||
:param ctx: The console command context.
|
|
||||||
:param param: The console command option.
|
|
||||||
:param value: The username.
|
|
||||||
:raise click.BadParameter: When validation fails.
|
|
||||||
:return: The username.
|
|
||||||
"""
|
|
||||||
value = value.strip()
|
|
||||||
if value == "":
|
|
||||||
raise click.BadParameter("Username empty.")
|
|
||||||
if not has_user(value):
|
|
||||||
raise click.BadParameter(f"User {value} does not exist.")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@click.command("accounting-init-currencies")
|
|
||||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
|
||||||
help="The username.", callback=__validate_username,
|
|
||||||
default=lambda: os.getlogin())
|
|
||||||
@with_appcontext
|
|
||||||
def init_currencies_command(username: str) -> None:
|
|
||||||
"""Initializes the currencies."""
|
|
||||||
data: list[CurrencyData] = [
|
|
||||||
("TWD", "New Taiwan dollar", "新臺幣", "新台币"),
|
|
||||||
("USD", "United States dollar", "美元", "美元"),
|
|
||||||
]
|
|
||||||
creator_pk: int = get_user_pk(username)
|
|
||||||
existing: list[Currency] = Currency.query.all()
|
|
||||||
existing_code: set[str] = {x.code for x in existing}
|
|
||||||
to_add: list[CurrencyData] = [x for x in data if x[0] not in existing_code]
|
|
||||||
if len(to_add) == 0:
|
|
||||||
click.echo("No more currency to add.")
|
|
||||||
return
|
|
||||||
|
|
||||||
db.session.bulk_save_objects(
|
|
||||||
[Currency(code=x[0], name_l10n=x[1],
|
|
||||||
created_by_id=creator_pk, updated_by_id=creator_pk)
|
|
||||||
for x in data])
|
|
||||||
db.session.bulk_save_objects(
|
|
||||||
[CurrencyL10n(currency_code=x[0], locale=y[0], name=y[1])
|
|
||||||
for x in data for y in (("zh_Hant", x[2]), ("zh_Hans", x[3]))])
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
click.echo(F"{len(to_add)} added. Currencies initialized.")
|
|
@ -1,48 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The path converters for the currency management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from flask import abort
|
|
||||||
from werkzeug.routing import BaseConverter
|
|
||||||
|
|
||||||
from accounting.database import db
|
|
||||||
from accounting.models import Currency
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyConverter(BaseConverter):
|
|
||||||
"""The currency converter to convert the currency code and to the
|
|
||||||
corresponding currency in the routes."""
|
|
||||||
|
|
||||||
def to_python(self, value: str) -> Currency:
|
|
||||||
"""Converts a currency code to a currency.
|
|
||||||
|
|
||||||
:param value: The currency code.
|
|
||||||
:return: The corresponding currency.
|
|
||||||
"""
|
|
||||||
currency: Currency | None = db.session.get(Currency, value)
|
|
||||||
if currency is None:
|
|
||||||
abort(404)
|
|
||||||
return currency
|
|
||||||
|
|
||||||
def to_url(self, value: Currency) -> str:
|
|
||||||
"""Converts a currency to its code.
|
|
||||||
|
|
||||||
:param value: The currency.
|
|
||||||
:return: The code.
|
|
||||||
"""
|
|
||||||
return value.code
|
|
@ -1,93 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The forms for the currency management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask_wtf import FlaskForm
|
|
||||||
from wtforms import StringField, ValidationError
|
|
||||||
from wtforms.validators import DataRequired, Regexp, NoneOf
|
|
||||||
|
|
||||||
from accounting.database import db
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import Currency
|
|
||||||
from accounting.utils.strip_text import strip_text
|
|
||||||
from accounting.utils.user import get_current_user_pk
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyForm(FlaskForm):
|
|
||||||
"""The form to create or edit a currency."""
|
|
||||||
CODE_BLOCKLIST: list[str] = ["create", "store", "exists-code"]
|
|
||||||
"""The reserved codes that are not available."""
|
|
||||||
|
|
||||||
class CodeUnique:
|
|
||||||
"""The validator to check if the code is unique."""
|
|
||||||
def __call__(self, form: CurrencyForm, field: StringField) -> None:
|
|
||||||
if field.data == "":
|
|
||||||
return
|
|
||||||
if form.obj_code is not None and form.obj_code == field.data:
|
|
||||||
return
|
|
||||||
if db.session.get(Currency, field.data) is not None:
|
|
||||||
raise ValidationError(lazy_gettext(
|
|
||||||
"Code conflicts with another currency."))
|
|
||||||
|
|
||||||
code = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the code.")),
|
|
||||||
Regexp(r"^[A-Z]{3}$",
|
|
||||||
message=lazy_gettext(
|
|
||||||
"Code can only be composed of 3 upper-cased"
|
|
||||||
" letters.")),
|
|
||||||
NoneOf(CODE_BLOCKLIST, message=lazy_gettext(
|
|
||||||
"This code is not available.")),
|
|
||||||
CodeUnique()])
|
|
||||||
"""The code. It may not conflict with another currency."""
|
|
||||||
name = StringField(
|
|
||||||
filters=[strip_text],
|
|
||||||
validators=[DataRequired(lazy_gettext("Please fill in the name."))])
|
|
||||||
"""The name."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.obj_code: str | None = None
|
|
||||||
"""The current code of the currency, or None when adding a new
|
|
||||||
currency."""
|
|
||||||
|
|
||||||
def populate_obj(self, obj: Currency) -> None:
|
|
||||||
"""Populates the form data into a currency object.
|
|
||||||
|
|
||||||
:param obj: The currency object.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
is_new: bool = obj.code is None
|
|
||||||
obj.code = self.code.data
|
|
||||||
obj.name = self.name.data
|
|
||||||
if is_new:
|
|
||||||
current_user_pk: int = get_current_user_pk()
|
|
||||||
obj.created_by_id = current_user_pk
|
|
||||||
obj.updated_by_id = current_user_pk
|
|
||||||
|
|
||||||
def post_update(self, obj) -> None:
|
|
||||||
"""The post-processing after the update.
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
current_user_pk: int = get_current_user_pk()
|
|
||||||
obj.updated_by_id = current_user_pk
|
|
||||||
obj.updated_at = sa.func.now()
|
|
@ -1,44 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The currency query.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from flask import request
|
|
||||||
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
|
||||||
from accounting.utils.query import parse_query_keywords
|
|
||||||
|
|
||||||
|
|
||||||
def get_currency_query() -> list[Currency]:
|
|
||||||
"""Returns the base accounts, optionally filtered by the query.
|
|
||||||
|
|
||||||
:return: The base accounts.
|
|
||||||
"""
|
|
||||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
|
||||||
if len(keywords) == 0:
|
|
||||||
return Currency.query.order_by(Currency.code).all()
|
|
||||||
conditions: list[sa.BinaryExpression] = []
|
|
||||||
for k in keywords:
|
|
||||||
l10n: list[CurrencyL10n] = CurrencyL10n.query\
|
|
||||||
.filter(CurrencyL10n.name.contains(k)).all()
|
|
||||||
l10n_matches: set[str] = {x.account_code for x in l10n}
|
|
||||||
conditions.append(sa.or_(Currency.code.contains(k),
|
|
||||||
Currency.name_l10n.contains(k),
|
|
||||||
Currency.code.in_(l10n_matches)))
|
|
||||||
return Currency.query.filter(*conditions)\
|
|
||||||
.order_by(Currency.code).all()
|
|
@ -1,178 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The views for the currency management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from urllib.parse import urlencode, parse_qsl
|
|
||||||
|
|
||||||
from flask import Blueprint, render_template, redirect, session, request, \
|
|
||||||
flash, url_for
|
|
||||||
from werkzeug.datastructures import ImmutableMultiDict
|
|
||||||
|
|
||||||
from accounting.database import db
|
|
||||||
from accounting.locale import lazy_gettext
|
|
||||||
from accounting.models import Currency
|
|
||||||
from accounting.utils.next_url import inherit_next, or_next
|
|
||||||
from accounting.utils.pagination import Pagination
|
|
||||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
|
||||||
from .forms import CurrencyForm
|
|
||||||
|
|
||||||
bp: Blueprint = Blueprint("currency", __name__)
|
|
||||||
"""The view blueprint for the currency management."""
|
|
||||||
api_bp: Blueprint = Blueprint("currency-api", __name__)
|
|
||||||
"""The view blueprint for the currency management API."""
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("", endpoint="list")
|
|
||||||
@has_permission(can_view)
|
|
||||||
def list_currencies() -> str:
|
|
||||||
"""Lists the currencies.
|
|
||||||
|
|
||||||
:return: The currency list.
|
|
||||||
"""
|
|
||||||
from .query import get_currency_query
|
|
||||||
currencies: list[Currency] = get_currency_query()
|
|
||||||
pagination: Pagination = Pagination[Currency](currencies)
|
|
||||||
return render_template("accounting/currency/list.html",
|
|
||||||
list=pagination.list, pagination=pagination)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/create", endpoint="create")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def show_add_currency_form() -> str:
|
|
||||||
"""Shows the form to add a currency.
|
|
||||||
|
|
||||||
:return: The form to add a currency.
|
|
||||||
"""
|
|
||||||
if "form" in session:
|
|
||||||
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
|
||||||
del session["form"]
|
|
||||||
form.validate()
|
|
||||||
else:
|
|
||||||
form = CurrencyForm()
|
|
||||||
return render_template("accounting/currency/create.html",
|
|
||||||
form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/store", endpoint="store")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def add_currency() -> redirect:
|
|
||||||
"""Adds a currency.
|
|
||||||
|
|
||||||
:return: The redirection to the currency detail on success, or the currency
|
|
||||||
creation form on error.
|
|
||||||
"""
|
|
||||||
form = CurrencyForm(request.form)
|
|
||||||
if not form.validate():
|
|
||||||
for key in form.errors:
|
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
|
||||||
return redirect(inherit_next(url_for("accounting.currency.create")))
|
|
||||||
currency: Currency = Currency()
|
|
||||||
form.populate_obj(currency)
|
|
||||||
db.session.add(currency)
|
|
||||||
db.session.commit()
|
|
||||||
flash(lazy_gettext("The currency is added successfully"), "success")
|
|
||||||
return redirect(inherit_next(url_for("accounting.currency.detail",
|
|
||||||
currency=currency)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<currency:currency>", endpoint="detail")
|
|
||||||
@has_permission(can_view)
|
|
||||||
def show_currency_detail(currency: Currency) -> str:
|
|
||||||
"""Shows the currency detail.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:return: The detail.
|
|
||||||
"""
|
|
||||||
return render_template("accounting/currency/detail.html", obj=currency)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<currency:currency>/edit", endpoint="edit")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def show_currency_edit_form(currency: Currency) -> str:
|
|
||||||
"""Shows the form to edit a currency.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:return: The form to edit the currency.
|
|
||||||
"""
|
|
||||||
form: CurrencyForm
|
|
||||||
if "form" in session:
|
|
||||||
form = CurrencyForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
|
||||||
del session["form"]
|
|
||||||
form.validate()
|
|
||||||
else:
|
|
||||||
form = CurrencyForm(obj=currency)
|
|
||||||
return render_template("accounting/currency/edit.html",
|
|
||||||
currency=currency, form=form)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<currency:currency>/update", endpoint="update")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def update_currency(currency: Currency) -> redirect:
|
|
||||||
"""Updates a currency.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:return: The redirection to the currency detail on success, or the currency
|
|
||||||
edit form on error.
|
|
||||||
"""
|
|
||||||
form = CurrencyForm(request.form)
|
|
||||||
form.obj_code = currency.code
|
|
||||||
if not form.validate():
|
|
||||||
for key in form.errors:
|
|
||||||
for error in form.errors[key]:
|
|
||||||
flash(error, "error")
|
|
||||||
session["form"] = urlencode(list(request.form.items()))
|
|
||||||
return redirect(inherit_next(url_for("accounting.currency.edit",
|
|
||||||
currency=currency)))
|
|
||||||
with db.session.no_autoflush:
|
|
||||||
form.populate_obj(currency)
|
|
||||||
if not currency.is_modified:
|
|
||||||
flash(lazy_gettext("The currency was not modified."), "success")
|
|
||||||
return redirect(inherit_next(url_for("accounting.currency.detail",
|
|
||||||
currency=currency)))
|
|
||||||
form.post_update(currency)
|
|
||||||
db.session.commit()
|
|
||||||
flash(lazy_gettext("The currency is updated successfully."), "success")
|
|
||||||
return redirect(inherit_next(url_for("accounting.currency.detail",
|
|
||||||
currency=currency)))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<currency:currency>/delete", endpoint="delete")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def delete_currency(currency: Currency) -> redirect:
|
|
||||||
"""Deletes a currency.
|
|
||||||
|
|
||||||
:param currency: The currency.
|
|
||||||
:return: The redirection to the currency list on success, or the currency
|
|
||||||
detail on error.
|
|
||||||
"""
|
|
||||||
currency.delete()
|
|
||||||
db.session.commit()
|
|
||||||
flash(lazy_gettext("The currency is deleted successfully."), "success")
|
|
||||||
return redirect(or_next(url_for("accounting.currency.list")))
|
|
||||||
|
|
||||||
|
|
||||||
@api_bp.get("/exists-code", endpoint="exists")
|
|
||||||
@has_permission(can_edit)
|
|
||||||
def exists_code() -> dict[str, bool]:
|
|
||||||
"""Validates whether a currency code exists.
|
|
||||||
|
|
||||||
:return: Whether the currency code exists.
|
|
||||||
"""
|
|
||||||
return {"exists": db.session.get(Currency, request.args["q"]) is not None}
|
|
@ -187,14 +187,16 @@ class Account(db.Model):
|
|||||||
if l10n.locale == current_locale:
|
if l10n.locale == current_locale:
|
||||||
l10n.title = value
|
l10n.title = value
|
||||||
return
|
return
|
||||||
self.l10n.append(AccountL10n(locale=current_locale, title=value))
|
self.l10n.append(AccountL10n(
|
||||||
|
locale=current_locale, title=value))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_by_code(cls, code: str) -> t.Self | None:
|
def find_by_code(cls, code: str) -> t.Self | None:
|
||||||
"""Finds an account by its code.
|
"""Finds an accounting account by its code.
|
||||||
|
|
||||||
:param code: The code.
|
:param code: The code.
|
||||||
:return: The account, or None if this account does not exist.
|
:return: The accounting account, or None if this account does not
|
||||||
|
exist.
|
||||||
"""
|
"""
|
||||||
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
||||||
if m is None:
|
if m is None:
|
||||||
@ -291,21 +293,8 @@ class Account(db.Model):
|
|||||||
"""
|
"""
|
||||||
return cls.find_by_code(cls.__NET_CHANGE)
|
return cls.find_by_code(cls.__NET_CHANGE)
|
||||||
|
|
||||||
@property
|
|
||||||
def is_modified(self) -> bool:
|
|
||||||
"""Returns whether a product account was modified.
|
|
||||||
|
|
||||||
:return: True if modified, or False otherwise.
|
|
||||||
"""
|
|
||||||
if db.session.is_modified(self):
|
|
||||||
return True
|
|
||||||
for l10n in self.l10n:
|
|
||||||
if db.session.is_modified(l10n):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
def delete(self) -> None:
|
||||||
"""Deletes this account.
|
"""Deletes this accounting account.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
@ -317,128 +306,11 @@ class Account(db.Model):
|
|||||||
class AccountL10n(db.Model):
|
class AccountL10n(db.Model):
|
||||||
"""A localized account title."""
|
"""A localized account title."""
|
||||||
__tablename__ = "accounting_accounts_l10n"
|
__tablename__ = "accounting_accounts_l10n"
|
||||||
"""The table name."""
|
|
||||||
account_id = db.Column(db.Integer,
|
account_id = db.Column(db.Integer,
|
||||||
db.ForeignKey(Account.id, onupdate="CASCADE",
|
db.ForeignKey(Account.id, onupdate="CASCADE",
|
||||||
ondelete="CASCADE"),
|
ondelete="CASCADE"),
|
||||||
nullable=False, primary_key=True)
|
nullable=False, primary_key=True)
|
||||||
"""The account ID."""
|
|
||||||
account = db.relationship(Account, back_populates="l10n")
|
account = db.relationship(Account, back_populates="l10n")
|
||||||
"""The account."""
|
|
||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||||
"""The locale."""
|
|
||||||
title = db.Column(db.String, nullable=False)
|
title = db.Column(db.String, nullable=False)
|
||||||
"""The localized title."""
|
db.UniqueConstraint(account_id, locale)
|
||||||
|
|
||||||
|
|
||||||
class Currency(db.Model):
|
|
||||||
"""A currency."""
|
|
||||||
__tablename__ = "accounting_currencies"
|
|
||||||
"""The table name."""
|
|
||||||
code = db.Column(db.String, nullable=False, primary_key=True)
|
|
||||||
"""The code."""
|
|
||||||
name_l10n = db.Column("name", db.String, nullable=False)
|
|
||||||
"""The name."""
|
|
||||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
|
||||||
server_default=db.func.now())
|
|
||||||
"""The time of creation."""
|
|
||||||
created_by_id = db.Column(db.Integer,
|
|
||||||
db.ForeignKey(user_pk_column,
|
|
||||||
onupdate="CASCADE"),
|
|
||||||
nullable=False)
|
|
||||||
"""The ID of the creator."""
|
|
||||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
|
||||||
"""The creator."""
|
|
||||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
|
||||||
server_default=db.func.now())
|
|
||||||
"""The time of last update."""
|
|
||||||
updated_by_id = db.Column(db.Integer,
|
|
||||||
db.ForeignKey(user_pk_column,
|
|
||||||
onupdate="CASCADE"),
|
|
||||||
nullable=False)
|
|
||||||
"""The ID of the updator."""
|
|
||||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
|
||||||
"""The updator."""
|
|
||||||
l10n = db.relationship("CurrencyL10n", back_populates="currency",
|
|
||||||
lazy=False)
|
|
||||||
"""The localized names."""
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""Returns the string representation of the currency.
|
|
||||||
|
|
||||||
:return: The string representation of the currency.
|
|
||||||
"""
|
|
||||||
return F"{self.name} ({self.code})"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
"""Returns the name in the current locale.
|
|
||||||
|
|
||||||
:return: The name in the current locale.
|
|
||||||
"""
|
|
||||||
current_locale = str(get_locale())
|
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
|
||||||
return self.name_l10n
|
|
||||||
for l10n in self.l10n:
|
|
||||||
if l10n.locale == current_locale:
|
|
||||||
return l10n.name
|
|
||||||
return self.name_l10n
|
|
||||||
|
|
||||||
@name.setter
|
|
||||||
def name(self, value: str) -> None:
|
|
||||||
"""Sets the name in the current locale.
|
|
||||||
|
|
||||||
:param value: The new name.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
if self.name_l10n is None:
|
|
||||||
self.name_l10n = value
|
|
||||||
return
|
|
||||||
current_locale = str(get_locale())
|
|
||||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
|
||||||
self.name_l10n = value
|
|
||||||
return
|
|
||||||
for l10n in self.l10n:
|
|
||||||
if l10n.locale == current_locale:
|
|
||||||
l10n.name = value
|
|
||||||
return
|
|
||||||
self.l10n.append(CurrencyL10n(locale=current_locale, name=value))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_modified(self) -> bool:
|
|
||||||
"""Returns whether a product account was modified.
|
|
||||||
|
|
||||||
:return: True if modified, or False otherwise.
|
|
||||||
"""
|
|
||||||
if db.session.is_modified(self):
|
|
||||||
return True
|
|
||||||
for l10n in self.l10n:
|
|
||||||
if db.session.is_modified(l10n):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def delete(self) -> None:
|
|
||||||
"""Deletes the currency.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
|
|
||||||
cls: t.Type[t.Self] = self.__class__
|
|
||||||
cls.query.filter(cls.code == self.code).delete()
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyL10n(db.Model):
|
|
||||||
"""A localized currency name."""
|
|
||||||
__tablename__ = "accounting_currencies_l10n"
|
|
||||||
"""The table name."""
|
|
||||||
currency_code = db.Column(db.String,
|
|
||||||
db.ForeignKey(Currency.code, onupdate="CASCADE",
|
|
||||||
ondelete="CASCADE"),
|
|
||||||
nullable=False, primary_key=True)
|
|
||||||
"""The currency code."""
|
|
||||||
currency = db.relationship(Currency, back_populates="l10n")
|
|
||||||
"""The currency."""
|
|
||||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
|
||||||
"""The locale."""
|
|
||||||
name = db.Column(db.String, nullable=False)
|
|
||||||
"""The localized name."""
|
|
||||||
|
@ -56,23 +56,6 @@
|
|||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The currency management. */
|
|
||||||
.currency {
|
|
||||||
padding: 2em 1.5em;
|
|
||||||
margin: 1em;
|
|
||||||
background-color: #E9ECEF;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
|
|
||||||
}
|
|
||||||
.currency .currency-name {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: bolder;
|
|
||||||
}
|
|
||||||
.currency .currency-code {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
color: #373b3e;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Material Design text field (floating form control in Bootstrap) */
|
/* The Material Design text field (floating form control in Bootstrap) */
|
||||||
.material-text-field {
|
.material-text-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -1,174 +0,0 @@
|
|||||||
/* The Mia! Accounting Flask Project
|
|
||||||
* currency-form.js: The JavaScript for the currency form
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Copyright (c) 2023 imacat.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
* First written: 2023/2/6
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Initializes the page JavaScript.
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
|
||||||
document.getElementById("currency-code")
|
|
||||||
.onchange = validateCode;
|
|
||||||
document.getElementById("currency-name")
|
|
||||||
.onchange = validateName;
|
|
||||||
document.getElementById("currency-form")
|
|
||||||
.onsubmit = validateForm;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The asynchronous validation result
|
|
||||||
* @type {object}
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
let isAsyncValid = {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the form.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateForm() {
|
|
||||||
isAsyncValid = {
|
|
||||||
"code": false,
|
|
||||||
"_sync": false,
|
|
||||||
};
|
|
||||||
let isValid = true;
|
|
||||||
isValid = validateCode() && isValid;
|
|
||||||
isValid = validateName() && isValid;
|
|
||||||
isAsyncValid["_sync"] = isValid;
|
|
||||||
submitFormIfAllAsyncValid();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Submits the form if the whole form passed the asynchronous
|
|
||||||
* validations.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function submitFormIfAllAsyncValid() {
|
|
||||||
let isValid = true;
|
|
||||||
Object.keys(isAsyncValid).forEach(function (key) {
|
|
||||||
isValid = isAsyncValid[key] && isValid;
|
|
||||||
});
|
|
||||||
if (isValid) {
|
|
||||||
document.getElementById("currency-form").submit()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the code.
|
|
||||||
*
|
|
||||||
* @param changeEvent {Event} the change event, if invoked from onchange
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateCode(changeEvent = null) {
|
|
||||||
const key = "code";
|
|
||||||
const isSubmission = changeEvent === null;
|
|
||||||
let hasAsyncValidation = false;
|
|
||||||
const field = document.getElementById("currency-code");
|
|
||||||
const error = document.getElementById("currency-code-error");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
if (field.value === "") {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the code.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const blocklist = JSON.parse(field.dataset.blocklist);
|
|
||||||
if (blocklist.includes(field.value)) {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("This code is not available.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!field.value.match(/^[A-Z]{3}$/)) {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Code can only be composed of 3 upper-cased letters.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const original = field.dataset.original;
|
|
||||||
if (original === "" || field.value !== original) {
|
|
||||||
hasAsyncValidation = true;
|
|
||||||
validateAsyncCodeIsDuplicated(isSubmission, key);
|
|
||||||
}
|
|
||||||
if (!hasAsyncValidation) {
|
|
||||||
isAsyncValid[key] = true;
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates asynchronously whether the code is duplicated.
|
|
||||||
* The boolean validation result is stored in isAsyncValid[key].
|
|
||||||
*
|
|
||||||
* @param isSubmission {boolean} whether this is invoked from a form submission
|
|
||||||
* @param key {string} the key to store the result in isAsyncValid
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateAsyncCodeIsDuplicated(isSubmission, key) {
|
|
||||||
const field = document.getElementById("currency-code");
|
|
||||||
const error = document.getElementById("currency-code-error");
|
|
||||||
const url = field.dataset.existsUrl;
|
|
||||||
const onLoad = function () {
|
|
||||||
if (this.status === 200) {
|
|
||||||
const result = JSON.parse(this.responseText);
|
|
||||||
if (result["exists"]) {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = _("Code conflicts with another currency.");
|
|
||||||
if (isSubmission) {
|
|
||||||
isAsyncValid[key] = false;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
if (isSubmission) {
|
|
||||||
isAsyncValid[key] = true;
|
|
||||||
submitFormIfAllAsyncValid();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const request = new XMLHttpRequest();
|
|
||||||
request.onload = onLoad;
|
|
||||||
request.open("GET", url + "?q=" + encodeURIComponent(field.value));
|
|
||||||
request.send();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the name.
|
|
||||||
*
|
|
||||||
* @returns {boolean} true if valid, or false otherwise
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
function validateName() {
|
|
||||||
const field = document.getElementById("currency-name");
|
|
||||||
const error = document.getElementById("currency-name-error");
|
|
||||||
field.value = field.value.trim();
|
|
||||||
if (field.value === "") {
|
|
||||||
field.classList.add("is-invalid");
|
|
||||||
error.innerText = A_("Please fill in the name.");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
field.classList.remove("is-invalid");
|
|
||||||
error.innerText = "";
|
|
||||||
return true;
|
|
||||||
}
|
|
@ -28,7 +28,7 @@ First written: 2023/1/30
|
|||||||
<div class="btn-group mb-2">
|
<div class="btn-group mb-2">
|
||||||
{% if can_edit_accounting() %}
|
{% if can_edit_accounting() %}
|
||||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
|
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
|
||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-user-plus"></i>
|
||||||
{{ A_("New") }}
|
{{ A_("New") }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
{#
|
|
||||||
The Mia! Accounting Flask Project
|
|
||||||
create.html: The currency creation form
|
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
First written: 2023/2/6
|
|
||||||
#}
|
|
||||||
{% extends "accounting/currency/include/form.html" %}
|
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
|
|
||||||
|
|
||||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
|
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}
|
|
@ -1,90 +0,0 @@
|
|||||||
{#
|
|
||||||
The Mia! Accounting Flask Project
|
|
||||||
detail.html: The currency detail
|
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
First written: 2023/2/6
|
|
||||||
#}
|
|
||||||
{% extends "accounting/base.html" %}
|
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="btn-group mb-3">
|
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.currency.list")|or_next }}">
|
|
||||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
|
||||||
{{ A_("Back") }}
|
|
||||||
</a>
|
|
||||||
{% if can_edit_accounting() %}
|
|
||||||
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|inherit_next }}">
|
|
||||||
<i class="fa-solid fa-gear"></i>
|
|
||||||
{{ A_("Settings") }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if can_edit_accounting() %}
|
|
||||||
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
{{ A_("Delete") }}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if can_edit_accounting() %}
|
|
||||||
<div class="d-md-none material-fab">
|
|
||||||
<a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|inherit_next }}">
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if can_edit_accounting() %}
|
|
||||||
<form id="delete-form" action="{{ url_for("accounting.currency.delete", currency=obj) }}" method="post">
|
|
||||||
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
||||||
{% if "next" in request.args %}
|
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
|
||||||
{% endif %}
|
|
||||||
<div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Currency Confirmation") }}</h1>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
{{ A_("Do you really want to delete this currency?") }}
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
|
|
||||||
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="currency col-sm-6">
|
|
||||||
<div class="currency-name">{{ obj.name }}</div>
|
|
||||||
<div class="currency-code">{{ obj.code }}</div>
|
|
||||||
<div class="small text-secondary fst-italic">
|
|
||||||
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
|
|
||||||
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -1,30 +0,0 @@
|
|||||||
{#
|
|
||||||
The Mia! Accounting Flask Project
|
|
||||||
edit.html: The currency edit form
|
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
First written: 2023/2/6
|
|
||||||
#}
|
|
||||||
{% extends "accounting/currency/include/form.html" %}
|
|
||||||
|
|
||||||
{% block header %}{% block title %}{{ A_("%(currency)s Settings", currency=currency) }}{% endblock %}{% endblock %}
|
|
||||||
|
|
||||||
{% block back_url %}{{ url_for("accounting.currency.detail", currency=currency)|inherit_next }}{% endblock %}
|
|
||||||
|
|
||||||
{% block action_url %}{{ url_for("accounting.currency.update", currency=currency) }}{% endblock %}
|
|
||||||
|
|
||||||
{% block original_code %}{{ currency.code }}{% endblock %}
|
|
@ -1,68 +0,0 @@
|
|||||||
{#
|
|
||||||
The Mia! Accounting Flask Project
|
|
||||||
form.html: The currency form
|
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
First written: 2023/2/6
|
|
||||||
#}
|
|
||||||
{% extends "accounting/base.html" %}
|
|
||||||
|
|
||||||
{% block accounting_scripts %}
|
|
||||||
<script src="{{ url_for("accounting.static", filename="js/currency-form.js") }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="btn-group btn-actions mb-3">
|
|
||||||
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
|
|
||||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
|
||||||
{{ A_("Back") }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="currency-form" action="{% block action_url %}{% endblock %}" method="post">
|
|
||||||
{{ form.csrf_token }}
|
|
||||||
{% if "next" in request.args %}
|
|
||||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
|
||||||
{% endif %}
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input id="currency-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ "" if form.code.data is none else form.code.data }}" placeholder=" " required="required" data-exists-url="{{ url_for("accounting.currency-api.exists") }}" data-original="{% block original_code %}{% endblock %}" data-blocklist="{{ form.CODE_BLOCKLIST|tojson|forceescape }}">
|
|
||||||
<label class="form-label" for="currency-code">{{ A_("Code") }}</label>
|
|
||||||
<div id="currency-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors[0] }}{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input id="currency-name" class="form-control {% if form.name.errors %} is-invalid {% endif %}" type="text" name="name" value="{{ "" if form.name.data is none else form.name.data }}" placeholder=" " required="required">
|
|
||||||
<label class="form-label" for="currency-name">{{ A_("Name") }}</label>
|
|
||||||
<div id="currency-name-error" class="invalid-feedback">{% if form.name.errors %}{{ form.name.errors[0] }}{% endif %}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-none d-md-block">
|
|
||||||
<button class="btn btn-primary" type="submit">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i>
|
|
||||||
{{ A_("Save") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-md-none material-fab">
|
|
||||||
<button class="btn btn-primary" type="submit">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -1,60 +0,0 @@
|
|||||||
{#
|
|
||||||
The Mia! Accounting Flask Project
|
|
||||||
list.html: The currency list
|
|
||||||
|
|
||||||
Copyright (c) 2023 imacat.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
|
|
||||||
Author: imacat@mail.imacat.idv.tw (imacat)
|
|
||||||
First written: 2023/2/6
|
|
||||||
#}
|
|
||||||
{% extends "accounting/base.html" %}
|
|
||||||
|
|
||||||
{% block header %}{% block title %}{% if "q" in request.args %}{{ A_("Search Result for \"%(query)s\"", query=request.args["q"]) }}{% else %}{{ A_("Currency Management") }}{% endif %}{% endblock %}{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
|
|
||||||
<div class="btn-group mb-2">
|
|
||||||
{% if can_edit_accounting() %}
|
|
||||||
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|append_next }}">
|
|
||||||
<i class="fa-solid fa-plus"></i>
|
|
||||||
{{ A_("New") }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search">
|
|
||||||
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
|
|
||||||
<label for="search-input" class="search-label">
|
|
||||||
<button type="submit">
|
|
||||||
<i class="fa-solid fa-magnifying-glass"></i>
|
|
||||||
{{ A_("Search") }}
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if list %}
|
|
||||||
{% include "accounting/include/pagination.html" %}
|
|
||||||
|
|
||||||
<div class="list-group">
|
|
||||||
{% for item in list %}
|
|
||||||
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.currency.detail", currency=item)|append_next }}">
|
|
||||||
{{ item }}
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p>{{ A_("There is no data.") }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -38,12 +38,6 @@ First written: 2023/1/26
|
|||||||
{{ A_("Base Accounts") }}
|
{{ A_("Base Accounts") }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<a class="dropdown-item {% if request.endpoint.startswith("accounting.currency.") %} active {% endif %}" href="{{ url_for("accounting.currency.list") }}">
|
|
||||||
<i class="fa-solid fa-list"></i>
|
|
||||||
{{ A_("Currencies") }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -14,10 +14,10 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
"""The test for the account management.
|
"""The test for the account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -26,40 +26,8 @@ from click.testing import Result
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.testing import FlaskCliRunner
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
|
from testlib import UserClient, get_user_client
|
||||||
from test_site import create_app
|
from test_site import create_app
|
||||||
from testlib import get_client, set_locale
|
|
||||||
|
|
||||||
|
|
||||||
class AccountData:
|
|
||||||
"""The account data."""
|
|
||||||
|
|
||||||
def __init__(self, base_code: str, no: int, title: str):
|
|
||||||
"""Constructs the account data.
|
|
||||||
|
|
||||||
:param base_code: The base code.
|
|
||||||
:param no: The number.
|
|
||||||
:param title: The title.
|
|
||||||
"""
|
|
||||||
self.base_code: str = base_code
|
|
||||||
"""The base code."""
|
|
||||||
self.no: int = no
|
|
||||||
"""The number."""
|
|
||||||
self.title: str = title
|
|
||||||
"""The title."""
|
|
||||||
self.code: str = f"{self.base_code}-{self.no:03d}"
|
|
||||||
"""The code."""
|
|
||||||
|
|
||||||
|
|
||||||
cash: AccountData = AccountData("1111", 1, "Cash")
|
|
||||||
"""The cash account."""
|
|
||||||
bank: AccountData = AccountData("1113", 1, "Bank")
|
|
||||||
"""The bank account."""
|
|
||||||
stock: AccountData = AccountData("1121", 1, "Stock")
|
|
||||||
"""The stock account."""
|
|
||||||
loan: AccountData = AccountData("2112", 1, "Loan")
|
|
||||||
"""The loan account."""
|
|
||||||
PREFIX: str = "/accounting/accounts"
|
|
||||||
"""The URL prefix of the currency management."""
|
|
||||||
|
|
||||||
|
|
||||||
class AccountCommandTestCase(unittest.TestCase):
|
class AccountCommandTestCase(unittest.TestCase):
|
||||||
@ -141,24 +109,26 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
Account.query.delete()
|
Account.query.delete()
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
self.client, self.csrf_token = get_client(self, self.app, "editor")
|
editor: UserClient = get_user_client(self, self.app, "editor")
|
||||||
|
self.client: httpx.Client = editor.client
|
||||||
|
self.csrf_token: str = editor.csrf_token
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/store",
|
response = self.client.post("/accounting/accounts/store",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": cash.base_code,
|
"base_code": "1111",
|
||||||
"title": cash.title})
|
"title": "1111 title"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"],
|
self.assertEqual(response.headers["Location"],
|
||||||
f"{PREFIX}/{cash.code}")
|
"/accounting/accounts/1111-001")
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/store",
|
response = self.client.post("/accounting/accounts/store",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": bank.base_code,
|
"base_code": "1112",
|
||||||
"title": bank.title})
|
"title": "1112 title"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"],
|
self.assertEqual(response.headers["Location"],
|
||||||
f"{PREFIX}/{bank.code}")
|
"/accounting/accounts/1112-001")
|
||||||
|
|
||||||
def test_nobody(self) -> None:
|
def test_nobody(self) -> None:
|
||||||
"""Test the permission as nobody.
|
"""Test the permission as nobody.
|
||||||
@ -166,47 +136,47 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
client, csrf_token = get_client(self, self.app, "nobody")
|
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||||
|
|
||||||
response = client.get(PREFIX)
|
response = nobody.client.get("/accounting/accounts")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{cash.code}")
|
response = nobody.client.get("/accounting/accounts/1111-001")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/create")
|
response = nobody.client.get("/accounting/accounts/create")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/store",
|
response = nobody.client.post("/accounting/accounts/store",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": nobody.csrf_token,
|
||||||
"base_code": stock.base_code,
|
"base_code": "1113",
|
||||||
"title": stock.title})
|
"title": "1113 title"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{cash.code}/edit")
|
response = nobody.client.get("/accounting/accounts/1111-001/edit")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{cash.code}/update",
|
response = nobody.client.post("/accounting/accounts/1111-001/update",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": nobody.csrf_token,
|
||||||
"base_code": cash.base_code,
|
"base_code": "1111",
|
||||||
"title": f"{cash.title}-2"})
|
"title": "1111 title #2"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{cash.code}/delete",
|
response = nobody.client.post("/accounting/accounts/1111-001/delete",
|
||||||
data={"csrf_token": csrf_token})
|
data={"csrf_token": nobody.csrf_token})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
|
response = nobody.client.get("/accounting/accounts/bases/1111")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
bank_id: int = Account.find_by_code(bank.code).id
|
account_id: int = Account.find_by_code("1112-001").id
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
response = nobody.client.post("/accounting/accounts/bases/1112",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": nobody.csrf_token,
|
||||||
"next": "/next",
|
"next": "/next",
|
||||||
f"{bank_id}-no": "5"})
|
f"{account_id}-no": "5"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_viewer(self) -> None:
|
def test_viewer(self) -> None:
|
||||||
@ -215,47 +185,47 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
client, csrf_token = get_client(self, self.app, "viewer")
|
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||||
|
|
||||||
response = client.get(PREFIX)
|
response = viewer.client.get("/accounting/accounts")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{cash.code}")
|
response = viewer.client.get("/accounting/accounts/1111-001")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/create")
|
response = viewer.client.get("/accounting/accounts/create")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/store",
|
response = viewer.client.post("/accounting/accounts/store",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": viewer.csrf_token,
|
||||||
"base_code": stock.base_code,
|
"base_code": "1113",
|
||||||
"title": stock.title})
|
"title": "1113 title"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{cash.code}/edit")
|
response = viewer.client.get("/accounting/accounts/1111-001/edit")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{cash.code}/update",
|
response = viewer.client.post("/accounting/accounts/1111-001/update",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": viewer.csrf_token,
|
||||||
"base_code": cash.base_code,
|
"base_code": "1111",
|
||||||
"title": f"{cash.title}-2"})
|
"title": "1111 title #2"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{cash.code}/delete",
|
response = viewer.client.post("/accounting/accounts/1111-001/delete",
|
||||||
data={"csrf_token": csrf_token})
|
data={"csrf_token": viewer.csrf_token})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/bases/{cash.base_code}")
|
response = viewer.client.get("/accounting/accounts/bases/1111")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
bank_id: int = Account.find_by_code(bank.code).id
|
account_id: int = Account.find_by_code("1112-001").id
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/bases/{bank.base_code}",
|
response = viewer.client.post("/accounting/accounts/bases/1112",
|
||||||
data={"csrf_token": csrf_token,
|
data={"csrf_token": viewer.csrf_token,
|
||||||
"next": "/next",
|
"next": "/next",
|
||||||
f"{bank_id}-no": "5"})
|
f"{account_id}-no": "5"})
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_editor(self) -> None:
|
def test_editor(self) -> None:
|
||||||
@ -266,382 +236,107 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
response = self.client.get(PREFIX)
|
response = self.client.get("/accounting/accounts")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = self.client.get(f"{PREFIX}/{cash.code}")
|
response = self.client.get("/accounting/accounts/1111-001")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = self.client.get(f"{PREFIX}/create")
|
response = self.client.get("/accounting/accounts/create")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/store",
|
response = self.client.post("/accounting/accounts/store",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": stock.base_code,
|
"base_code": "1113",
|
||||||
"title": stock.title})
|
"title": "1113 title"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"],
|
self.assertEqual(response.headers["Location"],
|
||||||
f"{PREFIX}/{stock.code}")
|
"/accounting/accounts/1113-001")
|
||||||
|
|
||||||
response = self.client.get(f"{PREFIX}/{cash.code}/edit")
|
response = self.client.get("/accounting/accounts/1111-001/edit")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/{cash.code}/update",
|
response = self.client.post("/accounting/accounts/1111-001/update",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": cash.base_code,
|
"base_code": "1111",
|
||||||
"title": f"{cash.title}-2"})
|
"title": "1111 title #2"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{cash.code}")
|
self.assertEqual(response.headers["Location"],
|
||||||
|
"/accounting/accounts/1111-001")
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/{cash.code}/delete",
|
response = self.client.post("/accounting/accounts/1111-001/delete",
|
||||||
data={"csrf_token": self.csrf_token})
|
data={"csrf_token": self.csrf_token})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], PREFIX)
|
self.assertEqual(response.headers["Location"],
|
||||||
|
"/accounting/accounts")
|
||||||
|
|
||||||
response = self.client.get(f"{PREFIX}/bases/{cash.base_code}")
|
response = self.client.get("/accounting/accounts/bases/1111")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
bank_id: int = Account.find_by_code(bank.code).id
|
account_id: int = Account.find_by_code("1112-001").id
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/bases/{bank.base_code}",
|
response = self.client.post("/accounting/accounts/bases/1112",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"next": "/next",
|
"next": "/next",
|
||||||
f"{bank_id}-no": "5"})
|
f"{account_id}-no": "5"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], "/next")
|
self.assertEqual(response.headers["Location"], "/next")
|
||||||
|
|
||||||
def test_add(self) -> None:
|
def test_change_base(self) -> None:
|
||||||
"""Tests to add the currencies.
|
"""Tests to change the base account.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
from accounting.database import db
|
from accounting.database import db
|
||||||
from accounting.models import Account
|
from accounting.models import Account
|
||||||
create_uri: str = f"{PREFIX}/create"
|
|
||||||
store_uri: str = f"{PREFIX}/store"
|
|
||||||
detail_uri: str = f"{PREFIX}/{stock.code}"
|
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
with self.app.app_context():
|
response = self.client.post("/accounting/accounts/store",
|
||||||
self.assertEqual({x.code for x in Account.query.all()},
|
|
||||||
{cash.code, bank.code})
|
|
||||||
|
|
||||||
# Missing CSRF token
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"base_code": stock.base_code,
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
# CSRF token mismatch
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": f"{self.csrf_token}-2",
|
|
||||||
"base_code": stock.base_code,
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
# Empty base account code
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": " ",
|
"base_code": "1111",
|
||||||
"title": stock.title})
|
"title": "Title #1"})
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Non-existing base account
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": "9999",
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Unavailable base account
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": "1",
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Empty name
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": stock.base_code,
|
|
||||||
"title": " "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Success, with spaces to be stripped
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": f" {stock.base_code} ",
|
|
||||||
"title": f" {stock.title} "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
# Success under the same base
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": stock.base_code,
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"],
|
self.assertEqual(response.headers["Location"],
|
||||||
f"{PREFIX}/{stock.base_code}-002")
|
"/accounting/accounts/1111-002")
|
||||||
|
|
||||||
# Success under the same base, with order in a mess.
|
response = self.client.post("/accounting/accounts/store",
|
||||||
with self.app.app_context():
|
|
||||||
stock_2: Account = Account.find_by_code(f"{stock.base_code}-002")
|
|
||||||
stock_2.no = 66
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": stock.base_code,
|
"base_code": "1111",
|
||||||
"title": stock.title})
|
"title": "Title #1"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"],
|
self.assertEqual(response.headers["Location"],
|
||||||
f"{PREFIX}/{stock.base_code}-067")
|
"/accounting/accounts/1111-003")
|
||||||
|
|
||||||
with self.app.app_context():
|
response = self.client.post("/accounting/accounts/store",
|
||||||
self.assertEqual({x.code for x in Account.query.all()},
|
|
||||||
{cash.code, bank.code, stock.code,
|
|
||||||
f"{stock.base_code}-066",
|
|
||||||
f"{stock.base_code}-067"})
|
|
||||||
|
|
||||||
stock_account: Account = Account.find_by_code(stock.code)
|
|
||||||
self.assertEqual(stock_account.base_code, stock.base_code)
|
|
||||||
self.assertEqual(stock_account.title_l10n, stock.title)
|
|
||||||
|
|
||||||
def test_basic_update(self) -> None:
|
|
||||||
"""Tests the basic rules to update a user.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Account
|
|
||||||
detail_uri: str = f"{PREFIX}/{cash.code}"
|
|
||||||
edit_uri: str = f"{PREFIX}/{cash.code}/edit"
|
|
||||||
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
|
||||||
detail_c_uri: str = f"{PREFIX}/{stock.code}"
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
# Success, with spaces to be stripped
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": f" {cash.base_code} ",
|
"base_code": "1112",
|
||||||
"title": f" {cash.title}-1 "})
|
"title": "Title #1"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
self.assertEqual(response.headers["Location"],
|
||||||
|
"/accounting/accounts/1112-002")
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
id_1: int = Account.find_by_code("1111-001").id
|
||||||
self.assertEqual(cash_account.base_code, cash.base_code)
|
id_2: int = Account.find_by_code("1111-002").id
|
||||||
self.assertEqual(cash_account.title_l10n, f"{cash.title}-1")
|
id_3: int = Account.find_by_code("1111-003").id
|
||||||
|
id_4: int = Account.find_by_code("1112-001").id
|
||||||
|
id_5: int = Account.find_by_code("1112-002").id
|
||||||
|
|
||||||
# Empty base account code
|
response = self.client.post("/accounting/accounts/1111-002/update",
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": " ",
|
"base_code": "1112",
|
||||||
"title": stock.title})
|
"title": "Account #1"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
self.assertEqual(response.headers["Location"],
|
||||||
|
"/accounting/accounts/1112-003")
|
||||||
# Non-existing base account
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": "9999",
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Unavailable base account
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": "1",
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Empty name
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": stock.base_code,
|
|
||||||
"title": " "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Change the base account
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": stock.base_code,
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_c_uri)
|
|
||||||
|
|
||||||
response = self.client.get(detail_uri)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
response = self.client.get(detail_c_uri)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_update_not_modified(self) -> None:
|
|
||||||
"""Tests that the data is not modified.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Account
|
|
||||||
detail_uri: str = f"{PREFIX}/{cash.code}"
|
|
||||||
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
|
||||||
response: httpx.Response
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": f" {cash.base_code} ",
|
|
||||||
"title": f" {cash.title} "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
self.assertEqual(db.session.get(Account, id_1).code, "1111-001")
|
||||||
self.assertIsNotNone(cash_account)
|
self.assertEqual(db.session.get(Account, id_2).code, "1112-003")
|
||||||
self.assertEqual(cash_account.created_at, cash_account.updated_at)
|
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
|
||||||
|
self.assertEqual(db.session.get(Account, id_4).code, "1112-001")
|
||||||
response = self.client.post(update_uri,
|
self.assertEqual(db.session.get(Account, id_5).code, "1112-002")
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": cash.base_code,
|
|
||||||
"title": stock.title})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
|
||||||
self.assertIsNotNone(cash_account)
|
|
||||||
self.assertNotEqual(cash_account.created_at,
|
|
||||||
cash_account.updated_at)
|
|
||||||
|
|
||||||
def test_created_updated_by(self) -> None:
|
|
||||||
"""Tests the created-by and updated-by record.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Account
|
|
||||||
editor_username, editor2_username = "editor", "editor2"
|
|
||||||
client, csrf_token = get_client(self, self.app, editor2_username)
|
|
||||||
detail_uri: str = f"{PREFIX}/{cash.code}"
|
|
||||||
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
|
||||||
self.assertEqual(cash_account.created_by.username, editor_username)
|
|
||||||
self.assertEqual(cash_account.updated_by.username, editor_username)
|
|
||||||
|
|
||||||
response = client.post(update_uri,
|
|
||||||
data={"csrf_token": csrf_token,
|
|
||||||
"base_code": cash.base_code,
|
|
||||||
"title": f"{cash.title}-2"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
|
||||||
self.assertEqual(cash_account.created_by.username,
|
|
||||||
editor_username)
|
|
||||||
self.assertEqual(cash_account.updated_by.username,
|
|
||||||
editor2_username)
|
|
||||||
|
|
||||||
def test_l10n(self) -> None:
|
|
||||||
"""Tests the localization.
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
from accounting.models import Account
|
|
||||||
detail_uri: str = f"{PREFIX}/{cash.code}"
|
|
||||||
update_uri: str = f"{PREFIX}/{cash.code}/update"
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
|
||||||
self.assertEqual(cash_account.title_l10n, cash.title)
|
|
||||||
self.assertEqual(cash_account.l10n, [])
|
|
||||||
|
|
||||||
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": cash.base_code,
|
|
||||||
"title": f"{cash.title}-zh_Hant"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
|
||||||
self.assertEqual(cash_account.title_l10n, cash.title)
|
|
||||||
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
|
|
||||||
{("zh_Hant", f"{cash.title}-zh_Hant")})
|
|
||||||
|
|
||||||
set_locale(self, self.client, self.csrf_token, "en")
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": cash.base_code,
|
|
||||||
"title": f"{cash.title}-2"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
|
||||||
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
|
|
||||||
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
|
|
||||||
{("zh_Hant", f"{cash.title}-zh_Hant")})
|
|
||||||
|
|
||||||
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"base_code": cash.base_code,
|
|
||||||
"title": f"{cash.title}-zh_Hant-2"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
cash_account: Account = Account.find_by_code(cash.code)
|
|
||||||
self.assertEqual(cash_account.title_l10n, f"{cash.title}-2")
|
|
||||||
self.assertEqual({(x.locale, x.title) for x in cash_account.l10n},
|
|
||||||
{("zh_Hant", f"{cash.title}-zh_Hant-2")})
|
|
||||||
|
|
||||||
def test_delete(self) -> None:
|
|
||||||
"""Tests to delete a currency.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Account
|
|
||||||
detail_uri: str = f"{PREFIX}/{cash.code}"
|
|
||||||
delete_uri: str = f"{PREFIX}/{cash.code}/delete"
|
|
||||||
list_uri: str = PREFIX
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
self.assertEqual({x.code for x in Account.query.all()},
|
|
||||||
{cash.code, bank.code})
|
|
||||||
|
|
||||||
response = self.client.get(detail_uri)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
response = self.client.post(delete_uri,
|
|
||||||
data={"csrf_token": self.csrf_token})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], list_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
self.assertEqual({x.code for x in Account.query.all()},
|
|
||||||
{bank.code})
|
|
||||||
|
|
||||||
response = self.client.get(detail_uri)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
response = self.client.post(delete_uri,
|
|
||||||
data={"csrf_token": self.csrf_token})
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
def test_reorder(self) -> None:
|
def test_reorder(self) -> None:
|
||||||
"""Tests to reorder the accounts under a same base account.
|
"""Tests to reorder the accounts under a same base account.
|
||||||
@ -653,13 +348,13 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
|
||||||
for i in range(2, 6):
|
for i in range(2, 6):
|
||||||
response = self.client.post(f"{PREFIX}/store",
|
response = self.client.post("/accounting/accounts/store",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"base_code": "1111",
|
"base_code": "1111",
|
||||||
"title": "Title"})
|
"title": "Title"})
|
||||||
self.assertEqual(response.status_code, 302)
|
self.assertEqual(response.status_code, 302)
|
||||||
self.assertEqual(response.headers["Location"],
|
self.assertEqual(response.headers["Location"],
|
||||||
f"{PREFIX}/1111-00{i}")
|
f"/accounting/accounts/1111-00{i}")
|
||||||
|
|
||||||
# Normal reorder
|
# Normal reorder
|
||||||
with self.app.app_context():
|
with self.app.app_context():
|
||||||
@ -669,7 +364,7 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
id_4: int = Account.find_by_code("1111-004").id
|
id_4: int = Account.find_by_code("1111-004").id
|
||||||
id_5: int = Account.find_by_code("1111-005").id
|
id_5: int = Account.find_by_code("1111-005").id
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
response = self.client.post("/accounting/accounts/bases/1111",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"next": "/next",
|
"next": "/next",
|
||||||
f"{id_1}-no": "4",
|
f"{id_1}-no": "4",
|
||||||
@ -696,7 +391,7 @@ class AccountTestCase(unittest.TestCase):
|
|||||||
db.session.get(Account, id_5).no = 9
|
db.session.get(Account, id_5).no = 9
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
response = self.client.post("/accounting/accounts/bases/1111",
|
||||||
data={"csrf_token": self.csrf_token,
|
data={"csrf_token": self.csrf_token,
|
||||||
"next": "/next",
|
"next": "/next",
|
||||||
f"{id_2}-no": "3a",
|
f"{id_2}-no": "3a",
|
||||||
|
@ -14,6 +14,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
"""The test for the base account management.
|
"""The test for the base account management.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@ -24,8 +25,8 @@ from click.testing import Result
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask.testing import FlaskCliRunner
|
from flask.testing import FlaskCliRunner
|
||||||
|
|
||||||
|
from testlib import UserClient, get_user_client
|
||||||
from test_site import create_app
|
from test_site import create_app
|
||||||
from testlib import get_client
|
|
||||||
|
|
||||||
|
|
||||||
class BaseAccountCommandTestCase(unittest.TestCase):
|
class BaseAccountCommandTestCase(unittest.TestCase):
|
||||||
@ -87,18 +88,22 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
result = runner.invoke(args="accounting-init-base")
|
result = runner.invoke(args="accounting-init-base")
|
||||||
self.assertEqual(result.exit_code, 0)
|
self.assertEqual(result.exit_code, 0)
|
||||||
|
|
||||||
|
self.viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||||
|
self.editor: UserClient = get_user_client(self, self.app, "editor")
|
||||||
|
self.nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||||
|
|
||||||
def test_nobody(self) -> None:
|
def test_nobody(self) -> None:
|
||||||
"""Test the permission as nobody.
|
"""Test the permission as nobody.
|
||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
client, csrf_token = get_client(self, self.app, "nobody")
|
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts")
|
response = nobody.client.get("/accounting/base-accounts")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts/1111")
|
response = nobody.client.get("/accounting/base-accounts/1111")
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
def test_viewer(self) -> None:
|
def test_viewer(self) -> None:
|
||||||
@ -106,13 +111,13 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
client, csrf_token = get_client(self, self.app, "viewer")
|
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts")
|
response = viewer.client.get("/accounting/base-accounts")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts/1111")
|
response = viewer.client.get("/accounting/base-accounts/1111")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_editor(self) -> None:
|
def test_editor(self) -> None:
|
||||||
@ -120,11 +125,11 @@ class BaseAccountTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
:return: None.
|
:return: None.
|
||||||
"""
|
"""
|
||||||
client, csrf_token = get_client(self, self.app, "editor")
|
|
||||||
response: httpx.Response
|
response: httpx.Response
|
||||||
|
editor: UserClient = get_user_client(self, self.app, "editor")
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts")
|
response = editor.client.get("/accounting/base-accounts")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
response = client.get("/accounting/base-accounts/1111")
|
response = editor.client.get("/accounting/base-accounts/1111")
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
@ -1,574 +0,0 @@
|
|||||||
# The Mia! Accounting Flask Project.
|
|
||||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
|
||||||
|
|
||||||
# Copyright (c) 2023 imacat.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
"""The test for the currency management.
|
|
||||||
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from click.testing import Result
|
|
||||||
from flask import Flask
|
|
||||||
from flask.testing import FlaskCliRunner
|
|
||||||
|
|
||||||
from test_site import create_app
|
|
||||||
from testlib import get_client, set_locale
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyData:
|
|
||||||
"""The currency data."""
|
|
||||||
|
|
||||||
def __init__(self, code: str, name: str):
|
|
||||||
"""Constructs the currency data.
|
|
||||||
|
|
||||||
:param code: The code.
|
|
||||||
:param name: The name.
|
|
||||||
"""
|
|
||||||
self.code: str = code
|
|
||||||
"""The code."""
|
|
||||||
self.name: str = name
|
|
||||||
"""The name."""
|
|
||||||
|
|
||||||
|
|
||||||
zza: CurrencyData = CurrencyData("ZZA", "Testing Dollar #A")
|
|
||||||
"""The first test currency."""
|
|
||||||
zzb: CurrencyData = CurrencyData("ZZB", "Testing Dollar #B")
|
|
||||||
"""The second test currency."""
|
|
||||||
zzc: CurrencyData = CurrencyData("ZZC", "Testing Dollar #C")
|
|
||||||
"""The third test currency."""
|
|
||||||
zzd: CurrencyData = CurrencyData("ZZD", "Testing Dollar #D")
|
|
||||||
"""The fourth test currency."""
|
|
||||||
PREFIX: str = "/accounting/currencies"
|
|
||||||
"""The URL prefix of the currency management."""
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyCommandTestCase(unittest.TestCase):
|
|
||||||
"""The account console command test case."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
"""Sets up the test.
|
|
||||||
This is run once per test.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
self.app: Flask = create_app(is_testing=True)
|
|
||||||
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
|
||||||
with self.app.app_context():
|
|
||||||
from accounting.database import db
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
|
||||||
result: Result
|
|
||||||
result = runner.invoke(args="init-db")
|
|
||||||
self.assertEqual(result.exit_code, 0)
|
|
||||||
CurrencyL10n.query.delete()
|
|
||||||
Currency.query.delete()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
def test_init(self) -> None:
|
|
||||||
"""Tests the "accounting-init-currencies" console command.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
|
||||||
with self.app.app_context():
|
|
||||||
result: Result = runner.invoke(
|
|
||||||
args=["accounting-init-currencies", "-u", "editor"])
|
|
||||||
self.assertEqual(result.exit_code, 0)
|
|
||||||
currencies: list[Currency] = Currency.query.all()
|
|
||||||
l10n: list[CurrencyL10n] = CurrencyL10n.query.all()
|
|
||||||
self.assertEqual(len(currencies), 2)
|
|
||||||
self.assertEqual(len(l10n), 2 * 2)
|
|
||||||
l10n_keys: set[str] = {f"{x.currency_code}-{x.locale}" for x in l10n}
|
|
||||||
for currency in currencies:
|
|
||||||
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
|
|
||||||
self.assertIn(f"{currency.code}-zh_Hant", l10n_keys)
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencyTestCase(unittest.TestCase):
|
|
||||||
"""The currency test case."""
|
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
"""Sets up the test.
|
|
||||||
This is run once per test.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
self.app: Flask = create_app(is_testing=True)
|
|
||||||
|
|
||||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
|
||||||
with self.app.app_context():
|
|
||||||
from accounting.database import db
|
|
||||||
from accounting.models import Currency, CurrencyL10n
|
|
||||||
result: Result
|
|
||||||
result = runner.invoke(args="init-db")
|
|
||||||
self.assertEqual(result.exit_code, 0)
|
|
||||||
CurrencyL10n.query.delete()
|
|
||||||
Currency.query.delete()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
self.client, self.csrf_token = get_client(self, self.app, "editor")
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/store",
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zza.code,
|
|
||||||
"name": zza.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zza.code}")
|
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/store",
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzb.code,
|
|
||||||
"name": zzb.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzb.code}")
|
|
||||||
|
|
||||||
def test_nobody(self) -> None:
|
|
||||||
"""Test the permission as nobody.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
client, csrf_token = get_client(self, self.app, "nobody")
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
response = client.get(PREFIX)
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{zza.code}")
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/create")
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/store",
|
|
||||||
data={"csrf_token": csrf_token,
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{zza.code}/edit")
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{zza.code}/update",
|
|
||||||
data={"csrf_token": csrf_token,
|
|
||||||
"code": zzd.code,
|
|
||||||
"name": zzd.name})
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{zzb.code}/delete",
|
|
||||||
data={"csrf_token": csrf_token})
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
def test_viewer(self) -> None:
|
|
||||||
"""Test the permission as viewer.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
client, csrf_token = get_client(self, self.app, "viewer")
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
response = client.get(PREFIX)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{zza.code}")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/create")
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/store",
|
|
||||||
data={"csrf_token": csrf_token,
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.get(f"{PREFIX}/{zza.code}/edit")
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{zza.code}/update",
|
|
||||||
data={"csrf_token": csrf_token,
|
|
||||||
"code": zzd.code,
|
|
||||||
"name": zzd.name})
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
response = client.post(f"{PREFIX}/{zzb.code}/delete",
|
|
||||||
data={"csrf_token": csrf_token})
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
def test_editor(self) -> None:
|
|
||||||
"""Test the permission as editor.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
response = self.client.get(PREFIX)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = self.client.get(f"{PREFIX}/{zza.code}")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = self.client.get(f"{PREFIX}/create")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/store",
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzc.code}")
|
|
||||||
|
|
||||||
response = self.client.get(f"{PREFIX}/{zza.code}/edit")
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/{zza.code}/update",
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzd.code,
|
|
||||||
"name": zzd.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{zzd.code}")
|
|
||||||
|
|
||||||
response = self.client.post(f"{PREFIX}/{zzb.code}/delete",
|
|
||||||
data={"csrf_token": self.csrf_token})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], PREFIX)
|
|
||||||
|
|
||||||
def test_add(self) -> None:
|
|
||||||
"""Tests to add the currencies.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Currency
|
|
||||||
from test_site import db
|
|
||||||
create_uri: str = f"{PREFIX}/create"
|
|
||||||
store_uri: str = f"{PREFIX}/store"
|
|
||||||
detail_uri: str = f"{PREFIX}/{zzc.code}"
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
self.assertEqual({x.code for x in Currency.query.all()},
|
|
||||||
{zza.code, zzb.code})
|
|
||||||
|
|
||||||
# Missing CSRF token
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"code": zzc.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
# CSRF token mismatch
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": f"{self.csrf_token}-2",
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
|
|
||||||
# Empty code
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": " ",
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Blocked code, with spaces to be stripped
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": " create ",
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Bad code
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": " zzc ",
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Empty name
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": " "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
# Success, with spaces to be stripped
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": f" {zzc.code} ",
|
|
||||||
"name": f" {zzc.name} "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
# Duplicated code
|
|
||||||
response = self.client.post(store_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], create_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
self.assertEqual({x.code for x in Currency.query.all()},
|
|
||||||
{zza.code, zzb.code, zzc.code})
|
|
||||||
|
|
||||||
zzc_currency: Currency = db.session.get(Currency, zzc.code)
|
|
||||||
self.assertEqual(zzc_currency.code, zzc.code)
|
|
||||||
self.assertEqual(zzc_currency.name_l10n, zzc.name)
|
|
||||||
|
|
||||||
def test_basic_update(self) -> None:
|
|
||||||
"""Tests the basic rules to update a user.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Currency
|
|
||||||
from test_site import db
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
|
||||||
edit_uri: str = f"{PREFIX}/{zza.code}/edit"
|
|
||||||
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
|
||||||
detail_c_uri: str = f"{PREFIX}/{zzc.code}"
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
# Success, with spaces to be stripped
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": f" {zza.code} ",
|
|
||||||
"name": f" {zza.name}-1 "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertEqual(zza_currency.code, zza.code)
|
|
||||||
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-1")
|
|
||||||
|
|
||||||
# Empty code
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": " ",
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Blocked code, with spaces to be stripped
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": " create ",
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Bad code
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": "abc/def",
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Empty name
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": " "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Duplicated code
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzb.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], edit_uri)
|
|
||||||
|
|
||||||
# Change code
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zzc.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_c_uri)
|
|
||||||
|
|
||||||
response = self.client.get(detail_uri)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
|
|
||||||
response = self.client.get(detail_c_uri)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_update_not_modified(self) -> None:
|
|
||||||
"""Tests that the data is not modified.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Currency
|
|
||||||
from test_site import db
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
|
||||||
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
|
||||||
response: httpx.Response
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": f" {zza.code} ",
|
|
||||||
"name": f" {zza.name} "})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertIsNotNone(zza_currency)
|
|
||||||
self.assertEqual(zza_currency.created_at, zza_currency.updated_at)
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zza.code,
|
|
||||||
"name": zzc.name})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertIsNotNone(zza_currency)
|
|
||||||
self.assertNotEqual(zza_currency.created_at,
|
|
||||||
zza_currency.updated_at)
|
|
||||||
|
|
||||||
def test_created_updated_by(self) -> None:
|
|
||||||
"""Tests the created-by and updated-by record.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Currency
|
|
||||||
from test_site import db
|
|
||||||
editor_username, editor2_username = "editor", "editor2"
|
|
||||||
client, csrf_token = get_client(self, self.app, editor2_username)
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
|
||||||
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertEqual(zza_currency.created_by.username, editor_username)
|
|
||||||
self.assertEqual(zza_currency.updated_by.username, editor_username)
|
|
||||||
|
|
||||||
response = client.post(update_uri,
|
|
||||||
data={"csrf_token": csrf_token,
|
|
||||||
"code": zza.code,
|
|
||||||
"name": f"{zza.name}-2"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertEqual(zza_currency.created_by.username, editor_username)
|
|
||||||
self.assertEqual(zza_currency.updated_by.username, editor2_username)
|
|
||||||
|
|
||||||
def test_l10n(self) -> None:
|
|
||||||
"""Tests the localization.
|
|
||||||
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
from accounting.models import Currency
|
|
||||||
from test_site import db
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
|
||||||
update_uri: str = f"{PREFIX}/{zza.code}/update"
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertEqual(zza_currency.name_l10n, zza.name)
|
|
||||||
self.assertEqual(zza_currency.l10n, [])
|
|
||||||
|
|
||||||
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zza.code,
|
|
||||||
"name": f"{zza.name}-zh_Hant"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertEqual(zza_currency.name_l10n, zza.name)
|
|
||||||
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
|
|
||||||
{("zh_Hant", f"{zza.name}-zh_Hant")})
|
|
||||||
|
|
||||||
set_locale(self, self.client, self.csrf_token, "en")
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zza.code,
|
|
||||||
"name": f"{zza.name}-2"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
|
|
||||||
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
|
|
||||||
{("zh_Hant", f"{zza.name}-zh_Hant")})
|
|
||||||
|
|
||||||
set_locale(self, self.client, self.csrf_token, "zh_Hant")
|
|
||||||
|
|
||||||
response = self.client.post(update_uri,
|
|
||||||
data={"csrf_token": self.csrf_token,
|
|
||||||
"code": zza.code,
|
|
||||||
"name": f"{zza.name}-zh_Hant-2"})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], detail_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
zza_currency: Currency = db.session.get(Currency, zza.code)
|
|
||||||
self.assertEqual(zza_currency.name_l10n, f"{zza.name}-2")
|
|
||||||
self.assertEqual({(x.locale, x.name) for x in zza_currency.l10n},
|
|
||||||
{("zh_Hant", f"{zza.name}-zh_Hant-2")})
|
|
||||||
|
|
||||||
def test_delete(self) -> None:
|
|
||||||
"""Tests to delete a currency.
|
|
||||||
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
from accounting.models import Currency
|
|
||||||
detail_uri: str = f"{PREFIX}/{zza.code}"
|
|
||||||
delete_uri: str = f"{PREFIX}/{zza.code}/delete"
|
|
||||||
list_uri: str = PREFIX
|
|
||||||
response: httpx.Response
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
self.assertEqual({x.code for x in Currency.query.all()},
|
|
||||||
{zza.code, zzb.code})
|
|
||||||
|
|
||||||
response = self.client.get(detail_uri)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
response = self.client.post(delete_uri,
|
|
||||||
data={"csrf_token": self.csrf_token})
|
|
||||||
self.assertEqual(response.status_code, 302)
|
|
||||||
self.assertEqual(response.headers["Location"], list_uri)
|
|
||||||
|
|
||||||
with self.app.app_context():
|
|
||||||
self.assertEqual({x.code for x in Currency.query.all()},
|
|
||||||
{zzb.code})
|
|
||||||
|
|
||||||
response = self.client.get(detail_uri)
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
||||||
response = self.client.post(delete_uri,
|
|
||||||
data={"csrf_token": self.csrf_token})
|
|
||||||
self.assertEqual(response.status_code, 404)
|
|
@ -91,9 +91,9 @@ def create_app(is_testing: bool = False) -> Flask:
|
|||||||
return user.id
|
return user.id
|
||||||
|
|
||||||
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||||
and auth.current_user().username in ["viewer", "editor", "editor2"]
|
and auth.current_user().username in ["viewer", "editor"]
|
||||||
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
|
||||||
and auth.current_user().username in ["editor", "editor2"]
|
and auth.current_user().username == "editor"
|
||||||
accounting.init_app(app, user_utils=UserUtils(),
|
accounting.init_app(app, user_utils=UserUtils(),
|
||||||
can_view_func=can_view, can_edit_func=can_edit)
|
can_view_func=can_view, can_edit_func=can_edit)
|
||||||
|
|
||||||
@ -106,7 +106,7 @@ def init_db_command() -> None:
|
|||||||
"""Initializes the database."""
|
"""Initializes the database."""
|
||||||
db.create_all()
|
db.create_all()
|
||||||
from .auth import User
|
from .auth import User
|
||||||
for username in ["viewer", "editor", "editor2", "nobody"]:
|
for username in ["viewer", "editor", "nobody"]:
|
||||||
if User.query.filter(User.username == username).first() is None:
|
if User.query.filter(User.username == username).first() is None:
|
||||||
db.session.add(User(username=username))
|
db.session.add(User(username=username))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
@ -58,8 +58,7 @@ def login() -> redirect:
|
|||||||
|
|
||||||
:return: The redirection to the home page.
|
:return: The redirection to the home page.
|
||||||
"""
|
"""
|
||||||
if request.form.get("username") not in ["viewer", "editor", "editor2",
|
if request.form.get("username") not in ["viewer", "editor", "nobody"]:
|
||||||
"nobody"]:
|
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
session["user"] = request.form.get("username")
|
session["user"] = request.form.get("username")
|
||||||
return redirect(url_for("home.home"))
|
return redirect(url_for("home.home"))
|
||||||
|
@ -29,7 +29,6 @@ First written: 2023/1/27
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
|
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
|
||||||
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
|
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
|
||||||
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button>
|
|
||||||
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
@ -9,8 +9,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
|
||||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||||
"POT-Creation-Date: 2023-02-06 23:25+0800\n"
|
"POT-Creation-Date: 2023-01-28 13:42+0800\n"
|
||||||
"PO-Revision-Date: 2023-02-06 23:26+0800\n"
|
"PO-Revision-Date: 2023-01-28 13:42+0800\n"
|
||||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||||
"Language: zh_Hant\n"
|
"Language: zh_Hant\n"
|
||||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||||
@ -51,10 +51,6 @@ msgid "Editor"
|
|||||||
msgstr "記帳者"
|
msgstr "記帳者"
|
||||||
|
|
||||||
#: tests/test_site/templates/login.html:32
|
#: tests/test_site/templates/login.html:32
|
||||||
msgid "Editor2"
|
|
||||||
msgstr "記帳者2"
|
|
||||||
|
|
||||||
#: tests/test_site/templates/login.html:33
|
|
||||||
msgid "Nobody"
|
msgid "Nobody"
|
||||||
msgstr "沒有權限者"
|
msgstr "沒有權限者"
|
||||||
|
|
||||||
|
@ -17,7 +17,6 @@
|
|||||||
"""The common test libraries.
|
"""The common test libraries.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import typing as t
|
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
@ -25,14 +24,27 @@ import httpx
|
|||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
def get_client(test_case: TestCase, app: Flask, username: str) \
|
class UserClient:
|
||||||
-> tuple[httpx.Client, str]:
|
"""A user client."""
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.Client, csrf_token: str):
|
||||||
|
"""Constructs a user client.
|
||||||
|
|
||||||
|
:param client: The client.
|
||||||
|
:param csrf_token: The CSRF token.
|
||||||
|
"""
|
||||||
|
self.client: httpx.Client = client
|
||||||
|
self.csrf_token: str = csrf_token
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_client(test_case: TestCase, app: Flask, username: str) \
|
||||||
|
-> UserClient:
|
||||||
"""Returns a user client.
|
"""Returns a user client.
|
||||||
|
|
||||||
:param test_case: The test case.
|
:param test_case: The test case.
|
||||||
:param app: The Flask application.
|
:param app: The Flask application.
|
||||||
:param username: The username.
|
:param username: The username.
|
||||||
:return: A tuple of the client and the CSRF token.
|
:return: The user client.
|
||||||
"""
|
"""
|
||||||
client: httpx.Client = httpx.Client(app=app, base_url="https://testserver")
|
client: httpx.Client = httpx.Client(app=app, base_url="https://testserver")
|
||||||
client.headers["Referer"] = "https://testserver"
|
client.headers["Referer"] = "https://testserver"
|
||||||
@ -42,7 +54,7 @@ def get_client(test_case: TestCase, app: Flask, username: str) \
|
|||||||
"username": username})
|
"username": username})
|
||||||
test_case.assertEqual(response.status_code, 302)
|
test_case.assertEqual(response.status_code, 302)
|
||||||
test_case.assertEqual(response.headers["Location"], "/")
|
test_case.assertEqual(response.headers["Location"], "/")
|
||||||
return client, csrf_token
|
return UserClient(client, csrf_token)
|
||||||
|
|
||||||
|
|
||||||
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
||||||
@ -76,21 +88,3 @@ def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
|||||||
parser.feed(response.text)
|
parser.feed(response.text)
|
||||||
test_case.assertIsNotNone(parser.csrf_token)
|
test_case.assertIsNotNone(parser.csrf_token)
|
||||||
return parser.csrf_token
|
return parser.csrf_token
|
||||||
|
|
||||||
|
|
||||||
def set_locale(test_case: TestCase, client: httpx.Client, csrf_token: str,
|
|
||||||
locale: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None:
|
|
||||||
"""Sets the current locale.
|
|
||||||
|
|
||||||
:param test_case: The test case.
|
|
||||||
:param client: The test client.
|
|
||||||
:param csrf_token: The CSRF token.
|
|
||||||
:param locale: The locale.
|
|
||||||
:return: None.
|
|
||||||
"""
|
|
||||||
response: httpx.Response = client.post("/locale",
|
|
||||||
data={"csrf_token": csrf_token,
|
|
||||||
"locale": locale,
|
|
||||||
"next": "/next"})
|
|
||||||
test_case.assertEqual(response.status_code, 302)
|
|
||||||
test_case.assertEqual(response.headers["Location"], "/next")
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user