Compare commits

...

55 Commits

Author SHA1 Message Date
52807c5322 Advanced to version 0.9.0. 2023-03-23 00:48:14 +08:00
231a71feea Updated the Sphinx documentation. 2023-03-23 00:47:04 +08:00
4902eecae0 Updated the translation. 2023-03-23 00:46:18 +08:00
889e4c058e Revised the option form to have a better look when there is no recurring expense and income. 2023-03-23 00:45:19 +08:00
7262a6cb42 Removed an unused ID in the form-recurring-item.html template. 2023-03-23 00:27:23 +08:00
c4ff4ecb3d Fixed the layout in the option detail when there is no recurring expenses or incomes. 2023-03-23 00:24:57 +08:00
2859f628ea Fixed the error finding the account in the default_ie_account_code_text pseudo property of the Options data model. 2023-03-23 00:21:31 +08:00
e0355b2af1 Revised the error message of the CurrentAccountExists and AccountNotCurrent validators. 2023-03-23 00:09:57 +08:00
b4d390c33a Renamed the isMatches method to isMatched in the JavaScript RecurringAccount class. 2023-03-23 00:00:39 +08:00
a4ab8a761c Renamed the "content" dataset to "text" in the base account selector of the account form, for consistency. 2023-03-22 23:56:37 +08:00
907ce6d06e Renamed the "content" dataset to "text" in the account selector of the journal entry form, for consistency. 2023-03-22 23:55:28 +08:00
7e1388735e Added the OptionTestCase test case. 2023-03-22 23:50:14 +08:00
6f773dd837 Added the ACCOUNT_REQUIRED validator to the account_code field of the RecurringExpenseForm and RecurringIncomeForm forms. 2023-03-22 23:25:20 +08:00
87fa5aa6bc Moved the ACCOUNT_REQUIRED validator from the "accounting.journal_entry.forms.line_item" module to the "accounting.forms" module, in order to share it. 2023-03-22 23:23:53 +08:00
35e05b3708 Revised the IsDebitAccount and IsCreditAccount validators to require the error message, and moved their default error messages to the DebitLineItemForm and CreditLineItemForm forms. 2023-03-22 23:19:52 +08:00
7ccc96bda0 Added the CurrentAccountExists and AccountNotCurrent validators to the default_ie_account_code field of the OptionForm form, to validate that the current account exists and is a current account. 2023-03-22 23:16:31 +08:00
283758ebe9 Revised the shortcut accounts in testlib_journal_entry.py. 2023-03-22 22:56:37 +08:00
b673c7aeaf Renamed the "default_currency" option to "default_currency_code". 2023-03-22 22:34:13 +08:00
0ad2ac53dd Added the "sql_condition" method to the CurrentAccount data model to simplify the queries. 2023-03-22 21:43:58 +08:00
7e90ec5a8f Replaced the "current_accounts" function with the "accounts" class method of the CurrentAccount data model. 2023-03-22 21:39:18 +08:00
7755365467 Revised the documentation of the CurrentAccount data model. 2023-03-22 21:36:07 +08:00
979eea606a Added the missing documentation to the account property of the CurrentAccount data model. 2023-03-22 20:40:43 +08:00
5a9e08f2c4 Moved the AccountExists, IsDebitAccount, and IsCreditAccount validators from the "accounting.journal_entry.forms.line_item" module to the "accounting.forms" module, to share it with the "accounting.option.forms" module. Removed the redundant AccountExists, IsExpenseAccount, and IsIncomeAccount validators from the "accounting.option.forms" module. 2023-03-22 20:37:53 +08:00
68c810d492 Renamed the debit and credit methods in the Account data model to selectable_debit and selectable_credit, to be clear. 2023-03-22 20:21:52 +08:00
5f88260507 Revised the elements in the option detail page for better layout. 2023-03-22 20:16:08 +08:00
779d89f8c4 Replaced the "clear" method with the "onOpen" method when the account is clicked in the RecurringItemEditor in the JavaScript RecurringAccountSelector class. 2023-03-22 20:09:41 +08:00
5d4bf4361b Revised the error messages of the NotStartPayableFromExpense and NotStartReceivableFromIncome validators. 2023-03-22 20:00:17 +08:00
10170d613d Fixed the debit and credit methods of the Account data model, removing the payable accounts that need offset from the debit accounts, and the receivable accounts that need offset from the credit accounts. They should not be selectable. 2023-03-22 19:54:27 +08:00
c885c08c37 Moved the "accounting.option.options" module to "accounting.utils.options", because it is meant to shared by other submodules. 2023-03-22 19:47:37 +08:00
e2a4340f2a Revised the imports in the "accounting.option.views" module. 2023-03-22 19:43:10 +08:00
9728ff30e0 Renamed the IsDebitAccount, IsCreditAccount, NotStartPayableFromDebit, and NotStartReceivableFromCredit validators to IsExpenseAccount, IsIncomeAccount, NotStartPayableFromExpense, and NotStartReceivableFromIncome, respectively, in the "accounting.option.forms" module. 2023-03-22 19:41:54 +08:00
a4644ede5f Fixed and replaced the IsDebitAccount validator with the IsCreditAccount validator in the account_code field of the RecurringIncomeForm form. 2023-03-22 19:39:02 +08:00
8f477dd6f1 Added the all_errors pseudo property to the RecurringItemForm form, and applied it to the form-recurring-item.html template. 2023-03-22 19:37:20 +08:00
44ac53f15c Fixed and added the missing validation in the update_options route. 2023-03-22 19:33:21 +08:00
5edb5465c5 Fixed the incomes field of the RecurringForm form to use the RecurringIncomeForm form instead of the RecurringExpenseForm form as its sub-forms. 2023-03-22 19:29:42 +08:00
067afdb165 Fixed and moved the account_text pseudo property from the RecurringExpenseForm form to its base RecurringItemForm form. 2023-03-22 19:28:46 +08:00
37a4c26f86 Fixed the label in the option detail and option form. 2023-03-22 19:21:24 +08:00
89e43830b4 Fixed the __get_accounts method of the DescriptionEditor class not to do empty queries. 2023-03-22 19:17:25 +08:00
671dbfb692 Moved the CURRENCY_REQUIRED validator back from the "accounting.forms" module to the "accounting.journal_entry.forms.currency" module. It is not shared with other module anymore. 2023-03-22 19:14:51 +08:00
2014344d25 Revised to use its own error message for the DataRequired validator in the default_currency field of the OptionForm form. 2023-03-22 19:13:08 +08:00
f9c39709c8 Revised the text messages in the option forms. 2023-03-22 19:11:07 +08:00
b394c58ec6 Added support to sort the recurring items. 2023-03-22 19:01:02 +08:00
0af3e2785b Removed an unused import from the "accounting.option.forms" module. 2023-03-22 18:50:24 +08:00
7066f75e72 Added the read-only view for the options. 2023-03-22 16:08:16 +08:00
619540da49 Fixed the documentation of the form-recurring-expense-income.html template. 2023-03-22 15:47:19 +08:00
567004f7d9 Renamed IncomeExpensesAccount to CurrentAccount. 2023-03-22 15:42:44 +08:00
761d5a5824 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. 2023-03-22 15:34:28 +08:00
fa3cdace7f Renamed the #validateForm method to #validate in the JavaScript AccountForm and CurrencyForm. 2023-03-22 11:22:46 +08:00
656762850c Moved the IncomeExpensesAccount data model from the "accounting.report.utils.ie_account" module to the "accounting.utils.ie_account" module. 2023-03-22 07:29:41 +08:00
e2325f08d0 Moved the CURRENCY_REQUIRED and CurrencyExists validators from the "accounting.journal_entry.forms.currency" module to the "accounting.forms" module. 2023-03-22 07:17:45 +08:00
855356084e Fixed the documentation of the can_view and can_edit functions in the "accounting.utils.permission" module. 2023-03-22 04:50:12 +08:00
7aaeb32a3d Added the missing "role=" to the "<a...></a>" links that act like buttons. 2023-03-22 02:35:07 +08:00
b376cf1580 Revised the toolbar layout so that it looks better with only one toolbar button on the mobile devices. 2023-03-22 02:28:58 +08:00
ccbdc779ac Restored the "Back" button on the toolbar for the mobile devices. It is still necessary, because the user may get lost in the navigation history. 2023-03-22 02:28:29 +08:00
61ee08fda2 Revised the date format in the journal entry order page, and removed the individual date in the page, as it is redundant. 2023-03-22 02:12:19 +08:00
62 changed files with 3111 additions and 314 deletions

View File

@ -0,0 +1,29 @@
accounting.option package
=========================
Submodules
----------
accounting.option.forms module
------------------------------
.. automodule:: accounting.option.forms
:members:
:undoc-members:
:show-inheritance:
accounting.option.views module
------------------------------
.. automodule:: accounting.option.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.option
:members:
:undoc-members:
:show-inheritance:

View File

@ -28,14 +28,6 @@ accounting.report.utils.csv\_export module
:undoc-members:
:show-inheritance:
accounting.report.utils.ie\_account module
------------------------------------------
.. automodule:: accounting.report.utils.ie_account
:members:
:undoc-members:
:show-inheritance:
accounting.report.utils.option\_link module
-------------------------------------------

View File

@ -11,12 +11,21 @@ Subpackages
accounting.base_account
accounting.currency
accounting.journal_entry
accounting.option
accounting.report
accounting.utils
Submodules
----------
accounting.forms module
-----------------------
.. automodule:: accounting.forms
:members:
:undoc-members:
:show-inheritance:
accounting.locale module
------------------------

View File

@ -12,6 +12,14 @@ accounting.utils.cast module
:undoc-members:
:show-inheritance:
accounting.utils.current\_account module
----------------------------------------
.. automodule:: accounting.utils.current_account
:members:
:undoc-members:
:show-inheritance:
accounting.utils.flash\_errors module
-------------------------------------
@ -36,6 +44,14 @@ accounting.utils.next\_uri module
:undoc-members:
:show-inheritance:
accounting.utils.options module
-------------------------------
.. automodule:: accounting.utils.options
:members:
:undoc-members:
:show-inheritance:
accounting.utils.pagination module
----------------------------------

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask'
copyright = '2023, imacat'
author = 'imacat'
release = '0.8.0'
release = '0.9.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.8.0
version = 0.9.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.

View File

