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:
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")))
|
Reference in New Issue
Block a user