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:
parent
fa3cdace7f
commit
761d5a5824
@ -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)
|
||||
|
@ -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]:
|
||||
|
@ -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."""
|
||||
|
30
src/accounting/option/__init__.py
Normal file
30
src/accounting/option/__init__.py
Normal 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")
|
250
src/accounting/option/forms.py
Normal file
250
src/accounting/option/forms.py
Normal 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()
|
198
src/accounting/option/options.py
Normal file
198
src/accounting/option/options.py
Normal 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."""
|
69
src/accounting/option/views.py
Normal file
69
src/accounting/option/views.py
Normal 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")))
|
@ -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))
|
@ -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:
|
||||
|
@ -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())
|
||||
|
||||
|
||||
|
@ -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;
|
||||
|
941
src/accounting/static/js/option-form.js
Normal file
941
src/accounting/static/js/option-form.js
Normal 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("<", "<")
|
||||
.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");
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
101
src/accounting/templates/accounting/option/form.html
Normal file
101
src/accounting/templates/accounting/option/form.html
Normal 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 %}
|
@ -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>
|
@ -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 #}
|
@ -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>
|
@ -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>
|
@ -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
|
||||
|
@ -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")
|
||||
|
@ -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]:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user