@ -86,4 +86,7 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
from . import report
report.init_app(app, bp)
from . import option
option.init_app(bp)
app.register_blueprint(bp)

96
src/accounting/forms.py Normal file
View File

@ -0,0 +1,96 @@
# 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.
"""
import re
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
"""The validator to check if the account code is empty."""
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
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 __init__(self, message: str | LazyString):
"""Constructs the validator.
:param message: The error message.
"""
self.__message: str | LazyString = message
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(self.__message)
class IsCreditAccount:
"""The validator to check if the account is for credit line items."""
def __init__(self, message: str | LazyString):
"""Constructs the validator.
:param message: The error message.
"""
self.__message: str | LazyString = message
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(self.__message)

View File

@ -27,29 +27,20 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
from wtforms.validators import DataRequired
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, JournalEntryLineItem
from accounting.forms import CurrencyExists
from accounting.journal_entry.utils.offset_alias import offset_alias
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency."))
"""The validator to check if the currency code is empty."""
class CurrencyExists:
"""The validator to check if the account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
return
if db.session.get(Currency, field.data) is None:
raise ValidationError(lazy_gettext(
"The currency does not exist."))
class SameCurrencyAsOriginalLineItems:
"""The validator to check if the currency is the same as the
original line items."""

View File

@ -220,7 +220,7 @@ class JournalEntryForm(FlaskForm):
:return: The selectable debit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.debit()
= [AccountOption(x) for x in Account.selectable_debit()
if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)
@ -237,7 +237,7 @@ class JournalEntryForm(FlaskForm):
:return: The selectable credit accounts.
"""
accounts: list[AccountOption] \
= [AccountOption(x) for x in Account.credit()
= [AccountOption(x) for x in Account.selectable_credit()
if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id)

View File

@ -17,7 +17,6 @@
"""The line item sub-forms for the journal entry management.
"""
import re
from datetime import date
from decimal import Decimal
@ -26,9 +25,11 @@ from flask_babel import LazyString
from flask_wtf import FlaskForm
from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import DataRequired, Optional
from wtforms.validators import Optional
from accounting import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntryLineItem
from accounting.template_filters import format_amount
@ -37,10 +38,6 @@ from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk
ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account."))
"""The validator to check if the account code is empty."""
class OriginalLineItemExists:
"""The validator to check if the original line item exists."""
@ -105,45 +102,6 @@ class OriginalLineItemNotOffset:
"The original line item cannot be an offset item."))
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 SameAccountAsOriginalLineItem:
"""The validator to check if the account is the same as the
original line item."""
@ -452,9 +410,11 @@ class DebitLineItemForm(LineItemForm):
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(),
IsDebitAccount(lazy_gettext(
"This account is not for debit line items.")),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartPayableFromDebit()])
@ -502,9 +462,11 @@ class CreditLineItemForm(LineItemForm):
"""The ID of the original line item."""
account_code = StringField(
filters=[strip_text],
validators=[ACCOUNT_REQUIRED,
validators=[
ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(),
IsCreditAccount(lazy_gettext(
"This account is not for credit line items.")),
SameAccountAsOriginalLineItem(),
KeepAccountWhenHavingOffset(),
NotStartReceivableFromCredit()])

View File

@ -21,10 +21,10 @@ import re
import typing as t
import sqlalchemy as sa
from flask import current_app
from accounting import db
from accounting.models import Account, JournalEntryLineItem
from accounting.utils.options import options, Recurring
class DescriptionAccount:
@ -148,16 +148,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 +280,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]:
@ -301,6 +299,9 @@ class DescriptionEditor:
:param codes: The account codes.
:return: The account.
"""
if len(codes) == 0:
return {}
def get_condition(code0: str) -> sa.BinaryExpression:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None,\

View File

@ -269,13 +269,15 @@ class Account(db.Model):
cls.no == int(m.group(2))).first()
@classmethod
def debit(cls) -> list[t.Self]:
"""Returns the debit accounts.
def selectable_debit(cls) -> list[t.Self]:
"""Returns the selectable debit accounts.
Payable line items can not start from debit.
:return: The debit accounts.
:return: The selectable debit accounts.
"""
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
cls.base_code.startswith("2"),
sa.and_(cls.base_code.startswith("2"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("3"),
cls.base_code.startswith("5"),
cls.base_code.startswith("6"),
@ -290,12 +292,14 @@ class Account(db.Model):
.order_by(cls.base_code, cls.no).all()
@classmethod
def credit(cls) -> list[t.Self]:
"""Returns the debit accounts.
def selectable_credit(cls) -> list[t.Self]:
"""Returns the selectable debit accounts.
Receivable line items can not start from credit.
:return: The debit accounts.
:return: The selectable debit accounts.
"""
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"),
sa.not_(cls.is_need_offset)),
cls.base_code.startswith("2"),
cls.base_code.startswith("3"),
cls.base_code.startswith("4"),
@ -779,3 +783,33 @@ class JournalEntryLineItem(db.Model):
journal_entry_day.day),
format_amount(self.amount),
format_amount(self.net_balance)])
class Option(db.Model):
"""An option."""
__tablename__ = "accounting_options"
"""The table name."""
name = db.Column(db.String, nullable=False, primary_key=True)
"""The name."""
value = db.Column(db.Text, nullable=False)
"""The option value."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of creation."""
created_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the creator."""
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
"""The creator."""
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
server_default=db.func.now())
"""The time of last update."""
updated_by_id = db.Column(db.Integer,
db.ForeignKey(user_pk_column,
onupdate="CASCADE"),
nullable=False)
"""The ID of the updator."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""

View File

@ -0,0 +1,30 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The option management.
"""
from flask import Blueprint
def init_app(bp: Blueprint) -> None:
"""Initialize the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
from .views import bp as option_bp
bp.register_blueprint(option_bp, url_prefix="/options")

View File

@ -0,0 +1,269 @@
# 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.
"""
from flask import render_template
from flask_babel import LazyString
from flask_wtf import FlaskForm
from wtforms import StringField, FieldList, FormField, IntegerField
from wtforms.validators import DataRequired, ValidationError
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
IsDebitAccount, IsCreditAccount
from accounting.locale import lazy_gettext
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import Options
from accounting.utils.strip_text import strip_text
class CurrentAccountExists:
"""The validator to check that the current account exists."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
return
if Account.find_by_code(field.data) is None:
raise ValidationError(lazy_gettext(
"The account does not exist."))
class AccountNotCurrent:
"""The validator to check that the account is a current account."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None or field.data == CurrentAccount.CURRENT_AL_CODE:
return
if field.data[:2] not in {"11", "12", "21", "22"}:
raise ValidationError(lazy_gettext(
"This is not a current account."))
class NotStartPayableFromExpense:
"""The validator to check that a payable line item does not start from
expense."""
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(
"You cannot select a payable account as expense."))
class NotStartReceivableFromIncome:
"""The validator to check that a receivable line item does not start
from income."""
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(
"You cannot select a receivable account as income."))
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."""
@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)
@property
def all_errors(self) -> list[str | LazyString]:
"""Returns all the errors of the form.
:return: All the errors of the form.
"""
all_errors: list[str | LazyString] = []
for key in self.errors:
if key != "csrf_token":
all_errors.extend(self.errors[key])
return all_errors
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=[
ACCOUNT_REQUIRED,
AccountExists(),
IsDebitAccount(lazy_gettext("This account is not for expense.")),
NotStartPayableFromExpense()])
"""The account code."""
description_template = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please fill in the description template."))])
"""The template for the line item description."""
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=[
ACCOUNT_REQUIRED,
AccountExists(),
IsCreditAccount(lazy_gettext("This account is not for income.")),
NotStartReceivableFromIncome()])
"""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(RecurringIncomeForm), 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.selectable_debit()
@property
def income_accounts(self) -> list[Account]:
"""The income accounts.
:return: None.
"""
return Account.selectable_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)
expenses: list[RecurringItemForm] = [x.form for x in self.expenses]
self.__sort_item_forms(expenses)
incomes: list[RecurringItemForm] = [x.form for x in self.incomes]
self.__sort_item_forms(incomes)
return {"expense": [as_tuple(x) for x in expenses],
"income": [as_tuple(x) for x in incomes]}
@staticmethod
def __sort_item_forms(forms: list[RecurringItemForm]) -> None:
"""Sorts the recurring item sub-forms.
:param forms: The recurring item sub-forms.
:return: None.
"""
ord_by_form: dict[RecurringItemForm, int] \
= {forms[i]: i for i in range(len(forms))}
recv_no: set[int] = {x.no.data for x in forms if x.no.data is not None}
missing_recv_no: int = 100 if len(recv_no) == 0 else max(recv_no) + 100
forms.sort(key=lambda x: (x.no.data or missing_recv_no,
ord_by_form.get(x)))
class OptionForm(FlaskForm):
"""The form to update the options."""
default_currency_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext("Please select the default currency.")),
CurrencyExists()])
"""The default currency code."""
default_ie_account_code = StringField(
filters=[strip_text],
validators=[
DataRequired(lazy_gettext(
"Please select the default account"
" for the income and expenses log.")),
CurrentAccountExists(),
AccountNotCurrent()])
"""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_code = self.default_currency_code.data
obj.default_ie_account_code = self.default_ie_account_code.data
obj.recurring_data = self.recurring.form.as_data
@property
def current_accounts(self) -> list[CurrentAccount]:
"""Returns the current accounts.
:return: The current accounts.
"""
return CurrentAccount.accounts()

View File

@ -0,0 +1,83 @@
# 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, urlencode
from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for
from werkzeug.datastructures import ImmutableMultiDict
from accounting.locale import lazy_gettext
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_admin
from .forms import OptionForm
bp: Blueprint = Blueprint("option", __name__)
"""The view blueprint for the currency management."""
@bp.get("", endpoint="detail")
@has_permission(can_admin)
def show_options() -> str:
"""Shows the options.
:return: The options.
"""
return render_template("accounting/option/detail.html", obj=options)
@bp.get("edit", endpoint="edit")
@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("update", endpoint="update")
@has_permission(can_admin)
def update_options() -> redirect:
"""Updates the options.
:return: The redirection to the option form.
"""
form = OptionForm(request.form)
if not form.validate():
flash_form_errors(form)
session["form"] = urlencode(list(request.form.items()))
return redirect(inherit_next(url_for("accounting.option.edit")))
form.populate_obj(options)
if not options.is_modified:
flash(s(lazy_gettext("The settings were not modified.")), "success")
return redirect(inherit_next(url_for("accounting.option.detail")))
options.commit()
flash(s(lazy_gettext("The settings are saved successfully.")), "success")
return redirect(inherit_next(url_for("accounting.option.detail")))

