From 761d5a5824ba15f1ea49dbc256b7e41c6ab8288b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BE=9D=E7=91=AA=E8=B2=93?= Date: Wed, 22 Mar 2023 15:34:28 +0800 Subject: [PATCH] Added the option management, and moved the configuration of the default currency, the default account for the income and expenses log, and the recurring expenses and incomes to the options. --- src/accounting/__init__.py | 3 + .../journal_entry/utils/description_editor.py | 29 +- src/accounting/models.py | 30 + src/accounting/option/__init__.py | 30 + src/accounting/option/forms.py | 250 +++++ src/accounting/option/options.py | 198 ++++ src/accounting/option/views.py | 69 ++ src/accounting/report/utils/ie_account.py | 43 - src/accounting/report/utils/urls.py | 4 +- src/accounting/report/views.py | 4 +- src/accounting/static/css/style.css | 8 + src/accounting/static/js/option-form.js | 941 ++++++++++++++++++ src/accounting/template_globals.py | 5 +- .../templates/accounting/include/nav.html | 8 + .../include/description-editor-modal.html | 2 +- .../templates/accounting/option/form.html | 101 ++ .../form-recurring-expense-income.html | 39 + .../option/include/form-recurring-item.html | 45 + .../recurring-account-selector-modal.html | 53 + .../include/recurring-item-editor-modal.html | 71 ++ src/accounting/utils/ie_account.py | 19 + src/accounting/utils/permission.py | 21 +- src/accounting/utils/user.py | 9 + tests/test_site/__init__.py | 16 +- 24 files changed, 1919 insertions(+), 79 deletions(-) create mode 100644 src/accounting/option/__init__.py create mode 100644 src/accounting/option/forms.py create mode 100644 src/accounting/option/options.py create mode 100644 src/accounting/option/views.py delete mode 100644 src/accounting/report/utils/ie_account.py create mode 100644 src/accounting/static/js/option-form.js create mode 100644 src/accounting/templates/accounting/option/form.html create mode 100644 src/accounting/templates/accounting/option/include/form-recurring-expense-income.html create mode 100644 src/accounting/templates/accounting/option/include/form-recurring-item.html create mode 100644 src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html create mode 100644 src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html diff --git a/src/accounting/__init__.py b/src/accounting/__init__.py index e39207f..cc3c4f8 100644 --- a/src/accounting/__init__.py +++ b/src/accounting/__init__.py @@ -86,4 +86,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface, from . import report report.init_app(app, bp) + from . import option + option.init_app(bp) + app.register_blueprint(bp) diff --git a/src/accounting/journal_entry/utils/description_editor.py b/src/accounting/journal_entry/utils/description_editor.py index 7ca8c59..b6c6792 100644 --- a/src/accounting/journal_entry/utils/description_editor.py +++ b/src/accounting/journal_entry/utils/description_editor.py @@ -25,6 +25,7 @@ from flask import current_app from accounting import db from accounting.models import Account, JournalEntryLineItem +from accounting.option.options import options, Recurring class DescriptionAccount: @@ -148,16 +149,16 @@ class DescriptionType: class DescriptionRecurring: """A recurring transaction.""" - def __init__(self, name: str, template: str, account: Account): + def __init__(self, name: str, account: Account, description_template: str): """Constructs a recurring transaction. :param name: The name. - :param template: The template. + :param description_template: The description template. :param account: The account. """ self.name: str = name - self.template: str = template self.account: DescriptionAccount = DescriptionAccount(account, 0) + self.description_template: str = description_template @property def account_codes(self) -> list[str]: @@ -280,19 +281,17 @@ class DescriptionEditor: :return: None. """ - if "ACCOUNTING_RECURRING" not in current_app.config: - return - data: list[tuple[t.Literal["debit", "credit"], str, str, str]] \ - = [x.split("|") - for x in current_app.config["ACCOUNTING_RECURRING"].split(",")] - debit_credit_dict: dict[t.Literal["debit", "credit"], - DescriptionDebitCredit] \ - = {x.debit_credit: x for x in {self.debit, self.credit}} + recurring: Recurring = options.recurring accounts: dict[str, Account] \ - = self.__get_accounts({x[1] for x in data}) - for row in data: - debit_credit_dict[row[0]].recurring.append( - DescriptionRecurring(row[2], row[3], accounts[row[1]])) + = self.__get_accounts(recurring.codes) + self.debit.recurring \ + = [DescriptionRecurring(x.name, accounts[x.account_code], + x.description_template) + for x in recurring.expenses] + self.credit.recurring \ + = [DescriptionRecurring(x.name, accounts[x.account_code], + x.description_template) + for x in recurring.incomes] @staticmethod def __get_accounts(codes: set[str]) -> dict[str, Account]: diff --git a/src/accounting/models.py b/src/accounting/models.py index 8dea8bf..a29ad31 100644 --- a/src/accounting/models.py +++ b/src/accounting/models.py @@ -779,3 +779,33 @@ class JournalEntryLineItem(db.Model): journal_entry_day.day), format_amount(self.amount), format_amount(self.net_balance)]) + + +class Option(db.Model): + """An option.""" + __tablename__ = "accounting_options" + """The table name.""" + name = db.Column(db.String, nullable=False, primary_key=True) + """The name.""" + value = db.Column(db.Text, nullable=False) + """The option value.""" + 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.""" diff --git a/src/accounting/option/__init__.py b/src/accounting/option/__init__.py new file mode 100644 index 0000000..aa8ec07 --- /dev/null +++ b/src/accounting/option/__init__.py @@ -0,0 +1,30 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 + +# 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 option management. + +""" +from flask import Blueprint + + +def init_app(bp: Blueprint) -> None: + """Initialize the application. + + :param bp: The blueprint of the accounting application. + :return: None. + """ + from .views import bp as option_bp + bp.register_blueprint(option_bp, url_prefix="/options") diff --git a/src/accounting/option/forms.py b/src/accounting/option/forms.py new file mode 100644 index 0000000..ae8b5f0 --- /dev/null +++ b/src/accounting/option/forms.py @@ -0,0 +1,250 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 + +# 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 option management. + +""" +import re +import typing as t + +from flask import render_template +from flask_wtf import FlaskForm +from wtforms import StringField, FieldList, FormField, IntegerField +from wtforms.validators import DataRequired, ValidationError + +from accounting.forms import CURRENCY_REQUIRED, CurrencyExists +from accounting.locale import lazy_gettext +from accounting.models import Account +from accounting.utils.ie_account import IncomeExpensesAccount, ie_accounts +from accounting.utils.strip_text import strip_text +from .options import Options + + +class AccountExists: + """The validator to check if the account exists.""" + + def __call__(self, form: FlaskForm, field: StringField) -> None: + if field.data is None: + return + if Account.find_by_code(field.data) is None: + raise ValidationError(lazy_gettext( + "The account does not exist.")) + + +class IsDebitAccount: + """The validator to check if the account is for debit line items.""" + + def __call__(self, form: FlaskForm, field: StringField) -> None: + if field.data is None: + return + if re.match(r"^(?:[1235689]|7[5678])", field.data) \ + and not field.data.startswith("3351-") \ + and not field.data.startswith("3353-"): + return + raise ValidationError(lazy_gettext( + "This account is not for debit line items.")) + + +class IsCreditAccount: + """The validator to check if the account is for credit line items.""" + + def __call__(self, form: FlaskForm, field: StringField) -> None: + if field.data is None: + return + if re.match(r"^(?:[123489]|7[1234])", field.data) \ + and not field.data.startswith("3351-") \ + and not field.data.startswith("3353-"): + return + raise ValidationError(lazy_gettext( + "This account is not for credit line items.")) + + +class NotStartPayableFromDebit: + """The validator to check that a payable line item does not start from + debit.""" + + def __call__(self, form: FlaskForm, field: StringField) -> None: + if field.data is None or field.data[0] != "2": + return + account: Account | None = Account.find_by_code(field.data) + if account is not None and account.is_need_offset: + raise ValidationError(lazy_gettext( + "A payable line item cannot start from debit.")) + + +class NotStartReceivableFromCredit: + """The validator to check that a receivable line item does not start + from credit.""" + + def __call__(self, form: FlaskForm, field: StringField) -> None: + if field.data is None or field.data[0] != "1": + return + account: Account | None = Account.find_by_code(field.data) + if account is not None and account.is_need_offset: + raise ValidationError(lazy_gettext( + "A receivable line item cannot start from credit.")) + + +class RecurringItemForm(FlaskForm): + """The base sub-form to add or update the recurring item.""" + no = IntegerField() + """The order number of this recurring item.""" + name = StringField() + """The name of the recurring item.""" + account_code = StringField() + """The account code.""" + description_template = StringField() + """The description template.""" + + +class RecurringExpenseForm(RecurringItemForm): + """The sub-form to add or update the recurring expenses.""" + no = IntegerField() + """The order number of this recurring item.""" + name = StringField( + filters=[strip_text], + validators=[DataRequired(lazy_gettext("Please fill in the name."))]) + """The name of the recurring item.""" + account_code = StringField( + filters=[strip_text], + validators=[AccountExists(), + IsDebitAccount(), + NotStartPayableFromDebit()]) + """The account code.""" + description_template = StringField( + filters=[strip_text], + validators=[ + DataRequired(lazy_gettext( + "Please fill in the template of the description."))]) + """The template for the line item description.""" + + @property + def account_text(self) -> str | None: + """Returns the account text. + + :return: The account text. + """ + if self.account_code.data is None: + return None + account: Account | None = Account.find_by_code(self.account_code.data) + return None if account is None else str(account) + + +class RecurringIncomeForm(RecurringItemForm): + """The sub-form to add or update the recurring incomes.""" + no = IntegerField() + """The order number of this recurring item.""" + name = StringField( + filters=[strip_text], + validators=[DataRequired(lazy_gettext("Please fill in the name."))]) + """The name of the recurring item.""" + account_code = StringField( + filters=[strip_text], + validators=[AccountExists(), + IsDebitAccount(), + NotStartReceivableFromCredit()]) + """The account code.""" + description_template = StringField( + filters=[strip_text], + validators=[ + DataRequired(lazy_gettext( + "Please fill in the description template."))]) + """The description template.""" + + +class RecurringForm(RecurringItemForm): + """The sub-form for the recurring expenses and incomes.""" + expenses = FieldList(FormField(RecurringExpenseForm), name="expense") + """The recurring expenses.""" + incomes = FieldList(FormField(RecurringExpenseForm), name="income") + """The recurring incomes.""" + + @property + def item_template(self) -> str: + """Returns the template of a recurring item. + + :return: The template of a recurring item. + """ + return render_template( + "accounting/option/include/form-recurring-item.html", + expense_income="EXPENSE_INCOME", + item_index="ITEM_INDEX", + form=RecurringItemForm()) + + @property + def expense_accounts(self) -> list[Account]: + """The expense accounts. + + :return: None. + """ + return Account.debit() + + @property + def income_accounts(self) -> list[Account]: + """The income accounts. + + :return: None. + """ + return Account.credit() + + @property + def as_data(self) -> dict[str, list[tuple[str, str, str]]]: + """Returns the form data. + + :return: The form data. + """ + def as_tuple(item: RecurringItemForm) -> tuple[str, str, str]: + return (item.name.data, item.account_code.data, + item.description_template.data) + + return {"expense": [as_tuple(x.form) for x in self.expenses], + "income": [as_tuple(x.form) for x in self.incomes]} + + +class OptionForm(FlaskForm): + """The form to update the options.""" + default_currency = StringField( + filters=[strip_text], + validators=[CURRENCY_REQUIRED, + CurrencyExists()]) + """The default currency code.""" + default_ie_account_code = StringField( + filters=[strip_text], + validators=[ + DataRequired(lazy_gettext( + "Please fill in the default account code" + " for the income and expenses log."))]) + """The default account code for the income and expenses log.""" + recurring = FormField(RecurringForm) + """The recurring expenses and incomes.""" + + def populate_obj(self, obj: Options) -> None: + """Populates the form data into a currency object. + + :param obj: The currency object. + :return: None. + """ + obj.default_currency = self.default_currency.data + obj.default_ie_account_code = self.default_ie_account_code.data + obj.recurring_data = self.recurring.form.as_data + + @property + def ie_accounts(self) -> list[IncomeExpensesAccount]: + """Returns the accounts for the income and expenses log. + + :return: The accounts for the income and expenses log. + """ + return ie_accounts() diff --git a/src/accounting/option/options.py b/src/accounting/option/options.py new file mode 100644 index 0000000..b899d52 --- /dev/null +++ b/src/accounting/option/options.py @@ -0,0 +1,198 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 + +# 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 getter and setter for the option management. + +""" +import json + +import sqlalchemy as sa + +from accounting import db +from accounting.models import Option, Account +from accounting.utils.ie_account import IncomeExpensesAccount +from accounting.utils.user import get_current_user_pk + + +class RecurringItem: + """A recurring item.""" + + def __init__(self, name: str, account_code: str, + description_template: str): + """Constructs the recurring item. + + :param name: The name. + :param account_code: The account code. + :param description_template: The description template. + """ + self.name: str = name + self.account_code: str = account_code + self.description_template: str = description_template + + +class Recurring: + """The recurring expenses or incomes.""" + + def __init__(self, data: dict[str, list[tuple[str, str, str]]]): + """Constructs the recurring item. + + :param data: The data. + """ + self.expenses: list[RecurringItem] \ + = [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]] + self.incomes: list[RecurringItem] \ + = [RecurringItem(x[0], x[1], x[2]) for x in data["income"]] + + @property + def codes(self) -> set[str]: + """Returns all the account codes. + + :return: All the account codes. + """ + return {x.account_code for x in self.expenses + self.incomes} + + +class Options: + """The options.""" + + def __init__(self): + """Constructs the options.""" + self.is_modified: bool = False + """Whether the options were modified.""" + + @property + def default_currency(self) -> str: + """Returns the default currency code. + + :return: The default currency code. + """ + return self.__get_option("default_currency", "USD") + + @default_currency.setter + def default_currency(self, value: str) -> None: + """Sets the default currency code. + + :param value: The default currency code. + :return: None. + """ + self.__set_option("default_currency", value) + + @property + def default_ie_account_code(self) -> str: + """Returns the default account code for the income and expenses log. + + :return: The default account code for the income and expenses log. + """ + return self.__get_option("default_ie_account", Account.CASH_CODE) + + @default_ie_account_code.setter + def default_ie_account_code(self, value: str) -> None: + """Sets the default account code for the income and expenses log. + + :param value: The default account code for the income and expenses log. + :return: None. + """ + self.__set_option("default_ie_account", value) + + @property + def default_ie_account(self) -> IncomeExpensesAccount: + """Returns the default account code for the income and expenses log. + + :return: The default account code for the income and expenses log. + """ + if self.default_ie_account_code \ + == IncomeExpensesAccount.CURRENT_AL_CODE: + return IncomeExpensesAccount.current_assets_and_liabilities() + return IncomeExpensesAccount( + Account.find_by_code(self.default_ie_account_code)) + + @property + def recurring_data(self) -> dict[str, list[tuple[str, str, str]]]: + """Returns the data of the recurring expenses and incomes. + + :return: The data of the recurring expenses and incomes. + """ + json_data: str | None = self.__get_option("recurring") + if json_data is None: + return {"expense": [], "income": []} + return json.loads(json_data) + + @recurring_data.setter + def recurring_data(self, + value: dict[str, list[tuple[str, str, str]]]) -> None: + """Sets the data of the recurring expenses and incomes. + + :param value: The data of the recurring expenses and incomes. + :return: None. + """ + self.__set_option("recurring", json.dumps(value, ensure_ascii=False, + separators=(",", ":"))) + + @property + def recurring(self) -> Recurring: + """Returns the recurring expenses and incomes. + + :return: The recurring expenses and incomes. + """ + return Recurring(self.recurring_data) + + @staticmethod + def __get_option(name: str, default: str | None = None) -> str: + """Returns the value of an option. + + :param name: The name. + :param default: The default value when the value does not exist. + :return: The value. + """ + option: Option | None = db.session.get(Option, name) + if option is None: + return default + return option.value + + def __set_option(self, name: str, value: str) -> None: + """Sets the value of an option. + + :param name: The name. + :param value: The value. + :return: None. + """ + option: Option | None = db.session.get(Option, name) + if option is None: + current_user_pk: int = get_current_user_pk() + db.session.add(Option(name=name, + value=value, + created_by_id=current_user_pk, + updated_by_id=current_user_pk)) + self.is_modified = True + return + if option.value == value: + return + option.value = value + option.updated_by_id = get_current_user_pk() + option.updated_at = sa.func.now() + self.is_modified = True + + def commit(self) -> None: + """Commits the options to the database. + + :return: None. + """ + db.session.commit() + self.is_modified = False + + +options: Options = Options() +"""The options.""" diff --git a/src/accounting/option/views.py b/src/accounting/option/views.py new file mode 100644 index 0000000..e931488 --- /dev/null +++ b/src/accounting/option/views.py @@ -0,0 +1,69 @@ +# The Mia! Accounting Flask Project. +# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 + +# 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 option management. + +""" +from urllib.parse import parse_qsl + +from flask import Blueprint, render_template, redirect, session, request, \ + flash, url_for +from werkzeug.datastructures import ImmutableMultiDict + +from accounting import db +from accounting.locale import lazy_gettext +from accounting.option.forms import OptionForm +from accounting.option.options import Options, options +from accounting.utils.cast import s +from accounting.utils.next_uri import inherit_next +from accounting.utils.permission import has_permission, can_admin + +bp: Blueprint = Blueprint("option", __name__) +"""The view blueprint for the currency management.""" + + +@bp.get("", endpoint="form") +@has_permission(can_admin) +def show_option_form() -> str: + """Shows the option form. + + :return: The option form. + """ + form: OptionForm + if "form" in session: + form = OptionForm(ImmutableMultiDict(parse_qsl(session["form"]))) + del session["form"] + form.validate() + else: + form = OptionForm(obj=options) + return render_template("accounting/option/form.html", form=form) + + +@bp.post("", endpoint="update") +@has_permission(can_admin) +def update_options() -> redirect: + """Updates the options. + + :return: The redirection to the option form. + """ + form = OptionForm(request.form) + form.populate_obj(options) + if not options.is_modified: + flash(s(lazy_gettext("The options were not modified.")), "success") + return redirect(inherit_next(url_for("accounting.option.form"))) + options.commit() + flash(s(lazy_gettext("The options are saved successfully.")), "success") + return redirect(inherit_next(url_for("accounting.option.form"))) diff --git a/src/accounting/report/utils/ie_account.py b/src/accounting/report/utils/ie_account.py deleted file mode 100644 index ec2062a..0000000 --- a/src/accounting/report/utils/ie_account.py +++ /dev/null @@ -1,43 +0,0 @@ -# The Mia! Accounting Flask Project. -# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 - -# 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 pseudo account for the income and expenses log. - -""" -from flask import current_app - -from accounting.models import Account -from accounting.utils.ie_account import IncomeExpensesAccount - - -def default_ie_account_code() -> str: - """Returns the default account code for the income and expenses log. - - :return: The default account code for the income and expenses log. - """ - return current_app.config.get("ACCOUNTING_DEFAULT_IE_ACCOUNT", - Account.CASH_CODE) - - -def default_ie_account() -> IncomeExpensesAccount: - """Returns the default account for the income and expenses log. - - :return: The default account for the income and expenses log. - """ - code: str = default_ie_account_code() - if code == IncomeExpensesAccount.CURRENT_AL_CODE: - return IncomeExpensesAccount.current_assets_and_liabilities() - return IncomeExpensesAccount(Account.find_by_code(code)) diff --git a/src/accounting/report/utils/urls.py b/src/accounting/report/utils/urls.py index 212a88a..8943142 100644 --- a/src/accounting/report/utils/urls.py +++ b/src/accounting/report/utils/urls.py @@ -20,8 +20,8 @@ from flask import url_for from accounting.models import Currency, Account +from accounting.option.options import options from accounting.report.period import Period -from accounting.report.utils.ie_account import default_ie_account_code from accounting.template_globals import default_currency_code from accounting.utils.ie_account import IncomeExpensesAccount @@ -65,7 +65,7 @@ def income_expenses_url(currency: Currency, account: IncomeExpensesAccount, :return: The URL of the income and expenses log. """ if currency.code == default_currency_code() \ - and account.code == default_ie_account_code() \ + and account.code == options.default_ie_account_code \ and period.is_default: return url_for("accounting.report.default") if period.is_default: diff --git a/src/accounting/report/views.py b/src/accounting/report/views.py index c28824a..0d72f0f 100644 --- a/src/accounting/report/views.py +++ b/src/accounting/report/views.py @@ -21,6 +21,7 @@ from flask import Blueprint, request, Response from accounting import db from accounting.models import Currency, Account +from accounting.option.options import options from accounting.report.period import Period, get_period from accounting.template_globals import default_currency_code from accounting.utils.ie_account import IncomeExpensesAccount @@ -28,7 +29,6 @@ from accounting.utils.permission import has_permission, can_view from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ IncomeStatement, BalanceSheet, Search from .template_filters import format_amount -from .utils.ie_account import default_ie_account bp: Blueprint = Blueprint("report", __name__) """The view blueprint for the reports.""" @@ -44,7 +44,7 @@ def get_default_report() -> str | Response: """ return __get_income_expenses( db.session.get(Currency, default_currency_code()), - default_ie_account(), + options.default_ie_account, get_period()) diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css index 31995af..5255f03 100644 --- a/src/accounting/static/css/style.css +++ b/src/accounting/static/css/style.css @@ -331,6 +331,14 @@ a.accounting-report-table-row { margin-top: 0.2rem; } +/* The illustration of the description template for the recurring transactions */ +.accounting-recurring-description-template-illustration p { + margin: 0.2rem 0; +} +.accounting-recurring-description-template-illustration ul { + margin: 0; +} + /* The Material Design text field (floating form control in Bootstrap) */ .accounting-material-text-field { position: relative; diff --git a/src/accounting/static/js/option-form.js b/src/accounting/static/js/option-form.js new file mode 100644 index 0000000..8ed397f --- /dev/null +++ b/src/accounting/static/js/option-form.js @@ -0,0 +1,941 @@ +/* The Mia! Accounting Flask Project + * account-form.js: The JavaScript for the account 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/3/22 + */ +"use strict"; + +// Initializes the page JavaScript. +document.addEventListener("DOMContentLoaded", () => { + OptionForm.initialize(); +}); + +/** + * Escapes the HTML special characters and returns. + * + * @param s {string} the original string + * @returns {string} the string with HTML special character escaped + * @private + */ +function escapeHtml(s) { + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); +} + +/** + * The option form. + * + * @private + */ +class OptionForm { + + /** + * The form element + * @type {HTMLFormElement} + */ + #element; + + /** + * The default currency + * @type {HTMLSelectElement} + */ + #defaultCurrency; + + /** + * The error message for the default currency + * @type {HTMLDivElement} + */ + #defaultCurrencyError; + + /** + * The default account for the income and expenses log + * @type {HTMLSelectElement} + */ + #defaultIeAccount; + + /** + * The error message for the default account for the income and expenses log + * @type {HTMLDivElement} + */ + #defaultIeAccountError; + + /** + * The recurring item template + * @type {string} + */ + recurringItemTemplate; + + /** + * The recurring expenses or incomes sub-form + * @type {{expense: RecurringExpenseIncomeSubForm, income: RecurringExpenseIncomeSubForm}} + */ + #expenseIncome; + + /** + * Constructs the option form. + * + */ + constructor() { + this.#element = document.getElementById("accounting-form"); + this.#defaultCurrency = document.getElementById("accounting-default-currency"); + this.#defaultCurrencyError = document.getElementById("accounting-default-currency-error"); + this.#defaultIeAccount = document.getElementById("accounting-default-ie-account"); + this.#defaultIeAccountError = document.getElementById("accounting-default-ie-account-error"); + this.recurringItemTemplate = this.#element.dataset.recurringItemTemplate; + this.#expenseIncome = RecurringExpenseIncomeSubForm.getInstances(this); + + this.#defaultCurrency.onchange = () => this.#validateDefaultCurrency(); + this.#defaultIeAccount.onchange = () => this.#validateDefaultIeAccount(); + this.#element.onsubmit = () => { + return this.#validate(); + }; + } + + /** + * Validates the form. + * + * @returns {boolean} true if valid, or false otherwise + */ + #validate() { + let isValid = true; + isValid = this.#validateDefaultCurrency() && isValid; + isValid = this.#validateDefaultIeAccount() && isValid; + isValid = this.#expenseIncome.expense.validate() && isValid; + isValid = this.#expenseIncome.income.validate() && isValid; + return isValid; + } + + /** + * Validates the default currency. + * + * @returns {boolean} true if valid, or false otherwise + */ + #validateDefaultCurrency() { + if (this.#defaultCurrency.value === "") { + this.#defaultCurrency.classList.add("is-invalid"); + this.#defaultCurrencyError.innerText = A_("Please select the default currency."); + return false; + } + this.#defaultCurrency.classList.remove("is-invalid"); + this.#defaultCurrencyError.innerText = ""; + return true; + } + + /** + * Validates the default account for the income and expenses log. + * + * @returns {boolean} true if valid, or false otherwise + */ + #validateDefaultIeAccount() { + if (this.#defaultIeAccount.value === "") { + this.#defaultIeAccount.classList.add("is-invalid"); + this.#defaultIeAccountError.innerText = A_("Please select the default account for the income and expenses log."); + return false; + } + this.#defaultIeAccount.classList.remove("is-invalid"); + this.#defaultIeAccountError.innerText = ""; + return true; + } + + /** + * The option form + * @type {OptionForm} + */ + static #form; + + /** + * Initializes the option form. + * + */ + static initialize() { + this.#form = new OptionForm(); + } +} + +/** + * The recurring expenses or incomes sub-form. + * + */ +class RecurringExpenseIncomeSubForm { + + /** + * The option form + * @type {OptionForm} + */ + #form; + + /** + * Either "expense" or "income" + * @type {string} + */ + expenseIncome; + + /** + * The recurring item editor + * @type {RecurringItemEditor} + */ + editor; + + /** + * The prefix of HTML ID and class + * @type {string} + */ + #prefix; + + /** + * The recurring items list + * @type {HTMLUListElement} + */ + #itemList; + + /** + * The recurring items + * @type {RecurringItemSubForm[]} + */ + #items; + + /** + * The button to add a new recurring item + * @type {HTMLButtonElement} + */ + #addButton; + + /** + * Constructs the recurring expenses or incomes. + * + * @param form {OptionForm} the option form + * @param expenseIncome {string} either "expense" or "income" + */ + constructor(form, expenseIncome) { + this.#form = form; + this.expenseIncome = expenseIncome; + this.editor = new RecurringItemEditor(this); + this.#prefix = "accounting-recurring-" + expenseIncome; + this.#itemList = document.getElementById(this.#prefix + "-list"); + this.#items = Array.from(document.getElementsByClassName(this.#prefix + "-item")).map((element) => new RecurringItemSubForm(this, element)); + this.#addButton = document.getElementById(this.#prefix + "-add"); + + this.#addButton.onclick = () => this.editor.onAddNew(); + } + + /** + * Adds a recurring item. + * + * @return {RecurringItemSubForm} the recurring item + */ + addItem() { + const newIndex = 1 + (this.#items.length === 0? 0: Math.max(...this.#items.map((item) => item.itemIndex))); + const html = this.#form.recurringItemTemplate + .replaceAll("EXPENSE_INCOME", escapeHtml(this.expenseIncome)) + .replaceAll("ITEM_INDEX", escapeHtml(String(newIndex))); + this.#itemList.insertAdjacentHTML("beforeend", html); + const element = document.getElementById(this.#prefix + "-" + String(newIndex)) + const item = new RecurringItemSubForm(this, element); + this.#items.push(item); + return item; + } + + /** + * Deletes a recurring item sub-form. + * + * @param item {RecurringItemSubForm} the recurring item sub-form to delete + */ + deleteItem(item) { + const index = this.#items.indexOf(item); + this.#items.splice(index, 1); + } + + /** + * Validates the form. + * + * @returns {boolean} true if valid, or false otherwise + */ + validate() { + let isValid = true; + for (const item of this.#items) { + isValid = item.validate() && isValid; + } + return isValid; + } + + /** + * Returns the recurring expenses or incomes sub-form instances. + * + * @param form {OptionForm} the option form + * @return {{expense: RecurringExpenseIncomeSubForm, income: RecurringExpenseIncomeSubForm}} + */ + static getInstances(form) { + const subForms = {}; + for (const expenseIncome of ["expense", "income"]) { + subForms[expenseIncome] = new RecurringExpenseIncomeSubForm(form, expenseIncome); + } + return subForms; + } +} + +/** + * A recurring item sub-form. + * + */ +class RecurringItemSubForm { + + /** + * The recurring expenses or incomes sub-form + * @type {RecurringExpenseIncomeSubForm} + */ + #expenseIncomeSubForm; + + /** + * The element + * @type {HTMLLIElement} + */ + #element; + + /** + * The item index + * @type {number} + */ + itemIndex; + + /** + * The control + * @type {HTMLDivElement} + */ + #control; + + /** + * The error message + * @type {HTMLDivElement} + */ + #error; + + /** + * The name input + * @type {HTMLInputElement} + */ + #name; + + /** + * The text display of the name + * @type {HTMLDivElement} + */ + #nameText; + + /** + * The account code input + * @type {HTMLInputElement} + */ + #accountCode; + + /** + * The text display of the account + * @type {HTMLDivElement} + */ + #accountText; + + /** + * The description template input + * @type {HTMLInputElement} + */ + #descriptionTemplate; + + /** + * The text display of the description template + * @type {HTMLDivElement} + */ + #descriptionTemplateText; + + /** + * The button to delete this recurring item + * @type {HTMLButtonElement} + */ + deleteButton; + + /** + * Constructs a recurring item sub-form. + * + * @param expenseIncomeSubForm {RecurringExpenseIncomeSubForm} the recurring expenses or incomes sub-form + * @param element {HTMLLIElement} the element + */ + constructor(expenseIncomeSubForm, element) { + this.#expenseIncomeSubForm = expenseIncomeSubForm + this.#element = element; + this.itemIndex = parseInt(element.dataset.itemIndex); + const prefix = "accounting-recurring-" + expenseIncomeSubForm.expenseIncome + "-" + element.dataset.itemIndex; + this.#control = document.getElementById(prefix + "-control"); + this.#error = document.getElementById(prefix + "-error"); + this.#name = document.getElementById(prefix + "-name"); + this.#nameText = document.getElementById(prefix + "-name-text"); + this.#accountCode = document.getElementById(prefix + "-account-code"); + this.#accountText = document.getElementById(prefix + "-account-text"); + this.#descriptionTemplate = document.getElementById(prefix + "-description-template"); + this.#descriptionTemplateText = document.getElementById(prefix + "-description-template-text"); + this.deleteButton = document.getElementById(prefix + "-delete"); + + this.#control.onclick = () => this.#expenseIncomeSubForm.editor.onEdit(this); + this.deleteButton.onclick = () => { + this.#element.parentElement.removeChild(this.#element); + this.#expenseIncomeSubForm.deleteItem(this); + }; + } + + /** + * Returns the name. + * + * @return {string|null} the name + */ + getName() { + return this.#name.value === ""? null: this.#name.value; + } + + /** + * Returns the account code. + * + * @return {string|null} the account code + */ + getAccountCode() { + return this.#accountCode.value === ""? null: this.#accountCode.value; + } + + /** + * Returns the account text. + * + * @return {string|null} the account text + */ + getAccountText() { + return this.#accountCode.dataset.text === ""? null: this.#accountCode.dataset.text; + } + + /** + * Returns the description template. + * + * @return {string|null} the description template + */ + getDescriptionTemplate() { + return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value; + } + + /** + * Saves the recurring item from the recurring item editor. + * + * @param editor {RecurringItemEditor} the recurring item editor + */ + save(editor) { + this.#name.value = editor.getName() === null? "": editor.getName(); + this.#nameText.innerText = this.#name.value; + this.#accountCode.value = editor.accountCode; + this.#accountCode.dataset.text = editor.accountText; + this.#accountText.innerText = editor.accountText; + this.#descriptionTemplate.value = editor.getDescriptionTemplate() === null? "": editor.getDescriptionTemplate(); + this.#descriptionTemplateText.innerText = this.#descriptionTemplate.value; + this.validate(); + } + + /** + * Validates the form. + * + * @returns {boolean} true if valid, or false otherwise + */ + validate() { + if (this.#name.value === "") { + this.#control.classList.add("is-invalid"); + this.#error.innerText = A_("Please fill in the name."); + return false; + } + if (this.#accountCode.value === "") { + this.#control.classList.add("is-invalid"); + this.#error.innerText = A_("Please select the account."); + return false; + } + if (this.#descriptionTemplate.value === "") { + this.#control.classList.add("is-invalid"); + this.#error.innerText = A_("Please fill in the description template."); + return false; + } + this.#control.classList.remove("is-invalid"); + this.#error.innerText = ""; + return true; + } +} + +/** + * The recurring item editor. + * + */ +class RecurringItemEditor { + + /** + * The recurring expense or income sub-form + * @type {RecurringExpenseIncomeSubForm} + */ + #subForm; + + /** + * Either "expense" or "income" + * @type {string} + */ + expenseIncome; + + /** + * The form + * @type {HTMLFormElement} + */ + #form; + + /** + * The modal + * @type {HTMLDivElement} + */ + #modal; + + /** + * The name + * @type {HTMLInputElement} + */ + #name; + + /** + * The error message of the name + * @type {HTMLDivElement} + */ + #nameError; + + /** + * The control of the account + * @type {HTMLDivElement} + */ + #accountControl; + + /** + * The text display of the account + * @type {HTMLDivElement} + */ + #accountContainer; + + /** + * The error message of the account + * @type {HTMLDivElement} + */ + #accountError; + + /** + * The description template + * @type {HTMLInputElement} + */ + #descriptionTemplate; + + /** + * The error message of the description template + * @type {HTMLDivElement} + */ + #descriptionTemplateError; + + /** + * The account selector + * @type {RecurringAccountSelector} + */ + #accountSelector; + + /** + * The account code + * @type {string|null} + */ + accountCode = null; + + /** + * The account text + * @type {string|null} + */ + accountText = null; + + /** + * The recurring item sub-form + * @type {RecurringItemSubForm|null} + */ + #item = null; + + /** + * Constructs the recurring item editor. + * + * @param subForm {RecurringExpenseIncomeSubForm} the recurring expense or income sub-form + */ + constructor(subForm) { + this.#subForm = subForm; + this.expenseIncome = subForm.expenseIncome; + const prefix = "accounting-recurring-item-editor-" + subForm.expenseIncome; + this.#form = document.getElementById(prefix); + this.#modal = document.getElementById(prefix + "-modal"); + this.#name = document.getElementById(prefix + "-name"); + this.#nameError = document.getElementById(prefix + "-name-error"); + this.#accountControl = document.getElementById(prefix + "-account-control"); + this.#accountContainer = document.getElementById(prefix + "-account"); + this.#accountError = document.getElementById(prefix + "-account-error"); + this.#descriptionTemplate = document.getElementById(prefix + "-description-template"); + this.#descriptionTemplateError = document.getElementById(prefix + "-description-template-error"); + this.#accountSelector = new RecurringAccountSelector(this); + + this.#name.onchange = () => this.#validateName(); + this.#accountControl.onclick = () => this.#accountSelector.clear(); + this.#descriptionTemplate.onchange = () => this.#validateDescriptionTemplate(); + this.#form.onsubmit = () => { + if (this.#validate()) { + if (this.#item === null) { + this.#item = this.#subForm.addItem(); + } + this.#item.save(this); + bootstrap.Modal.getInstance(this.#modal).hide(); + } + return false; + }; + } + + /** + * Returns the name. + * + * @return {string|null} the name + */ + getName() { + return this.#name.value === ""? null: this.#name.value; + } + + /** + * Returns the description template. + * + * @return {string|null} the description template + */ + getDescriptionTemplate() { + return this.#descriptionTemplate.value === ""? null: this.#descriptionTemplate.value; + } + + /** + * Saves the selected account. + * + * @param account {RecurringAccount} the selected account + */ + saveAccount(account) { + this.accountCode = account.code; + this.accountText = account.text; + this.#accountControl.classList.add("accounting-not-empty"); + this.#accountContainer.innerText = account.text; + this.#validateAccount(); + } + + /** + * Clears account. + * + */ + clearAccount() { + this.accountCode = null; + this.accountText = null; + this.#accountControl.classList.remove("accounting-not-empty"); + this.#accountContainer.innerText = ""; + this.#validateAccount() + } + + /** + * The callback when adding a new recurring item. + * + */ + onAddNew() { + this.#item = null; + this.#name.value = ""; + this.#name.classList.remove("is-invalid"); + this.#nameError.innerText = ""; + this.accountCode = null; + this.accountText = null; + this.#accountControl.classList.remove("accounting-not-empty"); + this.#accountControl.classList.remove("is-invalid"); + this.#accountContainer.innerText = ""; + this.#accountError.innerText = ""; + this.#descriptionTemplate.value = ""; + this.#descriptionTemplate.classList.remove("is-invalid"); + this.#descriptionTemplateError.innerText = ""; + } + + /** + * The callback when editing a recurring item. + * + * @param item {RecurringItemSubForm} the recurring item to edit + */ + onEdit(item) { + this.#item = item; + this.#name.value = item.getName() === null? "": item.getName(); + this.accountCode = item.getAccountCode(); + this.accountText = item.getAccountText(); + if (this.accountText === null) { + this.#accountControl.classList.remove("accounting-not-empty"); + } else { + this.#accountControl.classList.add("accounting-not-empty"); + } + this.#accountContainer.innerText = item.getAccountText() == null? "": item.getAccountText(); + this.#descriptionTemplate.value = item.getDescriptionTemplate() === null? "": item.getDescriptionTemplate(); + this.#validate(); + } + + /** + * Validates the form. + * + * @returns {boolean} true if valid, or false otherwise + */ + #validate() { + let isValid = true; + isValid = this.#validateName() && isValid; + isValid = this.#validateAccount() && isValid; + isValid = this.#validateDescriptionTemplate() && isValid; + return isValid; + } + + /** + * Validates the name. + * + * @returns {boolean} true if valid, or false otherwise + */ + #validateName() { + this.#name.value = this.#name.value.trim(); + if (this.#name.value === "") { + this.#name.classList.add("is-invalid"); + this.#nameError.innerText = A_("Please fill in the name."); + return false; + } + this.#name.classList.remove("is-invalid"); + this.#nameError.innerText = ""; + return true; + } + + /** + * Validates the account. + * + * @returns {boolean} true if valid, or false otherwise + */ + #validateAccount() { + if (this.accountCode === null) { + this.#accountControl.classList.add("is-invalid"); + this.#accountError.innerText = A_("Please select the account."); + return false; + } + this.#accountControl.classList.remove("is-invalid"); + this.#accountError.innerText = ""; + return true; + } + + /** + * Validates the description template. + * + * @returns {boolean} true if valid, or false otherwise + */ + #validateDescriptionTemplate() { + this.#descriptionTemplate.value = this.#descriptionTemplate.value.trim(); + if (this.#descriptionTemplate.value === "") { + this.#descriptionTemplate.classList.add("is-invalid"); + this.#descriptionTemplateError.innerText = A_("Please fill in the description template."); + return false; + } + this.#descriptionTemplate.classList.remove("is-invalid"); + this.#descriptionTemplateError.innerText = ""; + return true; + } +} + +/** + * The account selector for the recurring item editor. + * + */ +class RecurringAccountSelector { + + /** + * The recurring item editor + * @type {RecurringItemEditor} + */ + editor; + + /** + * Either "expense" or "income" + * @type {string} + */ + #expenseIncome; + + /** + * The query input + * @type {HTMLInputElement} + */ + #query; + + /** + * The error message when the query has no result + * @type {HTMLParagraphElement} + */ + #queryNoResult; + + /** + * The option list + * @type {HTMLUListElement} + */ + #optionList; + + /** + * The account options + * @type {RecurringAccount[]} + */ + #options; + + /** + * The button to clear the account + * @type {HTMLButtonElement} + */ + #clearButton; + + /** + * Constructs the account selector for the recurring item editor. + * + * @param editor {RecurringItemEditor} the recurring item editor + */ + constructor(editor) { + this.editor = editor; + this.#expenseIncome = editor.expenseIncome; + const prefix = "accounting-recurring-accounting-selector-" + editor.expenseIncome; + this.#query = document.getElementById(prefix + "-query"); + this.#queryNoResult = document.getElementById(prefix + "-option-no-result"); + this.#optionList = document.getElementById(prefix + "-option-list"); + this.#options = Array.from(document.getElementsByClassName(prefix + "-option")).map((element) => new RecurringAccount(this, element)); + this.#clearButton = document.getElementById(prefix + "-clear"); + + this.#query.oninput = () => this.#filterOptions(); + this.#clearButton.onclick = () => this.editor.clearAccount(); + } + + /** + * Clears the filter. + * + */ + clear() { + this.#query.value = ""; + this.#filterOptions(); + } + + /** + * Filters the options. + * + */ + #filterOptions() { + let hasAnyMatched = false; + for (const option of this.#options) { + if (option.isMatches(this.#query.value)) { + option.setShown(true); + hasAnyMatched = true; + } else { + option.setShown(false); + } + } + if (!hasAnyMatched) { + this.#optionList.classList.add("d-none"); + this.#queryNoResult.classList.remove("d-none"); + } else { + this.#optionList.classList.remove("d-none"); + this.#queryNoResult.classList.add("d-none"); + } + } +} + +/** + * An account in the account selector for the recurring item editor. + * + */ +class RecurringAccount { + + /** + * The account selector for the recurring item editor + * @type {RecurringAccountSelector} + */ + #selector; + + /** + * The element + * @type {HTMLLIElement} + */ + #element; + + /** + * The account code + * @type {string} + */ + code; + + /** + * The account text + * @type {string} + */ + text; + + /** + * The values to query against + * @type {string[]} + */ + #queryValues; + + /** + * Constructs the account in the account selector for the recurring item editor. + * + * @param selector {RecurringAccountSelector} the account selector + * @param element {HTMLLIElement} the element + */ + constructor(selector, element) { + this.#selector = selector; + this.#element = element; + this.code = element.dataset.code; + this.text = element.dataset.text; + this.#queryValues = JSON.parse(element.dataset.queryValues); + + this.#element.onclick = () => this.#selector.editor.saveAccount(this); + } + + /** + * Returns whether the account matches the query. + * + * @param query {string} the query term + * @return {boolean} true if the option matches, or false otherwise + */ + isMatches(query) { + if (query === "") { + return true; + } + for (const queryValue of this.#queryValues) { + if (queryValue.toLowerCase().includes(query.toLowerCase())) { + return true; + } + } + return false; + } + + /** + * Sets whether the option is shown. + * + * @param isShown {boolean} true to show, or false otherwise + */ + setShown(isShown) { + if (isShown) { + this.#element.classList.remove("d-none"); + } else { + this.#element.classList.add("d-none"); + } + } +} diff --git a/src/accounting/template_globals.py b/src/accounting/template_globals.py index b43d3c0..c66bb0e 100644 --- a/src/accounting/template_globals.py +++ b/src/accounting/template_globals.py @@ -17,9 +17,8 @@ """The template globals. """ -from flask import current_app - from accounting.models import Currency +from accounting.option.options import options def currency_options() -> str: @@ -35,4 +34,4 @@ def default_currency_code() -> str: :return: The default currency code. """ - return current_app.config.get("ACCOUNTING_DEFAULT_CURRENCY", "USD") + return options.default_currency diff --git a/src/accounting/templates/accounting/include/nav.html b/src/accounting/templates/accounting/include/nav.html index 19ad0c1..1251ff1 100644 --- a/src/accounting/templates/accounting/include/nav.html +++ b/src/accounting/templates/accounting/include/nav.html @@ -51,6 +51,14 @@ First written: 2023/1/26 {{ A_("Currencies") }} + {% if accounting_can_admin() %} +
  • + + + {{ A_("Settings") }} + +
  • + {% endif %} {% endif %} diff --git a/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html b/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html index 941421d..281de65 100644 --- a/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html +++ b/src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html @@ -158,7 +158,7 @@ First written: 2023/2/28
    {% for recurring in description_editor.recurring %} - {% endfor %} diff --git a/src/accounting/templates/accounting/option/form.html b/src/accounting/templates/accounting/option/form.html new file mode 100644 index 0000000..bfb5fd9 --- /dev/null +++ b/src/accounting/templates/accounting/option/form.html @@ -0,0 +1,101 @@ +{# +The Mia! Accounting Flask Project +form.html: The option 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/3/22 +#} +{% extends "accounting/base.html" %} + +{% block accounting_scripts %} + +{% endblock %} + +{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %} + +{% block content %} + +
    + {{ form.csrf_token }} + {% if request.args.next %} + + {% endif %} +
    + + +
    {% if form.default_currency.errors %}{{ form.default_currency.errors[0] }}{% endif %}
    +
    + +
    + + +
    {% if form.default_ie_account_code.errors %}{{ form.default_ie_account_code.errors[0] }}{% endif %}
    +
    + + {% with expense_income = "expense", + label = A_("Expense"), + recurring_items = form.recurring.expenses %} + {% include "accounting/option/include/form-recurring-expense-income.html" %} + {% endwith %} + + {% with expense_income = "income", + label = A_("Income"), + recurring_items = form.recurring.incomes %} + {% include "accounting/option/include/form-recurring-expense-income.html" %} + {% endwith %} + +
    + +
    + +
    + +
    +
    + +{% with expense_income = "expense", + title = A_("Recurring Expense") %} + {% include "accounting/option/include/recurring-item-editor-modal.html" %} +{% endwith %} +{% with expense_income = "income", + title = A_("Recurring Income") %} + {% include "accounting/option/include/recurring-item-editor-modal.html" %} +{% endwith %} + +{% with expense_income = "expense", + accounts = form.recurring.expense_accounts %} + {% include "accounting/option/include/recurring-account-selector-modal.html" %} +{% endwith %} +{% with expense_income = "income", + accounts = form.recurring.income_accounts %} + {% include "accounting/option/include/recurring-account-selector-modal.html" %} +{% endwith %} + +{% endblock %} diff --git a/src/accounting/templates/accounting/option/include/form-recurring-expense-income.html b/src/accounting/templates/accounting/option/include/form-recurring-expense-income.html new file mode 100644 index 0000000..8a556b3 --- /dev/null +++ b/src/accounting/templates/accounting/option/include/form-recurring-expense-income.html @@ -0,0 +1,39 @@ +{# +The Mia! Accounting Flask Project +form-recurring-item.html: The recurring expense or income sub-form in the option 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/3/22 +#} +
    + +
      + {% for recurring_item in recurring_items %} + {% with form = recurring_item.form, + item_index = loop.index %} + {% include "accounting/option/include/form-recurring-item.html" %} + {% endwith %} + {% endfor %} +
    + +
    + +
    +
    diff --git a/src/accounting/templates/accounting/option/include/form-recurring-item.html b/src/accounting/templates/accounting/option/include/form-recurring-item.html new file mode 100644 index 0000000..7c500a5 --- /dev/null +++ b/src/accounting/templates/accounting/option/include/form-recurring-item.html @@ -0,0 +1,45 @@ +{# +The Mia! Accounting Flask Project +form-recurring-item.html: The recurring item sub-form in the option 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/3/22 +#} +{#
      For SonarQube not to complain about incorrect HTML #} +
    • + + + + +
      +
      +
      +
      {{ form.account_text|accounting_default }}
      +
      {{ form.name.data|accounting_default }}
      +
      {{ form.description_template.data|accounting_default }}
      +
      +
      {% if form.form_errors %}{{ form.form_errors[0] }}{% endif %}
      +
      + +
      + +
      +
      +
    • +{#
    For SonarQube not to complain about incorrect HTML #} diff --git a/src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html b/src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html new file mode 100644 index 0000000..e5ca01d --- /dev/null +++ b/src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html @@ -0,0 +1,53 @@ +{# +The Mia! Accounting Flask Project +recurring-account-selector-modal.html: The modal of the account selector for the recurring item editor + + 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/3/22 +#} + diff --git a/src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html b/src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html new file mode 100644 index 0000000..34eb9bf --- /dev/null +++ b/src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html @@ -0,0 +1,71 @@ +{# +The Mia! Accounting Flask Project +recurring-item-editor-modal.html: The modal of the recurring item editor + + 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/3/22 +#} +
    + +
    diff --git a/src/accounting/utils/ie_account.py b/src/accounting/utils/ie_account.py index 575e777..406c6aa 100644 --- a/src/accounting/utils/ie_account.py +++ b/src/accounting/utils/ie_account.py @@ -19,8 +19,10 @@ """ import typing as t +from accounting import db from accounting.locale import gettext from accounting.models import Account +import sqlalchemy as sa class IncomeExpensesAccount: @@ -62,3 +64,20 @@ class IncomeExpensesAccount: account.title = gettext("current assets and liabilities") account.str = account.title return account + + +def ie_accounts() -> list[IncomeExpensesAccount]: + """Returns accounts for the income and expenses log. + + :return: The accounts for the income and expenses log. + """ + accounts: list[IncomeExpensesAccount] \ + = [IncomeExpensesAccount.current_assets_and_liabilities()] + accounts.extend([IncomeExpensesAccount(x) + for x in db.session.query(Account) + .filter(sa.or_(Account.base_code.startswith("11"), + Account.base_code.startswith("12"), + Account.base_code.startswith("21"), + Account.base_code.startswith("22"))) + .order_by(Account.base_code, Account.no)]) + return accounts diff --git a/src/accounting/utils/permission.py b/src/accounting/utils/permission.py index cbb14f4..11a4c71 100644 --- a/src/accounting/utils/permission.py +++ b/src/accounting/utils/permission.py @@ -63,6 +63,9 @@ data.""" __can_edit_func: t.Callable[[], bool] = lambda: True """The callback that returns whether the current user can edit the accounting data.""" +__can_admin_func: t.Callable[[], bool] = lambda: True +"""The callback that returns whether the current user can administrate the +accounting settings.""" def can_view() -> bool: @@ -87,6 +90,20 @@ def can_edit() -> bool: return __can_edit_func() +def can_admin() -> bool: + """Returns whether the current user can administrate the accounting + settings. + + The user has to log in. + + :return: True if the current user can administrate the accounting settings, + or False otherwise. + """ + if get_current_user() is None: + return False + return __can_admin_func() + + def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None: """Initializes the application. @@ -94,8 +111,10 @@ def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None: :param user_utils: The user utilities. :return: None. """ - global __can_view_func, __can_edit_func + global __can_view_func, __can_edit_func, __can_admin_func __can_view_func = user_utils.can_view __can_edit_func = user_utils.can_edit + __can_admin_func = user_utils.can_admin bp.add_app_template_global(user_utils.can_view, "accounting_can_view") bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit") + bp.add_app_template_global(user_utils.can_admin, "accounting_can_admin") diff --git a/src/accounting/utils/user.py b/src/accounting/utils/user.py index a99c487..63b0f3a 100644 --- a/src/accounting/utils/user.py +++ b/src/accounting/utils/user.py @@ -50,6 +50,15 @@ class UserUtilityInterface(t.Generic[T], ABC): data, or False otherwise. """ + @abstractmethod + def can_admin(self) -> bool: + """Returns whether the currently logged-in user can administrate the + accounting settings. + + :return: True if the currently logged-in user can administrate the + accounting settings, or False otherwise. + """ + @property @abstractmethod def cls(self) -> t.Type[T]: diff --git a/tests/test_site/__init__.py b/tests/test_site/__init__.py index 10061ea..757d4b0 100644 --- a/tests/test_site/__init__.py +++ b/tests/test_site/__init__.py @@ -50,18 +50,6 @@ def create_app(is_testing: bool = False) -> Flask: "SQLALCHEMY_DATABASE_URI": db_uri, "BABEL_DEFAULT_LOCALE": "en", "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", - "ACCOUNTING_DEFAULT_CURRENCY": "USD", - "ACCOUNTING_DEFAULT_IE_ACCOUNT": "1111-001", - "ACCOUNTING_RECURRING": ( - "debit|1314-001|Pension|Pension for {last_month_name}," - "debit|6262-001|Health insurance" - "|Health insurance for {last_month_name}," - "debit|6261-001|Electricity bill" - "|Electricity bill for {last_bimonthly_name}," - "debit|6261-001|Water bill|Water bill for {last_bimonthly_name}," - "debit|6261-001|Gas bill|Gas bill for {last_bimonthly_name}," - "debit|6261-001|Phone bill|Phone bill for {last_month_name}," - "credit|4611-001|Payroll|Payroll for {last_month_name}"), }) if is_testing: app.config["TESTING"] = True @@ -90,6 +78,10 @@ def create_app(is_testing: bool = False) -> Flask: return auth.current_user() is not None \ and auth.current_user().username in ["editor", "editor2"] + def can_admin(self) -> bool: + return auth.current_user() is not None \ + and auth.current_user().username == "editor" + @property def cls(self) -> t.Type[auth.User]: return auth.User