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.

This commit is contained in:
依瑪貓 2023-03-22 15:34:28 +08:00
parent fa3cdace7f
commit 761d5a5824
24 changed files with 1919 additions and 79 deletions

View File

@ -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)

View File

@ -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]:

View File

@ -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."""

View File

@ -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")

View File

@ -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()

View File

@ -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."""

View File

@ -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")))

View File

@ -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))

View File

@ -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:

View File

@ -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())

View File

@ -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;

View File

@ -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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;");
}
/**
* 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");
}
}
}

View File

@ -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

View File

@ -51,6 +51,14 @@ First written: 2023/1/26
{{ A_("Currencies") }}
</a>
</li>
{% if accounting_can_admin() %}
<li>
<a class="dropdown-item {% if request.endpoint and request.endpoint.startswith("accounting.option.") %} active {% endif %}" href="{{ url_for("accounting.option.form") }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}

View File

@ -158,7 +158,7 @@ First written: 2023/2/28
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-page" class="d-none" aria-current="false" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-recurring-tab">
<div class="accounting-description-editor-buttons">
{% for recurring in description_editor.recurring %}
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
<button class="btn btn-outline-primary accounting-description-editor-{{ description_editor.debit_credit }}-recurring-item" type="button" tabindex="-1" data-template="{{ recurring.description_template }}" data-accounts="{{ recurring.account_codes|tojson|forceescape }}">
{{ recurring.name }}
</button>
{% endfor %}

View File

@ -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 %}
<script src="{{ url_for("accounting.static", filename="js/option-form.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %}
{% block content %}
<form id="accounting-form" action="{{ url_for("accounting.option.form") }}" method="post" data-recurring-item-template="{{ form.recurring.item_template }}">
{{ form.csrf_token }}
{% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}">
{% endif %}
<div class="form-floating mb-3">
<select id="accounting-default-currency" class="form-select {% if form.default_currency.errors %} is-invalid {% endif %}" name="default_currency">
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == form.default_currency.data %} selected="selected" {% endif %}>{{ currency }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label>
<div id="accounting-default-currency-error" class="invalid-feedback">{% if form.default_currency.errors %}{{ form.default_currency.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<select id="accounting-default-ie-account" class="form-select {% if form.default_ie_account_code.errors %} is-invalid {% endif %}" name="default_ie_account_code">
{% for account in form.ie_accounts %}
<option value="{{ account.code }}" {% if account.code == form.default_ie_account_code.data %} selected="selected" {% endif %}>{{ account }}</option>
{% endfor %}
</select>
<label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
<div id="accounting-default-ie-account-error" class="invalid-feedback">{% if form.default_ie_account_code.errors %}{{ form.default_ie_account_code.errors[0] }}{% endif %}</div>
</div>
{% 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 %}
<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 accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% 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 %}

View File

@ -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
#}
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field accounting-not-empty">
<label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
<ul id="accounting-recurring-{{ expense_income }}-list" class="list-group mb-2 mt-2">
{% 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 %}
</ul>
<div>
<button id="accounting-recurring-{{ expense_income }}-add" class="btn btn-primary" type="button" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
<i class="fas fa-plus"></i>
{{ A_("New") }}
</button>
</div>
</div>

View File

@ -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
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-recurring-{{ expense_income }}-{{ item_index }}" class="list-group-item list-group-item-action accounting-recurring-{{ expense_income }}-item" data-item-index="{{ item_index }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-no" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-no" value="{{ item_index }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-name" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-name" value="{{ form.name.data|accounting_default }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-account-code" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text|accounting_default }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-description-template" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-description_template" value="{{ form.description_template.data|accounting_default }}">
<div class="d-flex justify-content-between">
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-content" class="w-100">
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-control" class="form-control accounting-clickable {% if form.form_errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-account-text" class="small">{{ form.account_text|accounting_default }}</div>
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-name-text">{{ form.name.data|accounting_default }}</div>
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-description-template-text" class="small">{{ form.description_template.data|accounting_default }}</div>
</div>
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-error" class="invalid-feedback">{% if form.form_errors %}{{ form.form_errors[0] }}{% endif %}</div>
</div>
<div class="ms-2">
<button id="accounting-recurring-{{ expense_income }}-{{ item_index }}-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -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
#}
<div id="accounting-recurring-accounting-selector-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-accounting-selector-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-recurring-accounting-selector-{{ expense_income }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-recurring-accounting-selector-{{ expense_income }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-recurring-accounting-selector-{{ expense_income }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-recurring-accounting-selector-{{ expense_income }}-option-list" class="list-group accounting-selector-list">
{% for account in accounts %}
<li id="accounting-recurring-accounting-selector-{{ expense_income }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-recurring-accounting-selector-{{ expense_income }}-option" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
{{ account }}
</li>
{% endfor %}
</ul>
<p id="accounting-recurring-accounting-selector-{{ expense_income }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Cancel") }}</button>
<button id="accounting-recurring-accounting-selector-{{ expense_income }}-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -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
#}
<form id="accounting-recurring-item-editor-{{ expense_income }}">
<div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-recurring-item-editor-{{ expense_income }}-modal-label">{{ title }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="form-floating mb-3">
<input id="accounting-recurring-item-editor-{{ expense_income }}-name" class="form-control" type="text" value="" placeholder=" " required="required">
<label for="accounting-recurring-item-editor-{{ expense_income }}-name">{{ A_("Name") }}</label>
<div id="accounting-recurring-item-editor-{{ expense_income }}-name-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-recurring-item-editor-{{ expense_income }}-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-recurring-accounting-selector-{{ expense_income }}-modal">
<label class="form-label" for="accounting-recurring-item-editor-{{ expense_income }}-account">{{ A_("Account") }}</label>
<div id="accounting-recurring-item-editor-{{ expense_income }}-account"></div>
</div>
<div id="accounting-recurring-item-editor-{{ expense_income }}-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-recurring-item-editor-{{ expense_income }}-description-template" class="form-control" type="text" value="" placeholder=" " required="required">
<label for="accounting-recurring-item-editor-{{ expense_income }}-description-template">{{ A_("Description Template") }}</label>
<div id="accounting-recurring-item-editor-{{ expense_income }}-description-template-error" class="invalid-feedback"></div>
</div>
<div class="mb-3 border-top accounting-recurring-description-template-illustration">
<p>{{ A_("Available template variables:") }}</p>
<ul>
<li><code>{this_month_number}</code>: {{ A_("This month, as a number.") }}</li>
<li><code>{this_month_name}</code>: {{ A_("This month, in its name.") }}</li>
<li><code>{last_month_number}</code>: {{ A_("Last month, as a number.") }}</li>
<li><code>{last_month_name}</code>: {{ A_("Last month, in its name.") }}</li>
<li><code>{last_bimonthly_number}</code>: {{ A_("The previous bimonthly period, as numbers.") }}</li>
<li><code>{last_bimonthly_name}</code>: {{ A_("The previous bimonthly period, as their names.") }}</li>
</ul>
<p>{{ A_("Example:") }} <code>{{ A_("Water bill for {last_bimonthly_name}") }}</code></p>
</div>
</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-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -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

View File

@ -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")

View File

@ -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]:

View File

@ -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