View File

@ -23,8 +23,8 @@ from flask import abort
from werkzeug.routing import BaseConverter
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from .period import Period, get_period
from .utils.ie_account import IncomeExpensesAccount
class PeriodConverter(BaseConverter):
@ -55,22 +55,22 @@ class IncomeExpensesAccountConverter(BaseConverter):
"""The supplier converter to convert the income and expenses log pseudo
account code from and to the corresponding pseudo account in the routes."""
def to_python(self, value: str) -> IncomeExpensesAccount:
def to_python(self, value: str) -> CurrentAccount:
"""Converts an account code to an account.
:param value: The account code.
:return: The corresponding account.
"""
if value == IncomeExpensesAccount.CURRENT_AL_CODE:
return IncomeExpensesAccount.current_assets_and_liabilities()
if value == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.current_assets_and_liabilities()
if not re.match("^[12][12]", value):
abort(404)
account: Account | None = Account.find_by_code(value)
if account is None:
abort(404)
return IncomeExpensesAccount(account)
return CurrentAccount(account)
def to_url(self, value: IncomeExpensesAccount) -> str:
def to_url(self, value: CurrentAccount) -> str:
"""Converts an account to account code.
:param value: The account.

View File

@ -33,12 +33,12 @@ from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.ie_account import IncomeExpensesAccount
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url
from accounting.utils.cast import be
from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination
@ -84,7 +84,7 @@ class ReportLineItem:
class LineItemCollector:
"""The line item collector."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
def __init__(self, currency: Currency, account: CurrentAccount,
period: Period):
"""Constructs the line item collector.
@ -94,7 +94,7 @@ class LineItemCollector:
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: IncomeExpensesAccount = account
self.__account: CurrentAccount = account
"""The account."""
self.__period: Period = period
"""The period"""
@ -173,11 +173,8 @@ class LineItemCollector:
@property
def __account_condition(self) -> sa.BinaryExpression:
if self.__account.code == IncomeExpensesAccount.CURRENT_AL_CODE:
return sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22"))
if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.sql_condition()
return Account.id == self.__account.id
def __get_total(self) -> ReportLineItem | None:
@ -264,7 +261,7 @@ class PageParams(BasePageParams):
"""The HTML page parameters."""
def __init__(self, currency: Currency,
account: IncomeExpensesAccount,
account: CurrentAccount,
period: Period,
has_data: bool,
pagination: Pagination[ReportLineItem],
@ -283,7 +280,7 @@ class PageParams(BasePageParams):
"""
self.currency: Currency = currency
"""The currency."""
self.account: IncomeExpensesAccount = account
self.account: CurrentAccount = account
"""The account."""
self.period: Period = period
"""The period."""
@ -341,8 +338,8 @@ class PageParams(BasePageParams):
:return: The account options.
"""
current_al: IncomeExpensesAccount \
= IncomeExpensesAccount.current_assets_and_liabilities()
current_al: CurrentAccount \
= CurrentAccount.current_assets_and_liabilities()
options: list[OptionLink] \
= [OptionLink(str(current_al),
income_expenses_url(self.currency, current_al,
@ -352,15 +349,12 @@ class PageParams(BasePageParams):
.join(Account)\
.filter(be(JournalEntryLineItem.currency_code
== self.currency.code),
sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22")))\
CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x),
income_expenses_url(
self.currency,
IncomeExpensesAccount(x),
CurrentAccount(x),
self.period),
x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use))
@ -371,7 +365,7 @@ class PageParams(BasePageParams):
class IncomeExpenses(BaseReport):
"""The income and expenses log."""
def __init__(self, currency: Currency, account: IncomeExpensesAccount,
def __init__(self, currency: Currency, account: CurrentAccount,
period: Period):
"""Constructs an income and expenses log.
@ -381,7 +375,7 @@ class IncomeExpenses(BaseReport):
"""
self.__currency: Currency = currency
"""The currency."""
self.__account: IncomeExpensesAccount = account
self.__account: CurrentAccount = account
"""The account."""
self.__period: Period = period
"""The period."""

View File

@ -30,7 +30,7 @@ from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount
from accounting.utils.current_account import CurrentAccount
from .option_link import OptionLink
from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \
@ -113,7 +113,7 @@ class ReportChooser:
account: Account = Account.cash()
return OptionLink(gettext("Income and Expenses Log"),
income_expenses_url(self.__currency,
IncomeExpensesAccount(account),
CurrentAccount(account),
self.__period),
self.__active_report == ReportType.INCOME_EXPENSES,
fa_icon="fa-solid fa-money-bill-wave")

View File

@ -22,7 +22,8 @@ from flask import url_for
from accounting.models import Currency, Account
from accounting.report.period import Period
from accounting.template_globals import default_currency_code
from .ie_account import IncomeExpensesAccount, default_ie_account_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import options
def journal_url(period: Period) \
@ -54,7 +55,7 @@ def ledger_url(currency: Currency, account: Account, period: Period) \
period=period)
def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
def income_expenses_url(currency: Currency, account: CurrentAccount,
period: Period) -> str:
"""Returns the URL of an income and expenses log.
@ -64,7 +65,7 @@ def income_expenses_url(currency: Currency, account: IncomeExpensesAccount,
:return: The URL of the income and expenses log.
"""
if currency.code == default_currency_code() \
and account.code == default_ie_account_code() \
and account.code == options.default_ie_account_code \
and period.is_default:
return url_for("accounting.report.default")
if period.is_default:

View File

@ -23,11 +23,12 @@ from accounting import db
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.options import options
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 IncomeExpensesAccount, default_ie_account
bp: Blueprint = Blueprint("report", __name__)
"""The view blueprint for the reports."""
@ -43,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())
@ -126,8 +127,7 @@ def __get_ledger(currency: Currency, account: Account, period: Period) \
@bp.get("income-expenses/<currency:currency>/<ieAccount:account>",
endpoint="income-expenses-default")
@has_permission(can_view)
def get_default_income_expenses(currency: Currency,
account: IncomeExpensesAccount) \
def get_default_income_expenses(currency: Currency, account: CurrentAccount) \
-> str | Response:
"""Returns the income and expenses log in the default period.
@ -142,7 +142,7 @@ def get_default_income_expenses(currency: Currency,
"income-expenses/<currency:currency>/<ieAccount:account>/<period:period>",
endpoint="income-expenses")
@has_permission(can_view)
def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
def get_income_expenses(currency: Currency, account: CurrentAccount,
period: Period) -> str | Response:
"""Returns the income and expenses log.
@ -154,7 +154,7 @@ def get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
return __get_income_expenses(currency, account, period)
def __get_income_expenses(currency: Currency, account: IncomeExpensesAccount,
def __get_income_expenses(currency: Currency, account: CurrentAccount,
period: Period) -> str | Response:
"""Returns the income and expenses log.

View File

@ -72,14 +72,11 @@
}
}
@media(max-width:767px) {
.accounting-toolbar {
width: 100%;
justify-content: space-evenly;
}
.accounting-toolbar > .btn:not(form), .accounting-toolbar > .btn-group > .btn {
height: 3.2rem;
width: 3.2rem;
border-radius: 50%;
margin-right: 0.5rem;
}
.accounting-toolbar > a.btn, .accounting-toolbar > .btn-group > a.btn {
padding-top: 0.7rem;
@ -334,6 +331,14 @@ a.accounting-report-table-row {
margin-top: 0.2rem;
}
/* The illustration of the description template for the recurring transactions */
.accounting-recurring-description-template-illustration p {
margin: 0.2rem 0;
}
.accounting-recurring-description-template-illustration ul {
margin: 0;
}
/* The Material Design text field (floating form control in Bootstrap) */
.accounting-material-text-field {
position: relative;

View File

@ -110,7 +110,7 @@ class AccountForm {
this.#isNeedOffsetControl = document.getElementById("accounting-is-need-offset-control");
this.#isNeedOffset = document.getElementById("accounting-is-need-offset");
this.#formElement.onsubmit = () => {
return this.#validateForm();
return this.#validate();
};
this.#baseControl.onclick = () => {
this.#baseControl.classList.add("accounting-not-empty");
@ -163,7 +163,7 @@ class AccountForm {
*
* @returns {boolean} true if valid, or false otherwise
*/
#validateForm() {
#validate() {
let isValid = true;
isValid = this.#validateBase() && isValid;
isValid = this.#validateTitle() && isValid;
@ -282,7 +282,7 @@ class BaseAccountSelector {
});
for (const option of this.#options) {
option.onclick = () => {
this.#form.setBaseAccount(option.dataset.code, option.dataset.content);
this.#form.setBaseAccount(option.dataset.code, option.dataset.text);
};
}
this.#clearButton.onclick = () => {

View File

@ -105,7 +105,7 @@ class AccountSelector {
};
this.#clearButton.onclick = () => this.#lineItemEditor.clearAccount();
for (const option of this.#options) {
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.content, option.classList.contains("accounting-account-is-need-offset"));
option.onclick = () => this.#lineItemEditor.saveAccount(option.dataset.code, option.dataset.text, option.classList.contains("accounting-account-is-need-offset"));
}
this.#query.addEventListener("input", () => {
this.#filterOptions();

View File

@ -81,7 +81,7 @@ class CurrencyForm {
this.#validateName();
};
this.#formElement.onsubmit = () => {
this.#validateForm().then((isValid) => {
this.#validate().then((isValid) => {
if (isValid) {
this.#formElement.submit();
}
@ -95,7 +95,7 @@ class CurrencyForm {
*
* @returns {Promise<boolean>} true if valid, or false otherwise
*/
async #validateForm() {
async #validate() {
let isValid = true;
isValid = await this.#validateCode() && isValid;
isValid = this.#validateName() && isValid;

File diff suppressed because it is too large Load Diff

View File

@ -17,9 +17,8 @@
"""The template globals.
"""
from flask import current_app
from accounting.models import Currency
from accounting.utils.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_code

View File

@ -26,17 +26,17 @@ First written: 2023/1/31
{% block content %}
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.order", base=obj.base)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
@ -57,7 +57,7 @@ First written: 2023/1/31
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.edit", account=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>

View File

@ -27,10 +27,10 @@ First written: 2023/2/1
{% block content %}
<div class="mb-3 d-none d-md-block">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@ -101,7 +101,7 @@ First written: 2023/2/1
<ul id="accounting-base-selector-option-list" class="list-group accounting-selector-list">
{% for base in form.base_options %}
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-content="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
<li class="list-group-item accounting-clickable accounting-base-selector-option" data-code="{{ base.code }}" data-text="{{ base }}" data-query-values="{{ base.query_values|tojson|forceescape }}" data-bs-dismiss="modal">
{{ base }}
</li>
{% endfor %}

View File

@ -27,7 +27,7 @@ First written: 2023/1/30
<div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
@ -45,7 +45,7 @@ First written: 2023/1/30
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>

View File

@ -30,10 +30,10 @@ First written: 2023/2/2
{% block content %}
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>

View File

@ -25,10 +25,10 @@ First written: 2023/2/1
{% block content %}
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
@ -38,7 +38,7 @@ First written: 2023/2/1
{% if obj.accounts %}
<div>
{% for account in obj.accounts %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.account.detail", account=account)|accounting_append_next }}">
{{ account }}
</a>
{% endfor %}

View File

@ -26,12 +26,12 @@ First written: 2023/2/6
{% block content %}
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.list")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
@ -53,7 +53,7 @@ First written: 2023/2/6
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.edit", currency=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>

View File

@ -27,10 +27,10 @@ First written: 2023/2/6
{% block content %}
<div class="mb-3 d-none d-md-block">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>

View File

@ -27,7 +27,7 @@ First written: 2023/2/6
<div class="mb-2 accounting-toolbar">
{% if accounting_can_edit() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<a class="btn btn-primary text-nowrap d-none d-md-block" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
{{ A_("New") }}
</a>
@ -45,7 +45,7 @@ First written: 2023/2/6
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.currency.create")|accounting_append_next }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>

View File

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

View File

@ -22,7 +22,7 @@ First written: 2023/2/26
{% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>

View File

@ -37,7 +37,7 @@ First written: 2023/2/25
<ul id="accounting-account-selector-{{ debit_credit }}-option-list" class="list-group accounting-selector-list">
{% for account in account_options %}
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-content="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
<li id="accounting-account-selector-{{ debit_credit }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-account-selector-{{ debit_credit }}-option {% if account.is_in_use %} accounting-account-in-use {% endif %} {% if account.is_need_offset %} accounting-account-is-need-offset {% endif %}" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-line-item-editor-modal">
{{ account }}
</li>
{% endfor %}

View File

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

View File

@ -26,17 +26,17 @@ First written: 2023/2/26
{% block content %}
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
{% if accounting_can_edit() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
{% endif %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i>
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a>
@ -58,7 +58,7 @@ First written: 2023/2/26
{% if accounting_can_edit() %}
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>

View File

@ -32,7 +32,7 @@ First written: 2023/2/26
{% block content %}
<div class="mb-3 d-none d-md-block">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>

View File

@ -21,7 +21,6 @@ First written: 2023/3/21
#}
<a class="small w-100 accounting-journal-entry-order-item" href="{{ url_for("accounting.journal-entry.detail", journal_entry=journal_entry)|accounting_append_next }}">
<div>
{{ journal_entry.date|accounting_format_date }}
{% if journal_entry.is_cash_disbursement %}
{{ A_("Cash Disbursement") }}
{% elif journal_entry.is_cash_receipt %}

View File

@ -26,14 +26,14 @@ First written: 2023/2/26
<script src="{{ url_for("accounting.static", filename="js/journal-entry-order.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date) }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Journal Entries on %(date)s", date=date|accounting_format_date) }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.report.default")|accounting_or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>

View File

@ -22,7 +22,7 @@ First written: 2023/2/26
{% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %}
<a class="btn btn-primary" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-bars-staggered"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
</a>

View File

@ -0,0 +1,63 @@
{#
The Mia! Accounting Flask Project
detail.html: The option detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary d-none d-md-inline" role="button" href="{{ url_for("accounting.option.edit")|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
{{ A_("Edit") }}
</a>
</div>
<div class="d-md-none accounting-material-fab">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.option.edit")|accounting_inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
<div class="form-floating mb-3">
<input id="accounting-default-currency" class="form-control" value="{{ obj.default_currency_text }}" readonly="readonly">
<label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label>
</div>
<div class="form-floating mb-3">
<input id="accounting-default-ie-account" class="form-control" value="{{ obj.default_ie_account_code_text }}" readonly="readonly">
<label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
</div>
{% with expense_income = "expense",
label = A_("Recurring Expense"),
recurring_items = obj.recurring.expenses %}
{% include "accounting/option/include/detail-recurring-expense-income.html" %}
{% endwith %}
{% with expense_income = "income",
label = A_("Recurring Income"),
recurring_items = obj.recurring.incomes %}
{% include "accounting/option/include/detail-recurring-expense-income.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,109 @@
{#
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/drag-and-drop-reorder.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/option-form.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{{ A_("Settings") }}{% endblock %}{% endblock %}
{% block content %}
<div class="mb-3 accounting-toolbar">
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.option.detail")|accounting_inherit_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
<span class="d-none d-md-inline">{{ A_("Back") }}</span>
</a>
</div>
<form id="accounting-form" action="{{ url_for("accounting.option.update") }}" 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_code.errors %} is-invalid {% endif %}" name="default_currency_code">
{% for currency in accounting_currency_options() %}
<option value="{{ currency.code }}" {% if currency.code == form.default_currency_code.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_code.errors %}{{ form.default_currency_code.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.current_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_("Recurring Expense"),
recurring_items = form.recurring.expenses %}
{% include "accounting/option/include/form-recurring-expense-income.html" %}
{% endwith %}
{% with expense_income = "income",
label = A_("Recurring Income"),
recurring_items = form.recurring.incomes %}
{% include "accounting/option/include/form-recurring-expense-income.html" %}
{% endwith %}
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none accounting-material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
{% with expense_income = "expense",
title = A_("Recurring Expense") %}
{% include "accounting/option/include/recurring-item-editor-modal.html" %}
{% endwith %}
{% with expense_income = "income",
title = A_("Recurring Income") %}
{% include "accounting/option/include/recurring-item-editor-modal.html" %}
{% endwith %}
{% with expense_income = "expense",
accounts = form.recurring.expense_accounts %}
{% include "accounting/option/include/recurring-account-selector-modal.html" %}
{% endwith %}
{% with expense_income = "income",
accounts = form.recurring.income_accounts %}
{% include "accounting/option/include/recurring-account-selector-modal.html" %}
{% endwith %}
{% endblock %}

View File

@ -0,0 +1,31 @@
{#
The Mia! Accounting Flask Project
detail-recurring-expense-income.html: The recurring expense or income in the option detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% endif %}">
<label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
{% if recurring_items %}
<ul class="list-group mb-2 mt-2">
{% for item in recurring_items %}
{% include "accounting/option/include/detail-recurring-item.html" %}
{% endfor %}
</ul>
{% endif %}
</div>

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
detail-recurring-item.html: The recurring item in the option detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li class="list-group-item list-group-item-action">
<div class="small">{{ item.account_text }}</div>
<div>{{ item.name }}</div>
<div class="small">{{ item.description_template }}</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -0,0 +1,41 @@
{#
The Mia! Accounting Flask Project
form-recurring-expense-income.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 {% if recurring_items %} accounting-not-empty {% else %} accounting-clickable {% endif %}">
<label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
<div id="accounting-recurring-{{ expense_income }}-content" class="{% if not recurring_items %} d-none {% endif %}">
<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>
</div>

View File

@ -0,0 +1,45 @@
{#
The Mia! Accounting Flask Project
form-recurring-item.html: The recurring item sub-form in the option form
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li id="accounting-recurring-{{ expense_income }}-{{ item_index }}" class="list-group-item list-group-item-action accounting-recurring-{{ expense_income }}-item" data-item-index="{{ item_index }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-no" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-no" value="{{ item_index }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-name" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-name" value="{{ form.name.data|accounting_default }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-account-code" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-account_code" value="{{ form.account_code.data|accounting_default }}" data-text="{{ form.account_text|accounting_default }}">
<input id="accounting-recurring-{{ expense_income }}-{{ item_index }}-description-template" type="hidden" name="recurring-{{ expense_income }}-{{ item_index }}-description_template" value="{{ form.description_template.data|accounting_default }}">
<div class="d-flex justify-content-between">
<div class="w-100">
<div id="accounting-recurring-{{ expense_income }}-{{ item_index }}-control" class="form-control accounting-clickable {% if form.all_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.all_errors %}{{ form.all_errors[0] }}{% endif %}</div>
</div>
<div class="ms-2">
<button id="accounting-recurring-{{ expense_income }}-{{ item_index }}-delete" class="btn btn-danger rounded-circle" type="button">
<i class="fas fa-minus"></i>
</button>
</div>
</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -0,0 +1,53 @@
{#
The Mia! Accounting Flask Project
recurring-account-selector-modal.html: The modal of the account selector for the recurring item editor
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<div id="accounting-recurring-accounting-selector-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-accounting-selector-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-recurring-accounting-selector-{{ expense_income }}-modal-label">{{ A_("Select Account") }}</h1>
<button type="button" class="btn-close" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="accounting-recurring-accounting-selector-{{ expense_income }}-query" class="form-control form-control-sm" type="search" placeholder=" " required="required">
<label class="input-group-text" for="accounting-recurring-accounting-selector-{{ expense_income }}-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul id="accounting-recurring-accounting-selector-{{ expense_income }}-option-list" class="list-group accounting-selector-list">
{% for account in accounts %}
<li id="accounting-recurring-accounting-selector-{{ expense_income }}-option-{{ account.code }}" class="list-group-item accounting-clickable accounting-recurring-accounting-selector-{{ expense_income }}-option" data-code="{{ account.code }}" data-text="{{ account }}" data-query-values="{{ account.query_values|tojson|forceescape }}" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">
{{ account }}
</li>
{% endfor %}
</ul>
<p id="accounting-recurring-accounting-selector-{{ expense_income }}-option-no-result" class="d-none">{{ A_("There is no data.") }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Cancel") }}</button>
<button id="accounting-recurring-accounting-selector-{{ expense_income }}-clear" type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#accounting-recurring-item-editor-{{ expense_income }}-modal">{{ A_("Clear") }}</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,71 @@
{#
The Mia! Accounting Flask Project
recurring-item-editor-modal.html: The modal of the recurring item editor
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<form id="accounting-recurring-item-editor-{{ expense_income }}">
<div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="accounting-recurring-item-editor-{{ expense_income }}-modal-label">{{ title }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ A_("Close") }}"></button>
</div>
<div class="modal-body">
<div class="form-floating mb-3">
<input id="accounting-recurring-item-editor-{{ expense_income }}-name" class="form-control" type="text" value="" placeholder=" " required="required">
<label for="accounting-recurring-item-editor-{{ expense_income }}-name">{{ A_("Name") }}</label>
<div id="accounting-recurring-item-editor-{{ expense_income }}-name-error" class="invalid-feedback"></div>
</div>
<div class="mb-3">
<div id="accounting-recurring-item-editor-{{ expense_income }}-account-control" class="form-control accounting-clickable accounting-material-text-field" data-bs-toggle="modal" data-bs-target="#accounting-recurring-accounting-selector-{{ expense_income }}-modal">
<label class="form-label" for="accounting-recurring-item-editor-{{ expense_income }}-account">{{ A_("Account") }}</label>
<div id="accounting-recurring-item-editor-{{ expense_income }}-account"></div>
</div>
<div id="accounting-recurring-item-editor-{{ expense_income }}-account-error" class="invalid-feedback"></div>
</div>
<div class="form-floating mb-3">
<input id="accounting-recurring-item-editor-{{ expense_income }}-description-template" class="form-control" type="text" value="" placeholder=" " required="required">
<label for="accounting-recurring-item-editor-{{ expense_income }}-description-template">{{ A_("Description Template") }}</label>
<div id="accounting-recurring-item-editor-{{ expense_income }}-description-template-error" class="invalid-feedback"></div>
</div>
<div class="mb-3 border-top accounting-recurring-description-template-illustration">
<p>{{ A_("Available template variables:") }}</p>
<ul>
<li><code>{this_month_number}</code>: {{ A_("This month, as a number.") }}</li>
<li><code>{this_month_name}</code>: {{ A_("This month, in its name.") }}</li>
<li><code>{last_month_number}</code>: {{ A_("Last month, as a number.") }}</li>
<li><code>{last_month_name}</code>: {{ A_("Last month, in its name.") }}</li>
<li><code>{last_bimonthly_number}</code>: {{ A_("The previous bimonthly period, as numbers.") }}</li>
<li><code>{last_bimonthly_name}</code>: {{ A_("The previous bimonthly period, as their names.") }}</li>
</ul>
<p>{{ A_("Example:") }} <code>{{ A_("Water bill for {last_bimonthly_name}") }}</code></p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-primary">{{ A_("Save") }}</button>
</div>
</div>
</div>
</div>
</form>

View File

@ -22,13 +22,13 @@ First written: 2023/2/25
{% if accounting_can_edit() %}
<div id="accounting-material-fab-speed-dial" class="d-md-none accounting-material-fab">
<div id="accounting-material-fab-speed-dial-actions" class="d-md-none accounting-material-fab-speed-dial-group">
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
<a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_DISBURSEMENT)|accounting_append_next }}">
{{ A_("Cash Disbursement") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
<a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.CASH_RECEIPT)|accounting_append_next }}">
{{ A_("Cash Receipt") }}
</a>
<a class="btn rounded-pill" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
<a class="btn rounded-pill" role="button" href="{{ url_for("accounting.journal-entry.create", journal_entry_type=report.journal_entry_types.TRANSFER)|accounting_append_next }}">
{{ A_("Transfer") }}
</a>
</div>

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-03-22 01:47+0800\n"
"PO-Revision-Date: 2023-03-22 01:47+0800\n"
"POT-Creation-Date: 2023-03-23 00:45+0800\n"
"PO-Revision-Date: 2023-03-23 00:46+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -19,22 +19,38 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.12.1\n"
#: src/accounting/models.py:559
#: src/accounting/forms.py:33
#: src/accounting/static/js/journal-entry-form.js:980
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
#: src/accounting/static/js/option-form.js:530
#: src/accounting/static/js/option-form.js:796
msgid "Please select the account."
msgstr "請選擇科目。"
#: src/accounting/forms.py:44
msgid "The currency does not exist."
msgstr "沒有這個貨幣。"
#: src/accounting/forms.py:55 src/accounting/option/forms.py:42
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/models.py:563
#, python-format
msgid "Cash Disbursement Journal Entry#%(id)s"
msgstr "現金支出傳票#%(id)s"
#: src/accounting/models.py:562
#: src/accounting/models.py:566
#, python-format
msgid "Cash Receipt Journal Entry#%(id)s"
msgstr "現金收入傳票#%(id)s"
#: src/accounting/models.py:563
#: src/accounting/models.py:567
#, python-format
msgid "Transfer Journal Entry#%(id)s"
msgstr "轉帳傳票#%(id)s"
#: src/accounting/models.py:696
#: src/accounting/models.py:700
#, python-format
msgid "%(date)s %(description)s %(amount)s"
msgstr "%(date)s %(description)s %(amount)s"
@ -141,8 +157,11 @@ msgstr "代碼限為三個大寫英文字母。"
msgid "This code is not available."
msgstr "不能用這個代碼。"
#: src/accounting/currency/forms.py:62
#: src/accounting/currency/forms.py:62 src/accounting/option/forms.py:124
#: src/accounting/option/forms.py:148
#: src/accounting/static/js/currency-form.js:153
#: src/accounting/static/js/option-form.js:525
#: src/accounting/static/js/option-form.js:780
msgid "Please fill in the name."
msgstr "請填上名稱。"
@ -186,28 +205,24 @@ msgstr "傳票不可刪除。"
msgid "The journal entry is deleted successfully."
msgstr "傳票刪掉了"
#: src/accounting/journal_entry/forms/currency.py:38
#: src/accounting/journal_entry/forms/currency.py:40
msgid "Please select the currency."
msgstr "請選擇貨幣。"
#: src/accounting/journal_entry/forms/currency.py:49
msgid "The currency does not exist."
msgstr "沒有這個貨幣。"
#: src/accounting/journal_entry/forms/currency.py:72
#: src/accounting/journal_entry/forms/currency.py:63
msgid "The currency must be the same as the original line item."
msgstr "貨幣需和原始分錄相同。"
#: src/accounting/journal_entry/forms/currency.py:99
#: src/accounting/journal_entry/forms/currency.py:90
msgid "The currency must not be changed when there is offset."
msgstr "抵銷過不可變更貨幣。"
#: src/accounting/journal_entry/forms/currency.py:108
#: src/accounting/journal_entry/forms/currency.py:99
#: src/accounting/static/js/journal-entry-form.js:729
msgid "Please add some line items."
msgstr "請加上分錄。"
#: src/accounting/journal_entry/forms/currency.py:121
#: src/accounting/journal_entry/forms/currency.py:112
#: src/accounting/static/js/journal-entry-form.js:516
msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 "
@ -236,62 +251,44 @@ msgstr "請加上貨幣。"
msgid "Line items with offset cannot be deleted."
msgstr "無法刪除抵銷過的分錄。"
#: src/accounting/journal_entry/forms/line_item.py:41
#: src/accounting/static/js/journal-entry-form.js:980
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
msgid "Please select the account."
msgstr "請選擇科目。"
#: src/accounting/journal_entry/forms/line_item.py:52
#: src/accounting/journal_entry/forms/line_item.py:49
msgid "The original line item does not exist."
msgstr "沒有這筆原始分錄。"
#: src/accounting/journal_entry/forms/line_item.py:73
#: src/accounting/journal_entry/forms/line_item.py:70
msgid "The original line item is on the same debit or credit."
msgstr "原始分錄在借貸同一邊。"
#: src/accounting/journal_entry/forms/line_item.py:88
#: src/accounting/journal_entry/forms/line_item.py:85
msgid "The original line item does not need offset."
msgstr "這筆原始分錄不需抵銷。"
#: src/accounting/journal_entry/forms/line_item.py:104
#: src/accounting/journal_entry/forms/line_item.py:101
msgid "The original line item cannot be an offset item."
msgstr "原始分錄不可以是抵銷分錄。"
#: src/accounting/journal_entry/forms/line_item.py:115
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/journal_entry/forms/line_item.py:129
msgid "This account is not for debit line items."
msgstr "科目不是借方科目。"
#: src/accounting/journal_entry/forms/line_item.py:143
msgid "This account is not for credit line items."
msgstr "科目不是貸方科目。"
#: src/accounting/journal_entry/forms/line_item.py:161
#: src/accounting/journal_entry/forms/line_item.py:119
msgid "The account must be the same as the original line item."
msgstr "科目需和原始分錄相同。"
#: src/accounting/journal_entry/forms/line_item.py:179
#: src/accounting/journal_entry/forms/line_item.py:137
msgid "The account must not be changed when there is offset."
msgstr "抵銷過不可變更科目。"
#: src/accounting/journal_entry/forms/line_item.py:195
#: src/accounting/journal_entry/forms/line_item.py:153
msgid "A payable line item cannot start from debit."
msgstr "不可由借方新建應付款。"
#: src/accounting/journal_entry/forms/line_item.py:211
#: src/accounting/journal_entry/forms/line_item.py:169
msgid "A receivable line item cannot start from credit."
msgstr "不可由貸方新建應收款。"
#: src/accounting/journal_entry/forms/line_item.py:222
#: src/accounting/journal_entry/forms/line_item.py:180
#: src/accounting/static/js/journal-entry-line-item-editor.js:455
msgid "Please fill in a positive amount."
msgstr "金額請填正數。"
#: src/accounting/journal_entry/forms/line_item.py:264
#: src/accounting/journal_entry/forms/line_item.py:222
#: src/accounting/static/js/journal-entry-line-item-editor.js:461
#, python-format
msgid ""
@ -299,12 +296,64 @@ msgid ""
"line item."
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
#: src/accounting/journal_entry/forms/line_item.py:285
#: src/accounting/journal_entry/forms/line_item.py:243
#: src/accounting/static/js/journal-entry-line-item-editor.js:469
#, python-format
msgid "The amount must not be less than the offset total %(total)s."
msgstr "金額不可低於抵銷總額 %(total)s 。"
#: src/accounting/journal_entry/forms/line_item.py:416
msgid "This account is not for debit line items."
msgstr "科目不是借方科目。"
#: src/accounting/journal_entry/forms/line_item.py:468
msgid "This account is not for credit line items."
msgstr "科目不是貸方科目。"
#: src/accounting/option/forms.py:53
msgid "This is not a current account."
msgstr "這不是流動科目。"
#: src/accounting/option/forms.py:66
msgid "You cannot select a payable account as expense."
msgstr "支出不能選應付科目。"
#: src/accounting/option/forms.py:79
msgid "You cannot select a receivable account as income."
msgstr "收入不能選應收科目。"
#: src/accounting/option/forms.py:131
msgid "This account is not for expense."
msgstr "科目不是支出科目。"
#: src/accounting/option/forms.py:137 src/accounting/option/forms.py:161
#: src/accounting/static/js/option-form.js:535
#: src/accounting/static/js/option-form.js:813
msgid "Please fill in the description template."
msgstr "請填上摘要範本。"
#: src/accounting/option/forms.py:155
msgid "This account is not for income."
msgstr "科目不是收入科目。"
#: src/accounting/option/forms.py:238
#: src/accounting/static/js/option-form.js:136
msgid "Please select the default currency."
msgstr "請選擇預設貨幣。"
#: src/accounting/option/forms.py:244
#: src/accounting/static/js/option-form.js:152
msgid "Please select the default account for the income and expenses log."
msgstr "請選擇收支帳的預設科目。"
#: src/accounting/option/views.py:79
msgid "The settings were not modified."
msgstr "設定未異動。"
#: src/accounting/option/views.py:82
msgid "The settings are saved successfully."
msgstr "設定存好了。"
#: src/accounting/report/period/description.py:33
msgid "for all time"
msgstr "全部"
@ -363,8 +412,8 @@ msgstr "全部"
#: src/accounting/report/reports/balance_sheet.py:426
#: src/accounting/report/reports/balance_sheet.py:438
#: src/accounting/report/reports/balance_sheet.py:440
#: src/accounting/report/reports/income_expenses.py:192
#: src/accounting/report/reports/income_expenses.py:429
#: src/accounting/report/reports/income_expenses.py:189
#: src/accounting/report/reports/income_expenses.py:423
#: src/accounting/report/reports/income_statement.py:299
#: src/accounting/report/reports/ledger.py:171
#: src/accounting/report/reports/ledger.py:380
@ -392,7 +441,7 @@ msgstr "合計"
msgid "Brought forward"
msgstr "前期轉入"
#: src/accounting/report/reports/income_expenses.py:413
#: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:155
#: src/accounting/report/reports/ledger.py:366
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
@ -404,10 +453,11 @@ msgstr "前期轉入"
msgid "Date"
msgstr "日期"
#: src/accounting/report/reports/income_expenses.py:413
#: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:156
#: src/accounting/report/reports/trial_balance.py:224
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
#: src/accounting/templates/accounting/report/income-expenses.html:56
#: src/accounting/templates/accounting/report/journal.html:55
@ -416,7 +466,7 @@ msgstr "日期"
msgid "Account"
msgstr "科目"
#: src/accounting/report/reports/income_expenses.py:414
#: src/accounting/report/reports/income_expenses.py:408
#: src/accounting/report/reports/journal.py:156
#: src/accounting/report/reports/ledger.py:366
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
@ -428,24 +478,24 @@ msgstr "科目"
msgid "Description"
msgstr "摘要"
#: src/accounting/report/reports/income_expenses.py:414
#: src/accounting/report/reports/income_expenses.py:408
#: src/accounting/templates/accounting/report/income-expenses.html:58
msgid "Income"
msgstr "收入"
#: src/accounting/report/reports/income_expenses.py:415
#: src/accounting/report/reports/income_expenses.py:409
#: src/accounting/templates/accounting/report/income-expenses.html:59
msgid "Expense"
msgstr "支出"
#: src/accounting/report/reports/income_expenses.py:415
#: src/accounting/report/reports/income_expenses.py:409
#: src/accounting/report/reports/ledger.py:368
#: src/accounting/templates/accounting/report/income-expenses.html:60
#: src/accounting/templates/accounting/report/ledger.html:60
msgid "Balance"
msgstr "餘額"
#: src/accounting/report/reports/income_expenses.py:416
#: src/accounting/report/reports/income_expenses.py:410
#: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/ledger.py:368
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
@ -515,10 +565,6 @@ msgstr "借方"
msgid "Credit"
msgstr "貸方"
#: src/accounting/report/utils/ie_account.py:64
msgid "current assets and liabilities"
msgstr "流動資產與負債"
#: src/accounting/report/utils/report_chooser.py:81
#: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40
@ -526,6 +572,7 @@ msgstr "流動資產與負債"
#: src/accounting/templates/accounting/currency/list.html:40
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:34
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:34
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:34
#: src/accounting/templates/accounting/report/include/search-modal.html:27
#: src/accounting/templates/accounting/report/include/search-modal.html:33
#: src/accounting/templates/accounting/report/include/search-modal.html:38
@ -678,12 +725,14 @@ msgstr "新增科目"
#: src/accounting/templates/accounting/journal-entry/include/detail.html:31
#: src/accounting/templates/accounting/journal-entry/include/form.html:38
#: src/accounting/templates/accounting/journal-entry/order.html:36
#: src/accounting/templates/accounting/option/form.html:36
msgid "Back"
msgstr "回上頁"
#: src/accounting/templates/accounting/account/detail.html:36
#: src/accounting/templates/accounting/currency/detail.html:36
#: src/accounting/templates/accounting/journal-entry/include/detail.html:36
#: src/accounting/templates/accounting/option/detail.html:31
msgid "Edit"
msgstr "編輯"
@ -713,6 +762,8 @@ msgstr "確認刪除科目"
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
#: src/accounting/templates/accounting/report/include/search-modal.html:28
msgid "Close"
@ -729,6 +780,8 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:193
#: src/accounting/templates/accounting/journal-entry/include/detail.html:84
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:70
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
#: src/accounting/templates/accounting/report/include/search-modal.html:37
msgid "Cancel"
msgstr "取消"
@ -773,6 +826,7 @@ msgstr "科目管理"
#: src/accounting/templates/accounting/currency/list.html:32
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:44
#: src/accounting/templates/accounting/journal-entry/include/form.html:64
#: src/accounting/templates/accounting/option/include/form-recurring-expense-income.html:37
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:26
msgid "New"
msgstr "新增"
@ -785,6 +839,7 @@ msgstr "新增"
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:46
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:51
#: src/accounting/templates/accounting/journal-entry/order.html:82
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:45
#: src/accounting/templates/accounting/report/balance-sheet.html:110
#: src/accounting/templates/accounting/report/income-expenses.html:113
#: src/accounting/templates/accounting/report/income-statement.html:96
@ -807,6 +862,8 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/journal-entry/include/form.html:80
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
#: src/accounting/templates/accounting/journal-entry/order.html:61
#: src/accounting/templates/accounting/option/form.html:80
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
msgid "Save"
msgstr "儲存"
@ -833,6 +890,7 @@ msgstr "選擇基本科目"
#: src/accounting/templates/accounting/account/include/form.html:114
#: src/accounting/templates/accounting/account/include/form.html:116
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:50
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:49
msgid "Clear"
msgstr "清除"
@ -866,6 +924,7 @@ msgid "Code"
msgstr "代碼"
#: src/accounting/templates/accounting/currency/include/form.html:50
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:33
msgid "Name"
msgstr "名稱"
@ -889,6 +948,12 @@ msgstr "基本科目"
msgid "Currencies"
msgstr "貨幣"
#: src/accounting/templates/accounting/include/nav.html:58
#: src/accounting/templates/accounting/option/detail.html:24
#: src/accounting/templates/accounting/option/form.html:29
msgid "Settings"
msgstr "設定"
#: src/accounting/templates/accounting/include/pagination.html:23
msgid "Page navigation"
msgstr "分頁瀏覽"
@ -923,6 +988,7 @@ msgid "Editing %(journal_entry)s"
msgstr "編輯%(journal_entry)s"
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:26
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:26
msgid "Select Account"
msgstr "選擇科目"
@ -1016,19 +1082,19 @@ msgstr "分錄內容"
msgid "Original Line Item"
msgstr "原始分錄"
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:26
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:25
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:26
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:31
msgid "Cash Disbursement"
msgstr "現金支出"
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:28
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:27
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:29
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:36
msgid "Cash Receipt"
msgstr "現金收入"
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:30
#: src/accounting/templates/accounting/journal-entry/include/order-journal-entry.html:29
#: src/accounting/templates/accounting/report/include/add-journal-entry-material-fab.html:32
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:41
msgid "Transfer"
@ -1046,6 +1112,68 @@ msgstr "新增現金收入傳票"
msgid "Add a New Transfer Journal Entry"
msgstr "新增轉帳傳票"
#: src/accounting/templates/accounting/option/detail.html:43
#: src/accounting/templates/accounting/option/form.html:51
msgid "Default Currency"
msgstr "預設貨幣"
#: src/accounting/templates/accounting/option/detail.html:48
#: src/accounting/templates/accounting/option/form.html:61
msgid "Default Account for the Income and Expenses Log"
msgstr "收支帳預設科目"
#: src/accounting/templates/accounting/option/detail.html:52
#: src/accounting/templates/accounting/option/form.html:66
#: src/accounting/templates/accounting/option/form.html:92
msgid "Recurring Expense"
msgstr "常用支出"
#: src/accounting/templates/accounting/option/detail.html:58
#: src/accounting/templates/accounting/option/form.html:72
#: src/accounting/templates/accounting/option/form.html:96
msgid "Recurring Income"
msgstr "常用收入"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:47
msgid "Description Template"
msgstr "摘要範本"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:52
msgid "Available template variables:"
msgstr "範本變數說明:"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:54
msgid "This month, as a number."
msgstr "這個月的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
msgid "This month, in its name."
msgstr "這個月的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
msgid "Last month, as a number."
msgstr "上個月的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
msgid "Last month, in its name."
msgstr "上個月的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
msgid "The previous bimonthly period, as numbers."
msgstr "前個雙月期的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
msgid "The previous bimonthly period, as their names."
msgstr "前個雙月期的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
msgid "Example:"
msgstr "範例:"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
msgid "Water bill for {last_bimonthly_name}"
msgstr "水費{last_bimonthly_number}月"
#: src/accounting/templates/accounting/report/balance-sheet.html:29
#: src/accounting/templates/accounting/report/balance-sheet.html:49
#, python-format
@ -1112,6 +1240,10 @@ msgstr "期間"
msgid "Download"
msgstr "下載"
#: src/accounting/utils/current_account.py:65
msgid "current assets and liabilities"
msgstr "流動資產與負債"
#: src/accounting/utils/pagination.py:206
msgctxt "Pagination|"
msgid "Previous"

View File

@ -1,5 +1,5 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat.
#
@ -14,28 +14,29 @@
# 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.
"""The current assets and liabilities account.
"""
import typing as t
from flask import current_app
from accounting import db
from accounting.locale import gettext
from accounting.models import Account
import sqlalchemy as sa
class IncomeExpensesAccount:
"""The pseudo account for the income and expenses log."""
class CurrentAccount:
"""A current assets and liabilities account."""
CURRENT_AL_CODE: str = "0000-000"
"""The account code for the current assets and liabilities."""
"""The account code for all current assets and liabilities."""
def __init__(self, account: Account | None = None):
"""Constructs the pseudo account for the income and expenses log.
"""Constructs the current assets and liabilities account.
:param account: The actual account.
"""
self.account: Account | None = account
"""The actual account."""
self.id: int = -1 if account is None else account.id
"""The ID."""
self.code: str = "" if account is None else account.code
@ -54,9 +55,9 @@ class IncomeExpensesAccount:
@classmethod
def current_assets_and_liabilities(cls) -> t.Self:
"""Returns the pseudo account for current assets and liabilities.
"""Returns the pseudo account for all current assets and liabilities.
:return: The pseudo account for current assets and liabilities.
:return: The pseudo account for all current assets and liabilities.
"""
account: cls = cls()
account.id = 0
@ -65,22 +66,28 @@ class IncomeExpensesAccount:
account.str = account.title
return account
@classmethod
def accounts(cls) -> list[t.Self]:
"""Returns the current assets and liabilities accounts.
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: The current assets and liabilities accounts.
"""
return current_app.config.get("ACCOUNTING_DEFAULT_IE_ACCOUNT",
Account.CASH_CODE)
accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x)
for x in db.session.query(Account)
.filter(cls.sql_condition())
.order_by(Account.base_code, Account.no)])
return accounts
@classmethod
def sql_condition(cls) -> sa.BinaryExpression:
"""Returns the SQL condition for the current assets and liabilities
accounts.
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.
:return: The SQL condition for the current assets and liabilities
accounts.
"""
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))
return sa.or_(Account.base_code.startswith("11"),
Account.base_code.startswith("12"),
Account.base_code.startswith("21"),
Account.base_code.startswith("22"))

View File

@ -0,0 +1,225 @@
# 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, Currency
from accounting.utils.current_account import CurrentAccount
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
@property
def account_text(self) -> str:
"""Returns the account text.
:return: The account text.
"""
return str(Account.find_by_code(self.account_code))
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_code(self) -> str:
"""Returns the default currency code.
:return: The default currency code.
"""
return self.__get_option("default_currency_code", "USD")
@default_currency_code.setter
def default_currency_code(self, value: str) -> None:
"""Sets the default currency code.
:param value: The default currency code.
:return: None.
"""
self.__set_option("default_currency_code", value)
@property
def default_currency_text(self) -> str:
"""Returns the text of the default currency code.
:return: The text of the default currency code.
"""
return str(db.session.get(Currency, self.default_currency_code))
@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_code_text(self) -> str:
"""Returns the text of the default currency code.
:return: The text of the default currency code.
"""
code: str = self.default_ie_account_code
if code == CurrentAccount.CURRENT_AL_CODE:
return str(CurrentAccount.current_assets_and_liabilities())
return str(CurrentAccount(Account.find_by_code(code)))
@property
def default_ie_account(self) -> CurrentAccount:
"""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 \
== CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.current_assets_and_liabilities()
return CurrentAccount(
Account.find_by_code(self.default_ie_account_code))
@property
def recurring_data(self) -> dict[str, list[tuple[str, str, str]]]:
"""Returns the data of the recurring expenses and incomes.
:return: The data of the recurring expenses and incomes.
"""
json_data: str | None = self.__get_option("recurring")
if json_data is None:
return {"expense": [], "income": []}
return json.loads(json_data)
@recurring_data.setter
def recurring_data(self,
value: dict[str, list[tuple[str, str, str]]]) -> None:
"""Sets the data of the recurring expenses and incomes.
:param value: The data of the recurring expenses and incomes.
:return: None.
"""
self.__set_option("recurring", json.dumps(value, ensure_ascii=False,
separators=(",", ":")))
@property
def recurring(self) -> Recurring:
"""Returns the recurring expenses and incomes.
:return: The recurring expenses and incomes.
"""
return Recurring(self.recurring_data)
@staticmethod
def __get_option(name: str, default: str | None = None) -> str:
"""Returns the value of an option.
:param name: The name.
:param default: The default value when the value does not exist.
:return: The value.
"""
option: Option | None = db.session.get(Option, name)
if option is None:
return default
return option.value
def __set_option(self, name: str, value: str) -> None:
"""Sets the value of an option.
:param name: The name.
:param value: The value.
:return: None.
"""
option: Option | None = db.session.get(Option, name)
if option is None:
current_user_pk: int = get_current_user_pk()
db.session.add(Option(name=name,
value=value,
created_by_id=current_user_pk,
updated_by_id=current_user_pk))
self.is_modified = True
return
if option.value == value:
return
option.value = value
option.updated_by_id = get_current_user_pk()
option.updated_at = sa.func.now()
self.is_modified = True
def commit(self) -> None:
"""Commits the options to the database.
:return: None.
"""
db.session.commit()
self.is_modified = False
options: Options = Options()
"""The options."""

View File

@ -63,10 +63,13 @@ 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:
"""Returns whether the current user can view the account data.
"""Returns whether the current user can view the accounting data.
:return: True if the current user can view the accounting data, or False
otherwise.
@ -75,7 +78,7 @@ def can_view() -> bool:
def can_edit() -> bool:
"""Returns whether the current user can edit the account data.
"""Returns whether the current user can edit the accounting data.
The user has to log in.
@ -87,6 +90,20 @@ def can_edit() -> bool:
return __can_edit_func()
def can_admin() -> bool:
"""Returns whether the current user can administrate the accounting
settings.
The user has to log in.
:return: True if the current user can administrate the accounting settings,
or False otherwise.
"""
if get_current_user() is None:
return False
return __can_admin_func()
def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
"""Initializes the application.
@ -94,8 +111,10 @@ def init_app(bp: Blueprint, user_utils: UserUtilityInterface) -> None:
:param user_utils: The user utilities.
:return: None.
"""
global __can_view_func, __can_edit_func
global __can_view_func, __can_edit_func, __can_admin_func
__can_view_func = user_utils.can_view
__can_edit_func = user_utils.can_edit
__can_admin_func = user_utils.can_admin
bp.add_app_template_global(user_utils.can_view, "accounting_can_view")
bp.add_app_template_global(user_utils.can_edit, "accounting_can_edit")
bp.add_app_template_global(user_utils.can_admin, "accounting_can_admin")

View File

@ -50,6 +50,15 @@ class UserUtilityInterface(t.Generic[T], ABC):
data, or False otherwise.
"""
@abstractmethod
def can_admin(self) -> bool:
"""Returns whether the currently logged-in user can administrate the
accounting settings.
:return: True if the currently logged-in user can administrate the
accounting settings, or False otherwise.
"""
@property
@abstractmethod
def cls(self) -> t.Type[T]:

View File

@ -309,7 +309,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.assertEqual(len(currencies[2].credit), 2)
self.assertEqual(currencies[2].credit[0].no, 6)
self.assertEqual(currencies[2].credit[0].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies[2].credit[1].no, 7)
self.assertEqual(currencies[2].credit[1].account.code,
Accounts.DONATION)
@ -441,7 +441,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.assertNotIn(currencies1[0].credit[1].id, old_id)
self.assertEqual(currencies1[0].credit[1].no, 2)
self.assertEqual(currencies1[0].credit[1].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies1[1].code, "EUR")
self.assertEqual(len(currencies1[1].debit), 1)
@ -457,7 +457,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
currencies0[2].credit[0].id)
self.assertEqual(currencies1[1].credit[0].no, 3)
self.assertEqual(currencies1[1].credit[0].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies1[1].credit[1].id,
currencies0[2].credit[1].id)
self.assertEqual(currencies1[1].credit[1].no, 4)
@ -1562,7 +1562,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertEqual(len(currencies[2].credit), 2)
self.assertEqual(currencies[2].credit[0].no, 6)
self.assertEqual(currencies[2].credit[0].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies[2].credit[1].no, 7)
self.assertEqual(currencies[2].credit[1].account.code,
Accounts.DONATION)
@ -1728,7 +1728,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertNotIn(currencies1[0].credit[1].id, old_id)
self.assertEqual(currencies1[0].credit[1].no, 2)
self.assertEqual(currencies1[0].credit[1].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies1[1].code, "EUR")
self.assertEqual(len(currencies1[1].debit), 2)
@ -1748,7 +1748,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
currencies0[2].credit[0].id)
self.assertEqual(currencies1[1].credit[0].no, 3)
self.assertEqual(currencies1[1].credit[0].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies1[1].credit[1].id,
currencies0[2].credit[1].id)
self.assertEqual(currencies1[1].credit[1].no, 4)
@ -1914,7 +1914,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertNotIn(currencies1[0].credit[1].id, old_id)
self.assertEqual(currencies1[0].credit[1].no, 2)
self.assertEqual(currencies1[0].credit[1].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies1[1].code, "EUR")
self.assertEqual(len(currencies1[1].debit), 1)
@ -1930,7 +1930,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
currencies0[2].credit[0].id)
self.assertEqual(currencies1[1].credit[0].no, 3)
self.assertEqual(currencies1[1].credit[0].account.code,
Accounts.RENT)
Accounts.RENT_INCOME)
self.assertEqual(currencies1[1].credit[1].id,
currencies0[2].credit[1].id)
self.assertEqual(currencies1[1].credit[1].no, 4)

417
tests/test_option.py Normal file
View File

@ -0,0 +1,417 @@
# 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 test for the options.
"""
import unittest
from datetime import datetime, timedelta
import httpx
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db
from testlib import create_test_app, get_client
from testlib_journal_entry import NEXT_URI, Accounts
from testlib_offset import TestData
PREFIX: str = "/accounting/options"
"""The URL prefix for the option management."""
DETAIL_URI: str = f"{PREFIX}?next=%2F_next"
"""THE URI for the option detail."""
EDIT_URI: str = f"{PREFIX}/edit?next=%2F_next"
"""THE URI for the form to edit the options."""
UPDATE_URI: str = f"{PREFIX}/update"
"""THE URI to update the options."""
class OptionTestCase(unittest.TestCase):
"""The option test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Option
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Option.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
self.data: TestData = TestData(self.app, self.client, self.csrf_token)
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
response: httpx.Response
response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI)
self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
"""Test the permission as viewer.
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
response: httpx.Response
response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI)
self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403)
def test_editor2(self) -> None:
"""Test the permission as non-administrator.
:return: None.
"""
client, csrf_token = get_client(self.app, "editor2")
response: httpx.Response
response = client.get(DETAIL_URI)
self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI)
self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
"""Test the permission as administrator.
:return: None.
"""
response: httpx.Response
response = self.client.get(DETAIL_URI)
self.assertEqual(response.status_code, 200)
response = self.client.get(EDIT_URI)
self.assertEqual(response.status_code, 200)
response = self.client.post(UPDATE_URI, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
def test_set(self) -> None:
"""Test to set the options.
:return: None.
"""
from accounting.utils.options import options
form: dict[str, str]
response: httpx.Response
# Empty currency code
form = self.__get_form()
form["default_currency_code"] = " "
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Non-existing currency code
form = self.__get_form()
form["default_currency_code"] = "ZZZ"
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Empty current account
form = self.__get_form()
form["default_ie_account_code"] = " "
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Non-existing current account
form = self.__get_form()
form["default_ie_account_code"] = "9999-999"
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Not a current account
form = self.__get_form()
form["default_ie_account_code"] = Accounts.MEAL
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Recurring item name empty
form = self.__get_form()
key = [x for x in form if x.endswith("-name")][0]
form[key] = " "
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Recurring item account empty
form = self.__get_form()
key = [x for x in form if x.endswith("-account_code")][0]
form[key] = " "
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Recurring item non-expense account
form = self.__get_form()
key = [x for x in form
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.SERVICE
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Recurring item non-income account
form = self.__get_form()
key = [x for x in form
if x.startswith("recurring-income-")
and x.endswith("-account_code")][0]
form[key] = Accounts.UTILITIES
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Recurring item payable expense
form = self.__get_form()
key = [x for x in form
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.PAYABLE
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Recurring item receivable income
form = self.__get_form()
key = [x for x in form
if x.startswith("recurring-income-")
and x.endswith("-account_code")][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Recurring item description template empty
form = self.__get_form()
key = [x for x in form if x.endswith("-description_template")][0]
form[key] = " "
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI)
# Success, with malformed order
with self.app.app_context():
self.assertEqual(options.default_currency_code, "USD")
self.assertEqual(options.default_ie_account_code, "1111-001")
self.assertEqual(len(options.recurring.expenses), 0)
self.assertEqual(len(options.recurring.incomes), 0)
response = self.client.post(UPDATE_URI, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context():
self.assertEqual(options.default_currency_code, "EUR")
self.assertEqual(options.default_ie_account_code, "0000-000")
self.assertEqual(len(options.recurring.expenses), 4)
self.assertEqual(options.recurring.expenses[0].account_code,
Accounts.INSURANCE)
self.assertEqual(options.recurring.expenses[1].account_code,
Accounts.POSTAGE)
self.assertEqual(options.recurring.expenses[2].account_code,
Accounts.UTILITIES)
self.assertEqual(options.recurring.expenses[3].account_code,
Accounts.RENT_EXPENSE)
self.assertEqual(len(options.recurring.incomes), 2)
self.assertEqual(options.recurring.incomes[0].account_code,
Accounts.SERVICE)
self.assertEqual(options.recurring.incomes[1].account_code,
Accounts.DONATION)
# Success, with no recurring data
form = self.__get_form()
form = {x: form[x] for x in form if not x.startswith("recurring-")}
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context():
self.assertEqual(len(options.recurring.expenses), 0)
self.assertEqual(len(options.recurring.incomes), 0)
def test_update_not_modified(self) -> None:
"""Tests that the data is not modified.
:return: None.
"""
from accounting.models import Option
form: dict[str, str]
option: Option | None
resource: httpx.Response
response = self.client.post(UPDATE_URI, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
timestamp: datetime = option.created_at - timedelta(seconds=5)
option.created_at = timestamp
option.updated_at = timestamp
db.session.commit()
# The recurring setting was not modified
form = self.__get_form()
form["default_currency_code"] = "JPY"
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
self.assertEqual(option.created_at, timestamp)
self.assertEqual(option.updated_at, timestamp)
# The recurring setting was modified
form = self.__get_form()
key: str = [x for x in form
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
self.assertLess(option.created_at, option.updated_at)
def test_created_updated_by(self) -> None:
"""Tests the created-by and updated-by record.
:return: None.
"""
from accounting.models import Option
from accounting.utils.user import get_user_pk
editor_username, editor2_username = "editor", "editor2"
option: Option | None
response: httpx.Response
response = self.client.post(UPDATE_URI, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context():
editor2_pk: int = get_user_pk(editor2_username)
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
option.created_by_id = editor2_pk
option.updated_by_id = editor2_pk
db.session.commit()
form: dict[str, str] = self.__get_form()
key: str = [x for x in form
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL
response = self.client.post(UPDATE_URI, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
self.assertEqual(option.created_by.username, editor2_username)
self.assertEqual(option.updated_by.username, editor_username)
def __get_form(self, csrf_token: str | None = None) -> dict[str, str]:
"""Returns the option form.
:param csrf_token: The CSRF token.
:return: The option form.
"""
if csrf_token is None:
csrf_token = self.csrf_token
return {"csrf_token": csrf_token,
"next": NEXT_URI,
"default_currency_code": "EUR",
"default_ie_account_code": "0000-000",
"recurring-expense-1-name": "Water bill",
"recurring-expense-1-account_code": Accounts.UTILITIES,
"recurring-expense-1-description_template":
"Water bill for {last_bimonthly_name}",
"recurring-expense-3-no": "16",
"recurring-expense-3-name": "Phone bill",
"recurring-expense-3-account_code": Accounts.POSTAGE,
"recurring-expense-3-description_template":
"Phone bill for {last_month_name}",
"recurring-expense-12-name": "Rent",
"recurring-expense-12-account_code": Accounts.RENT_EXPENSE,
"recurring-expense-12-description_template":
"Rent for {this_month_name}",
"recurring-expense-26-no": "7",
"recurring-expense-26-name": "Insurance",
"recurring-expense-26-account_code": Accounts.INSURANCE,
"recurring-expense-26-description_template":
"Insurance for {last_month_name}",
"recurring-income-2-no": "12",
"recurring-income-2-name": "Donation",
"recurring-income-2-account_code": Accounts.DONATION,
"recurring-income-2-description_template":
"Donation for {this_month_name}",
"recurring-income-15-no": "4",
"recurring-income-15-name": "Payroll",
"recurring-income-15-account_code": Accounts.SERVICE,
"recurring-income-15-description_template":
"Payroll for {last_month_name}"}

View File

@ -50,18 +50,6 @@ def create_app(is_testing: bool = False) -> Flask:
"SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
"ACCOUNTING_DEFAULT_CURRENCY": "USD",
"ACCOUNTING_DEFAULT_IE_ACCOUNT": "1111-001",
"ACCOUNTING_RECURRING": (
"debit|1314-001|Pension|Pension for {last_month_name},"
"debit|6262-001|Health insurance"
"|Health insurance for {last_month_name},"
"debit|6261-001|Electricity bill"
"|Electricity bill for {last_bimonthly_name},"
"debit|6261-001|Water bill|Water bill for {last_bimonthly_name},"
"debit|6261-001|Gas bill|Gas bill for {last_bimonthly_name},"
"debit|6261-001|Phone bill|Phone bill for {last_month_name},"
"credit|4611-001|Payroll|Payroll for {last_month_name}"),
})
if is_testing:
app.config["TESTING"] = True
@ -90,6 +78,10 @@ def create_app(is_testing: bool = False) -> Flask:
return auth.current_user() is not None \
and auth.current_user().username in ["editor", "editor2"]
def can_admin(self) -> bool:
return auth.current_user() is not None \
and auth.current_user().username == "editor"
@property
def cls(self) -> t.Type[auth.User]:
return auth.User

View File

@ -48,12 +48,16 @@ class Accounts:
SALES: str = "4111-001"
SERVICE: str = "4611-001"
AGENCY: str = "4711-001"
OFFICE: str = "6153-001"
TRAVEL: str = "6154-001"
MEAL: str = "6172-001"
RENT_EXPENSE: str = "6252-001"
OFFICE: str = "6253-001"
TRAVEL: str = "6254-001"
POSTAGE: str = "6256-001"
UTILITIES: str = "6261-001"
INSURANCE: str = "6262-001"
MEAL: str = "6272-001"
INTEREST: str = "7111-001"
DONATION: str = "7481-001"
RENT: str = "7482-001"
RENT_INCOME: str = "7482-001"
def get_add_form(csrf_token: str) -> dict[str, str]:
@ -115,7 +119,7 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
"currency-16-debit-9-description": " Gas ",
"currency-16-debit-9-amount": "30000",
"currency-16-credit-6-no": "6",
"currency-16-credit-6-account_code": Accounts.RENT,
"currency-16-credit-6-account_code": Accounts.RENT_INCOME,
"currency-16-credit-6-description": " Rent ",
"currency-16-credit-6-amount": "35000",
"currency-16-credit-9-account_code": Accounts.DONATION,
@ -349,7 +353,7 @@ def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
f"{prefix}debit-14-description": " ",
f"{prefix}debit-14-amount": "14.55",
f"{prefix}credit-16-no": "7",
f"{prefix}credit-16-account_code": Accounts.RENT,
f"{prefix}credit-16-account_code": Accounts.RENT_INCOME,
f"{prefix}credit-16-description": " Bike ",
f"{prefix}credit-16-amount": "19.5",
f"{prefix}credit-22-no": "5",