Added the transaction management.
This commit is contained in:
		
							
								
								
									
										37
									
								
								src/accounting/transaction/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/accounting/transaction/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| # The Mia! Accounting Flask Project. | ||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||
|  | ||||
| #  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 transaction management. | ||||
|  | ||||
| """ | ||||
| from flask import Flask, Blueprint | ||||
|  | ||||
|  | ||||
| def init_app(app: Flask, bp: Blueprint) -> None: | ||||
|     """Initialize the application. | ||||
|  | ||||
|     :param app: The Flask application. | ||||
|     :param bp: The blueprint of the accounting application. | ||||
|     :return: None. | ||||
|     """ | ||||
|     from .converters import TransactionConverter, TransactionTypeConverter, \ | ||||
|         DateConverter | ||||
|     app.url_map.converters["transaction"] = TransactionConverter | ||||
|     app.url_map.converters["transactionType"] = TransactionTypeConverter | ||||
|     app.url_map.converters["date"] = DateConverter | ||||
|  | ||||
|     from .views import bp as transaction_bp | ||||
|     bp.register_blueprint(transaction_bp, url_prefix="/transactions") | ||||
							
								
								
									
										100
									
								
								src/accounting/transaction/converters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								src/accounting/transaction/converters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,100 @@ | ||||
| # The Mia! Accounting Flask Project. | ||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 | ||||
|  | ||||
| #  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 path converters for the transaction management. | ||||
|  | ||||
| """ | ||||
| from datetime import date | ||||
|  | ||||
| from flask import abort | ||||
| from werkzeug.routing import BaseConverter | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.models import Transaction | ||||
| from accounting.transaction.dispatcher import TransactionType, \ | ||||
|     TXN_TYPE_DICT | ||||
|  | ||||
|  | ||||
| class TransactionConverter(BaseConverter): | ||||
|     """The transaction converter to convert the transaction ID from and to the | ||||
|     corresponding transaction in the routes.""" | ||||
|  | ||||
|     def to_python(self, value: str) -> Transaction: | ||||
|         """Converts a transaction ID to a transaction. | ||||
|  | ||||
|         :param value: The transaction ID. | ||||
|         :return: The corresponding transaction. | ||||
|         """ | ||||
|         transaction: Transaction | None = db.session.get(Transaction, value) | ||||
|         if transaction is None: | ||||
|             abort(404) | ||||
|         return transaction | ||||
|  | ||||
|     def to_url(self, value: Transaction) -> str: | ||||
|         """Converts a transaction to its ID. | ||||
|  | ||||
|         :param value: The transaction. | ||||
|         :return: The ID. | ||||
|         """ | ||||
|         return str(value.id) | ||||
|  | ||||
|  | ||||
| class TransactionTypeConverter(BaseConverter): | ||||
|     """The transaction converter to convert the transaction type ID from and to | ||||
|     the corresponding transaction type in the routes.""" | ||||
|  | ||||
|     def to_python(self, value: str) -> TransactionType: | ||||
|         """Converts a transaction ID to a transaction. | ||||
|  | ||||
|         :param value: The transaction ID. | ||||
|         :return: The corresponding transaction. | ||||
|         """ | ||||
|         txn_type: TransactionType | None = TXN_TYPE_DICT.get(value) | ||||
|         if txn_type is None: | ||||
|             abort(404) | ||||
|         return txn_type | ||||
|  | ||||
|     def to_url(self, value: TransactionType) -> str: | ||||
|         """Converts a transaction type to its ID. | ||||
|  | ||||
|         :param value: The transaction type. | ||||
|         :return: The ID. | ||||
|         """ | ||||
|         return str(value.ID) | ||||
|  | ||||
|  | ||||
| class DateConverter(BaseConverter): | ||||
|     """The date converter to convert the ISO date from and to the | ||||
|     corresponding date in the routes.""" | ||||
|  | ||||
|     def to_python(self, value: str) -> date: | ||||
|         """Converts an ISO date to a date. | ||||
|  | ||||
|         :param value: The ISO date. | ||||
|         :return: The corresponding date. | ||||
|         """ | ||||
|         try: | ||||
|             return date.fromisoformat(value) | ||||
|         except ValueError: | ||||
|             abort(404) | ||||
|  | ||||
|     def to_url(self, value: date) -> str: | ||||
|         """Converts a date to its ISO date. | ||||
|  | ||||
|         :param value: The date. | ||||
|         :return: The ISO date. | ||||
|         """ | ||||
|         return value.isoformat() | ||||
							
								
								
									
										344
									
								
								src/accounting/transaction/dispatcher.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										344
									
								
								src/accounting/transaction/dispatcher.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,344 @@ | ||||
| # The Mia! Accounting Flask Project. | ||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 | ||||
|  | ||||
| #  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 transaction type dispatcher. | ||||
|  | ||||
| """ | ||||
| import typing as t | ||||
| from abc import ABC, abstractmethod | ||||
|  | ||||
| from flask import render_template, request, abort | ||||
| from flask_wtf import FlaskForm | ||||
|  | ||||
| from accounting.models import Transaction | ||||
| from .forms import TransactionForm, IncomeTransactionForm, \ | ||||
|     ExpenseTransactionForm, TransferTransactionForm | ||||
| from .template import default_currency_code | ||||
|  | ||||
|  | ||||
| class TransactionType(ABC): | ||||
|     """An abstract transaction type.""" | ||||
|     ID: str = "" | ||||
|     """The transaction type ID.""" | ||||
|     CHECK_ORDER: int = -1 | ||||
|     """The order when checking the transaction type.""" | ||||
|  | ||||
|     @property | ||||
|     @abstractmethod | ||||
|     def form(self) -> t.Type[TransactionForm]: | ||||
|         """Returns the form class. | ||||
|  | ||||
|         :return: The form class. | ||||
|         """ | ||||
|  | ||||
|     @abstractmethod | ||||
|     def render_create_template(self, form: FlaskForm) -> str: | ||||
|         """Renders the template for the form to create a transaction. | ||||
|  | ||||
|         :param form: The transaction form. | ||||
|         :return: the form to create a transaction. | ||||
|         """ | ||||
|  | ||||
|     @abstractmethod | ||||
|     def render_detail_template(self, txn: Transaction) -> str: | ||||
|         """Renders the template for the detail page. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: the detail page. | ||||
|         """ | ||||
|  | ||||
|     @abstractmethod | ||||
|     def render_edit_template(self, txn: Transaction, form: FlaskForm) -> str: | ||||
|         """Renders the template for the form to edit a transaction. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :param form: The form. | ||||
|         :return: the form to edit a transaction. | ||||
|         """ | ||||
|  | ||||
|     @abstractmethod | ||||
|     def is_my_type(self, txn: Transaction) -> bool: | ||||
|         """Checks and returns whether the transaction belongs to the type. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: True if the transaction belongs to the type, or False | ||||
|             otherwise. | ||||
|         """ | ||||
|  | ||||
|     @property | ||||
|     def _entry_template(self) -> str: | ||||
|         """Renders and returns the template for the journal entry sub-form. | ||||
|  | ||||
|         :return: The template for the journal entry sub-form. | ||||
|         """ | ||||
|         return render_template( | ||||
|             "accounting/transaction/include/form-entry-item.html", | ||||
|             currency_index="CURRENCY_INDEX", | ||||
|             entry_type="ENTRY_TYPE", | ||||
|             entry_index="ENTRY_INDEX") | ||||
|  | ||||
|  | ||||
| class IncomeTransaction(TransactionType): | ||||
|     """An income transaction.""" | ||||
|     ID: str = "income" | ||||
|     """The transaction type ID.""" | ||||
|     CHECK_ORDER: int = 2 | ||||
|     """The order when checking the transaction type.""" | ||||
|  | ||||
|     @property | ||||
|     def form(self) -> t.Type[TransactionForm]: | ||||
|         """Returns the form class. | ||||
|  | ||||
|         :return: The form class. | ||||
|         """ | ||||
|         return IncomeTransactionForm | ||||
|  | ||||
|     def render_create_template(self, form: IncomeTransactionForm) -> str: | ||||
|         """Renders the template for the form to create a transaction. | ||||
|  | ||||
|         :param form: The transaction form. | ||||
|         :return: the form to create a transaction. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/income/create.html", | ||||
|                                form=form, txn_type=self, | ||||
|                                currency_template=self.__currency_template, | ||||
|                                entry_template=self._entry_template) | ||||
|  | ||||
|     def render_detail_template(self, txn: Transaction) -> str: | ||||
|         """Renders the template for the detail page. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: the detail page. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/income/detail.html", | ||||
|                                obj=txn) | ||||
|  | ||||
|     def render_edit_template(self, txn: Transaction, | ||||
|                              form: IncomeTransactionForm) -> str: | ||||
|         """Renders the template for the form to edit a transaction. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :param form: The form. | ||||
|         :return: the form to edit a transaction. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/income/edit.html", | ||||
|                                txn=txn, form=form, | ||||
|                                currency_template=self.__currency_template, | ||||
|                                entry_template=self._entry_template) | ||||
|  | ||||
|     def is_my_type(self, txn: Transaction) -> bool: | ||||
|         """Checks and returns whether the transaction belongs to the type. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: True if the transaction belongs to the type, or False | ||||
|             otherwise. | ||||
|         """ | ||||
|         return txn.is_cash_income | ||||
|  | ||||
|     @property | ||||
|     def __currency_template(self) -> str: | ||||
|         """Renders and returns the template for the currency sub-form. | ||||
|  | ||||
|         :return: The template for the currency sub-form. | ||||
|         """ | ||||
|         return render_template( | ||||
|             "accounting/transaction/income/include/form-currency-item.html", | ||||
|             currency_index="CURRENCY_INDEX", | ||||
|             currency_code_data=default_currency_code(), | ||||
|             credit_total="-") | ||||
|  | ||||
|  | ||||
| class ExpenseTransaction(TransactionType): | ||||
|     """An expense transaction.""" | ||||
|     ID: str = "expense" | ||||
|     """The transaction type ID.""" | ||||
|     CHECK_ORDER: int = 1 | ||||
|     """The order when checking the transaction type.""" | ||||
|  | ||||
|     @property | ||||
|     def form(self) -> t.Type[TransactionForm]: | ||||
|         """Returns the form class. | ||||
|  | ||||
|         :return: The form class. | ||||
|         """ | ||||
|         return ExpenseTransactionForm | ||||
|  | ||||
|     def render_create_template(self, form: ExpenseTransactionForm) -> str: | ||||
|         """Renders the template for the form to create a transaction. | ||||
|  | ||||
|         :param form: The transaction form. | ||||
|         :return: the form to create a transaction. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/expense/create.html", | ||||
|                                form=form, txn_type=self, | ||||
|                                currency_template=self.__currency_template, | ||||
|                                entry_template=self._entry_template) | ||||
|  | ||||
|     def render_detail_template(self, txn: Transaction) -> str: | ||||
|         """Renders the template for the detail page. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: the detail page. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/expense/detail.html", | ||||
|                                obj=txn) | ||||
|  | ||||
|     def render_edit_template(self, txn: Transaction, | ||||
|                              form: ExpenseTransactionForm) -> str: | ||||
|         """Renders the template for the form to edit a transaction. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :param form: The form. | ||||
|         :return: the form to edit a transaction. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/expense/edit.html", | ||||
|                                txn=txn, form=form, | ||||
|                                currency_template=self.__currency_template, | ||||
|                                entry_template=self._entry_template) | ||||
|  | ||||
|     def is_my_type(self, txn: Transaction) -> bool: | ||||
|         """Checks and returns whether the transaction belongs to the type. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: True if the transaction belongs to the type, or False | ||||
|             otherwise. | ||||
|         """ | ||||
|         return txn.is_cash_expense | ||||
|  | ||||
|     @property | ||||
|     def __currency_template(self) -> str: | ||||
|         """Renders and returns the template for the currency sub-form. | ||||
|  | ||||
|         :return: The template for the currency sub-form. | ||||
|         """ | ||||
|         return render_template( | ||||
|             "accounting/transaction/expense/include/form-currency-item.html", | ||||
|             currency_index="CURRENCY_INDEX", | ||||
|             currency_code_data=default_currency_code(), | ||||
|             debit_total="-") | ||||
|  | ||||
|  | ||||
| class TransferTransaction(TransactionType): | ||||
|     """A transfer transaction.""" | ||||
|     ID: str = "transfer" | ||||
|     """The transaction type ID.""" | ||||
|     CHECK_ORDER: int = 3 | ||||
|     """The order when checking the transaction type.""" | ||||
|  | ||||
|     @property | ||||
|     def form(self) -> t.Type[TransactionForm]: | ||||
|         """Returns the form class. | ||||
|  | ||||
|         :return: The form class. | ||||
|         """ | ||||
|         return TransferTransactionForm | ||||
|  | ||||
|     def render_create_template(self, form: TransferTransactionForm) -> str: | ||||
|         """Renders the template for the form to create a transaction. | ||||
|  | ||||
|         :param form: The transaction form. | ||||
|         :return: the form to create a transaction. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/transfer/create.html", | ||||
|                                form=form, txn_type=self, | ||||
|                                currency_template=self.__currency_template, | ||||
|                                entry_template=self._entry_template) | ||||
|  | ||||
|     def render_detail_template(self, txn: Transaction) -> str: | ||||
|         """Renders the template for the detail page. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: the detail page. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/transfer/detail.html", | ||||
|                                obj=txn) | ||||
|  | ||||
|     def render_edit_template(self, txn: Transaction, | ||||
|                              form: TransferTransactionForm) -> str: | ||||
|         """Renders the template for the form to edit a transaction. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :param form: The form. | ||||
|         :return: the form to edit a transaction. | ||||
|         """ | ||||
|         return render_template("accounting/transaction/transfer/edit.html", | ||||
|                                txn=txn, form=form, | ||||
|                                currency_template=self.__currency_template, | ||||
|                                entry_template=self._entry_template) | ||||
|  | ||||
|     def is_my_type(self, txn: Transaction) -> bool: | ||||
|         """Checks and returns whether the transaction belongs to the type. | ||||
|  | ||||
|         :param txn: The transaction. | ||||
|         :return: True if the transaction belongs to the type, or False | ||||
|             otherwise. | ||||
|         """ | ||||
|         return True | ||||
|  | ||||
|     @property | ||||
|     def __currency_template(self) -> str: | ||||
|         """Renders and returns the template for the currency sub-form. | ||||
|  | ||||
|         :return: The template for the currency sub-form. | ||||
|         """ | ||||
|         return render_template( | ||||
|             "accounting/transaction/transfer/include/form-currency-item.html", | ||||
|             currency_index="CURRENCY_INDEX", | ||||
|             currency_code_data=default_currency_code(), | ||||
|             debit_total="-", credit_total="-") | ||||
|  | ||||
|  | ||||
| class TransactionTypes: | ||||
|     """The transaction types, as object properties.""" | ||||
|  | ||||
|     def __init__(self, income: IncomeTransaction, expense: ExpenseTransaction, | ||||
|                  transfer: TransferTransaction): | ||||
|         """Constructs the transaction types as object properties. | ||||
|  | ||||
|         :param income: The income transaction type. | ||||
|         :param expense: The expense transaction type. | ||||
|         :param transfer: The transfer transaction type. | ||||
|         """ | ||||
|         self.income: IncomeTransaction = income | ||||
|         self.expense: ExpenseTransaction = expense | ||||
|         self.transfer: TransferTransaction = transfer | ||||
|  | ||||
|  | ||||
| TXN_TYPE_DICT: dict[str, TransactionType] \ | ||||
|     = {x.ID: x() for x in {IncomeTransaction, | ||||
|                            ExpenseTransaction, | ||||
|                            TransferTransaction}} | ||||
| """The transaction types, as a dictionary.""" | ||||
| TXN_TYPE_OBJ: TransactionTypes = TransactionTypes(**TXN_TYPE_DICT) | ||||
| """The transaction types, as an object.""" | ||||
|  | ||||
|  | ||||
| def get_txn_type(txn: Transaction) -> TransactionType: | ||||
|     """Returns the transaction type that may be specified in the "as" query | ||||
|     parameter.  If it is not specified, check the transaction type from the | ||||
|     transaction. | ||||
|  | ||||
|     :param txn: The transaction. | ||||
|     :return: None. | ||||
|     """ | ||||
|     if "as" in request.args: | ||||
|         if request.args["as"] not in TXN_TYPE_DICT: | ||||
|             abort(404) | ||||
|         return TXN_TYPE_DICT[request.args["as"]] | ||||
|     for txn_type in sorted(TXN_TYPE_DICT.values(), | ||||
|                            key=lambda x: x.CHECK_ORDER): | ||||
|         if txn_type.is_my_type(txn): | ||||
|             return txn_type | ||||
							
								
								
									
										832
									
								
								src/accounting/transaction/forms.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										832
									
								
								src/accounting/transaction/forms.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,832 @@ | ||||
| # The Mia! Accounting Flask Project. | ||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||
|  | ||||
| #  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 transaction management. | ||||
|  | ||||
| """ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import re | ||||
| import typing as t | ||||
| from abc import ABC, abstractmethod | ||||
| from datetime import date | ||||
| from decimal import Decimal | ||||
|  | ||||
| import sqlalchemy as sa | ||||
| from flask import request | ||||
| from flask_babel import LazyString | ||||
| from flask_wtf import FlaskForm | ||||
| from wtforms import DateField, StringField, FieldList, FormField, \ | ||||
|     IntegerField, TextAreaField, DecimalField, BooleanField | ||||
| from wtforms.validators import DataRequired, ValidationError | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.locale import lazy_gettext | ||||
| from accounting.models import Transaction, Account, JournalEntry, \ | ||||
|     TransactionCurrency, Currency | ||||
| from accounting.utils.random_id import new_id | ||||
| from accounting.utils.strip_text import strip_text, strip_multiline_text | ||||
| from accounting.utils.user import get_current_user_pk | ||||
|  | ||||
| MISSING_CURRENCY: LazyString = lazy_gettext("Please select the currency.") | ||||
| """The error message when the currency code is empty.""" | ||||
| MISSING_ACCOUNT: LazyString = lazy_gettext("Please select the account.") | ||||
| """The error message when the account code is empty.""" | ||||
|  | ||||
|  | ||||
| class NeedSomeCurrencies: | ||||
|     """The validator to check if there is any currency sub-form.""" | ||||
|  | ||||
|     def __call__(self, form: CurrencyForm, field: FieldList) \ | ||||
|             -> None: | ||||
|         if len(field) == 0: | ||||
|             raise ValidationError(lazy_gettext( | ||||
|                 "Please add some currencies.")) | ||||
|  | ||||
|  | ||||
| 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 NeedSomeJournalEntries: | ||||
|     """The validator to check if there is any journal entry sub-form.""" | ||||
|  | ||||
|     def __call__(self, form: TransferCurrencyForm, field: FieldList) \ | ||||
|             -> None: | ||||
|         if len(field) == 0: | ||||
|             raise ValidationError(lazy_gettext( | ||||
|                 "Please add some journal entries.")) | ||||
|  | ||||
|  | ||||
| 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 PositiveAmount: | ||||
|     """The validator to check if the amount is positive.""" | ||||
|  | ||||
|     def __call__(self, form: FlaskForm, field: DecimalField) -> None: | ||||
|         if field.data is None: | ||||
|             return | ||||
|         if field.data <= 0: | ||||
|             raise ValidationError(lazy_gettext( | ||||
|                 "Please fill in a positive amount.")) | ||||
|  | ||||
|  | ||||
| class IsDebitAccount: | ||||
|     """The validator to check if the account is for debit journal entries.""" | ||||
|  | ||||
|     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 entries.")) | ||||
|  | ||||
|  | ||||
| class JournalEntryForm(FlaskForm): | ||||
|     """The base form to create or edit a journal entry.""" | ||||
|     eid = IntegerField() | ||||
|     """The existing journal entry ID.""" | ||||
|     no = IntegerField() | ||||
|     """The order in the currency.""" | ||||
|     account_code = StringField() | ||||
|     """The account code.""" | ||||
|     amount = DecimalField() | ||||
|     """The amount.""" | ||||
|  | ||||
|     @property | ||||
|     def account_text(self) -> str: | ||||
|         """Returns the text representation of the account. | ||||
|  | ||||
|         :return: The text representation of the account. | ||||
|         """ | ||||
|         if self.account_code.data is None: | ||||
|             return "" | ||||
|         account: Account | None = Account.find_by_code(self.account_code.data) | ||||
|         if account is None: | ||||
|             return "" | ||||
|         return 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 DebitEntryForm(JournalEntryForm): | ||||
|     """The form to create or edit a debit journal entry.""" | ||||
|     eid = IntegerField() | ||||
|     """The existing journal entry ID.""" | ||||
|     no = IntegerField() | ||||
|     """The order in the currency.""" | ||||
|     account_code = StringField( | ||||
|         filters=[strip_text], | ||||
|         validators=[DataRequired(MISSING_ACCOUNT), | ||||
|                     AccountExists(), | ||||
|                     IsDebitAccount()]) | ||||
|     """The account code.""" | ||||
|     summary = StringField(filters=[strip_text]) | ||||
|     """The summary.""" | ||||
|     amount = DecimalField(validators=[PositiveAmount()]) | ||||
|     """The amount.""" | ||||
|  | ||||
|     def populate_obj(self, obj: JournalEntry) -> None: | ||||
|         """Populates the form data into a journal entry object. | ||||
|  | ||||
|         :param obj: The journal entry object. | ||||
|         :return: None. | ||||
|         """ | ||||
|         is_new: bool = obj.id is None | ||||
|         if is_new: | ||||
|             obj.id = new_id(JournalEntry) | ||||
|         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||
|         obj.summary = self.summary.data | ||||
|         obj.is_debit = True | ||||
|         obj.amount = self.amount.data | ||||
|         if is_new: | ||||
|             current_user_pk: int = get_current_user_pk() | ||||
|             obj.created_by_id = current_user_pk | ||||
|             obj.updated_by_id = current_user_pk | ||||
|  | ||||
|  | ||||
| class IsCreditAccount: | ||||
|     """The validator to check if the account is for credit journal entries.""" | ||||
|  | ||||
|     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 entries.")) | ||||
|  | ||||
|  | ||||
| class CreditEntryForm(JournalEntryForm): | ||||
|     """The form to create or edit a credit journal entry.""" | ||||
|     eid = IntegerField() | ||||
|     """The existing journal entry ID.""" | ||||
|     no = IntegerField() | ||||
|     """The order in the currency.""" | ||||
|     account_code = StringField( | ||||
|         filters=[strip_text], | ||||
|         validators=[DataRequired(MISSING_ACCOUNT), | ||||
|                     AccountExists(), | ||||
|                     IsCreditAccount()]) | ||||
|     """The account code.""" | ||||
|     summary = StringField(filters=[strip_text]) | ||||
|     """The summary.""" | ||||
|     amount = DecimalField(validators=[PositiveAmount()]) | ||||
|     """The amount.""" | ||||
|  | ||||
|     def populate_obj(self, obj: JournalEntry) -> None: | ||||
|         """Populates the form data into a journal entry object. | ||||
|  | ||||
|         :param obj: The journal entry object. | ||||
|         :return: None. | ||||
|         """ | ||||
|         is_new: bool = obj.id is None | ||||
|         if is_new: | ||||
|             obj.id = new_id(JournalEntry) | ||||
|         obj.account_id = Account.find_by_code(self.account_code.data).id | ||||
|         obj.summary = self.summary.data | ||||
|         obj.is_debit = False | ||||
|         obj.amount = self.amount.data | ||||
|         if is_new: | ||||
|             current_user_pk: int = get_current_user_pk() | ||||
|             obj.created_by_id = current_user_pk | ||||
|             obj.updated_by_id = current_user_pk | ||||
|  | ||||
|  | ||||
| class CurrencyForm(FlaskForm): | ||||
|     """The form to create or edit a currency in a transaction.""" | ||||
|     no = IntegerField() | ||||
|     """The order in the transaction.""" | ||||
|     code = StringField() | ||||
|     """The currency code.""" | ||||
|     whole_form = BooleanField() | ||||
|     """The pseudo field for the whole form validators.""" | ||||
|  | ||||
|  | ||||
| class TransactionForm(FlaskForm): | ||||
|     """The base form to create or edit a transaction.""" | ||||
|     date = DateField() | ||||
|     """The date.""" | ||||
|     currencies = FieldList(FormField(CurrencyForm)) | ||||
|     """The journal entries categorized by their currencies.""" | ||||
|     note = TextAreaField() | ||||
|     """The note.""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         """Constructs a base transaction form. | ||||
|  | ||||
|         :param args: The arguments. | ||||
|         :param kwargs: The keyword arguments. | ||||
|         """ | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.is_modified: bool = False | ||||
|         """Whether the transaction is modified during populate_obj().""" | ||||
|         self.collector: t.Type[JournalEntryCollector] = JournalEntryCollector | ||||
|         """The journal entry collector.  The default is the base abstract | ||||
|         collector only to provide the correct type.  The subclass forms should | ||||
|         provide their own collectors.""" | ||||
|         self.__in_use_account_id: set[int] | None = None | ||||
|         """The ID of the accounts that are in use.""" | ||||
|  | ||||
|     def populate_obj(self, obj: Transaction) -> None: | ||||
|         """Populates the form data into a transaction object. | ||||
|  | ||||
|         :param obj: The transaction object. | ||||
|         :return: None. | ||||
|         """ | ||||
|         is_new: bool = obj.id is None | ||||
|         if is_new: | ||||
|             obj.id = new_id(Transaction) | ||||
|         self.__set_date(obj, self.date.data) | ||||
|         obj.note = self.note.data | ||||
|  | ||||
|         entries: list[JournalEntry] = obj.entries | ||||
|         collector_cls: t.Type[JournalEntryCollector] = self.collector | ||||
|         collector: collector_cls = collector_cls(self, obj.id, entries, | ||||
|                                                  obj.currencies) | ||||
|         collector.collect() | ||||
|  | ||||
|         to_delete: set[int] = {x.id for x in entries | ||||
|                                if x.id not in collector.to_keep} | ||||
|         if len(to_delete) > 0: | ||||
|             JournalEntry.query.filter(JournalEntry.id.in_(to_delete)).delete() | ||||
|             self.is_modified = True | ||||
|  | ||||
|         if is_new or db.session.is_modified(obj): | ||||
|             self.is_modified = True | ||||
|  | ||||
|         if is_new: | ||||
|             current_user_pk: int = get_current_user_pk() | ||||
|             obj.created_by_id = current_user_pk | ||||
|             obj.updated_by_id = current_user_pk | ||||
|  | ||||
|     @staticmethod | ||||
|     def __set_date(obj: Transaction, new_date: date) -> None: | ||||
|         """Sets the transaction date and number. | ||||
|  | ||||
|         :param obj: The transaction object. | ||||
|         :param new_date: The new date. | ||||
|         :return: None. | ||||
|         """ | ||||
|         if obj.date is None or obj.date != new_date: | ||||
|             if obj.date is not None: | ||||
|                 sort_transactions_in(obj.date, obj.id) | ||||
|             sort_transactions_in(new_date, obj.id) | ||||
|             count: int = Transaction.query\ | ||||
|                 .filter(Transaction.date == new_date).count() | ||||
|             obj.date = new_date | ||||
|             obj.no = count + 1 | ||||
|  | ||||
|     @property | ||||
|     def debit_account_options(self) -> list[Account]: | ||||
|         """The selectable debit accounts. | ||||
|  | ||||
|         :return: The selectable debit accounts. | ||||
|         """ | ||||
|         accounts: list[Account] = Account.debit() | ||||
|         in_use: set[int] = self.__get_in_use_account_id() | ||||
|         for account in accounts: | ||||
|             account.is_in_use = account.id in in_use | ||||
|         return accounts | ||||
|  | ||||
|     @property | ||||
|     def credit_account_options(self) -> list[Account]: | ||||
|         """The selectable credit accounts. | ||||
|  | ||||
|         :return: The selectable credit accounts. | ||||
|         """ | ||||
|         accounts: list[Account] = Account.credit() | ||||
|         in_use: set[int] = self.__get_in_use_account_id() | ||||
|         for account in accounts: | ||||
|             account.is_in_use = account.id in in_use | ||||
|         return accounts | ||||
|  | ||||
|     def __get_in_use_account_id(self) -> set[int]: | ||||
|         """Returns the ID of the accounts that are in use. | ||||
|  | ||||
|         :return: The ID of the accounts that are in use. | ||||
|         """ | ||||
|         if self.__in_use_account_id is None: | ||||
|             self.__in_use_account_id = set(db.session.scalars( | ||||
|                 sa.select(JournalEntry.account_id) | ||||
|                 .group_by(JournalEntry.account_id)).all()) | ||||
|         return self.__in_use_account_id | ||||
|  | ||||
|     @property | ||||
|     def currencies_errors(self) -> list[str | LazyString]: | ||||
|         """Returns the currency errors, without the errors in their sub-forms. | ||||
|  | ||||
|         :return: | ||||
|         """ | ||||
|         return [x for x in self.currencies.errors | ||||
|                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||
|  | ||||
|  | ||||
| T = t.TypeVar("T", bound=TransactionForm) | ||||
| """A transaction form variant.""" | ||||
|  | ||||
|  | ||||
| class JournalEntryCollector(t.Generic[T], ABC): | ||||
|     """The journal entry collector.""" | ||||
|  | ||||
|     def __init__(self, form: T, txn_id: int, entries: list[JournalEntry], | ||||
|                  currencies: list[TransactionCurrency]): | ||||
|         """Constructs the journal entry collector. | ||||
|  | ||||
|         :param form: The transaction form. | ||||
|         :param txn_id: The transaction ID. | ||||
|         :param entries: The existing journal entries. | ||||
|         :param currencies: The currencies in the transaction. | ||||
|         """ | ||||
|         self.form: T = form | ||||
|         """The transaction form.""" | ||||
|         self.entries: list[JournalEntry] = entries | ||||
|         """The existing journal entries.""" | ||||
|         self.txn_id: int = txn_id | ||||
|         """The transaction ID.""" | ||||
|         self.__entries_by_id: dict[int, JournalEntry] \ | ||||
|             = {x.id: x for x in entries} | ||||
|         """A dictionary from the entry ID to their entries.""" | ||||
|         self.__no_by_id: dict[int, int] = {x.id: x.no for x in entries} | ||||
|         """A dictionary from the entry number to their entries.""" | ||||
|         self.__currencies: list[TransactionCurrency] = currencies | ||||
|         """The currencies in the transaction.""" | ||||
|         self._debit_no: int = 1 | ||||
|         """The number index for the debit entries.""" | ||||
|         self._credit_no: int = 1 | ||||
|         """The number index for the credit entries.""" | ||||
|         self.to_keep: set[int] = set() | ||||
|         """The ID of the existing journal entries to keep.""" | ||||
|  | ||||
|     @abstractmethod | ||||
|     def collect(self) -> set[int]: | ||||
|         """Collects the journal entries. | ||||
|  | ||||
|         :return: The ID of the journal entries to keep. | ||||
|         """ | ||||
|  | ||||
|     def _add_entry(self, form: JournalEntryForm, currency_code: str, no: int) \ | ||||
|             -> None: | ||||
|         """Composes a journal entry from the form. | ||||
|  | ||||
|         :param form: The journal entry form. | ||||
|         :param currency_code: The code of the currency. | ||||
|         :param no: The number of the entry. | ||||
|         :return: None. | ||||
|         """ | ||||
|         entry: JournalEntry | None = self.__entries_by_id.get(form.eid.data) | ||||
|         if entry is not None: | ||||
|             self.to_keep.add(entry.id) | ||||
|             entry.currency_code = currency_code | ||||
|             form.populate_obj(entry) | ||||
|             entry.no = no | ||||
|             if db.session.is_modified(entry): | ||||
|                 self.form.is_modified = True | ||||
|         else: | ||||
|             entry = JournalEntry() | ||||
|             entry.transaction_id = self.txn_id | ||||
|             entry.currency_code = currency_code | ||||
|             form.populate_obj(entry) | ||||
|             entry.no = no | ||||
|             db.session.add(entry) | ||||
|             self.form.is_modified = True | ||||
|  | ||||
|     def _make_cash_entry(self, forms: list[JournalEntryForm], is_debit: bool, | ||||
|                          currency_code: str, no: int) -> None: | ||||
|         """Composes the cash journal entry at the other side of the cash | ||||
|         transaction. | ||||
|  | ||||
|         :param forms: The journal entry forms in the same currency. | ||||
|         :param is_debit: True for a cash income transaction, or False for a | ||||
|             cash expense transaction. | ||||
|         :param currency_code: The code of the currency. | ||||
|         :param no: The number of the entry. | ||||
|         :return: None. | ||||
|         """ | ||||
|         candidates: list[JournalEntry] = [x for x in self.entries | ||||
|                                           if x.is_debit == is_debit | ||||
|                                           and x.currency_code == currency_code] | ||||
|         entry: JournalEntry | ||||
|         if len(candidates) > 0: | ||||
|             candidates.sort(key=lambda x: x.no) | ||||
|             entry = candidates[0] | ||||
|             self.to_keep.add(entry.id) | ||||
|             entry.account_id = Account.cash().id | ||||
|             entry.summary = None | ||||
|             entry.amount = sum([x.amount.data for x in forms]) | ||||
|             entry.no = no | ||||
|             if db.session.is_modified(entry): | ||||
|                 self.form.is_modified = True | ||||
|         else: | ||||
|             entry = JournalEntry() | ||||
|             entry.id = new_id(JournalEntry) | ||||
|             entry.transaction_id = self.txn_id | ||||
|             entry.is_debit = is_debit | ||||
|             entry.currency_code = currency_code | ||||
|             entry.account_id = Account.cash().id | ||||
|             entry.summary = None | ||||
|             entry.amount = sum([x.amount.data for x in forms]) | ||||
|             entry.no = no | ||||
|             db.session.add(entry) | ||||
|             self.form.is_modified = True | ||||
|  | ||||
|     def _sort_entry_forms(self, forms: list[JournalEntryForm]) -> None: | ||||
|         """Sorts the journal entry forms. | ||||
|  | ||||
|         :param forms: The journal entry forms. | ||||
|         :return: None. | ||||
|         """ | ||||
|         missing_no: int = 100 if len(self.__no_by_id) == 0 \ | ||||
|             else max(self.__no_by_id.values()) + 100 | ||||
|         ord_by_form: dict[JournalEntryForm, 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, | ||||
|                                   missing_no if x.eid.data is None else | ||||
|                                   self.__no_by_id.get(x.eid.data, missing_no), | ||||
|                                   ord_by_form.get(x))) | ||||
|  | ||||
|     def _sort_currency_forms(self, forms: list[CurrencyForm]) -> None: | ||||
|         """Sorts the currency forms. | ||||
|  | ||||
|         :param forms: The currency forms. | ||||
|         :return: None. | ||||
|         """ | ||||
|         missing_no: int = len(self.__currencies) + 100 | ||||
|         no_by_code: dict[str, int] = {self.__currencies[i].code: i | ||||
|                                       for i in range(len(self.__currencies))} | ||||
|         ord_by_form: dict[CurrencyForm, 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, | ||||
|                                   no_by_code.get(x.code.data, missing_no), | ||||
|                                   ord_by_form.get(x))) | ||||
|  | ||||
|  | ||||
| class IncomeCurrencyForm(CurrencyForm): | ||||
|     """The form to create or edit a currency in a cash income transaction.""" | ||||
|     no = IntegerField() | ||||
|     """The order in the transaction.""" | ||||
|     code = StringField( | ||||
|         filters=[strip_text], | ||||
|         validators=[DataRequired(MISSING_CURRENCY), | ||||
|                     CurrencyExists()]) | ||||
|     """The currency code.""" | ||||
|     credit = FieldList(FormField(CreditEntryForm), | ||||
|                        validators=[NeedSomeJournalEntries()]) | ||||
|     """The credit entries.""" | ||||
|     whole_form = BooleanField() | ||||
|     """The pseudo field for the whole form validators.""" | ||||
|  | ||||
|     @property | ||||
|     def credit_total(self) -> Decimal: | ||||
|         """Returns the total amount of the credit journal entries. | ||||
|  | ||||
|         :return: The total amount of the credit journal entries. | ||||
|         """ | ||||
|         return sum([x.amount.data for x in self.credit | ||||
|                     if x.amount.data is not None]) | ||||
|  | ||||
|     @property | ||||
|     def credit_errors(self) -> list[str | LazyString]: | ||||
|         """Returns the credit journal entry errors, without the errors in their | ||||
|         sub-forms. | ||||
|  | ||||
|         :return: | ||||
|         """ | ||||
|         return [x for x in self.credit.errors | ||||
|                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||
|  | ||||
|  | ||||
| class IncomeTransactionForm(TransactionForm): | ||||
|     """The form to create or edit a cash income transaction.""" | ||||
|     date = DateField(default=date.today()) | ||||
|     """The date.""" | ||||
|     currencies = FieldList(FormField(IncomeCurrencyForm), name="currency", | ||||
|                            validators=[NeedSomeCurrencies()]) | ||||
|     """The journal entries categorized by their currencies.""" | ||||
|     note = TextAreaField(filters=[strip_multiline_text]) | ||||
|     """The note.""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|         class Collector(JournalEntryCollector[IncomeTransactionForm]): | ||||
|             """The journal entry collector for the cash income transactions.""" | ||||
|  | ||||
|             def collect(self) -> None: | ||||
|                 currencies: list[IncomeCurrencyForm] \ | ||||
|                     = [x.form for x in self.form.currencies] | ||||
|                 self._sort_currency_forms(currencies) | ||||
|                 for currency in currencies: | ||||
|                     # The debit cash entry | ||||
|                     self._make_cash_entry(list(currency.credit), True, | ||||
|                                           currency.code.data, self._debit_no) | ||||
|                     self._debit_no = self._debit_no + 1 | ||||
|  | ||||
|                     # The credit forms | ||||
|                     credit_forms: list[CreditEntryForm] \ | ||||
|                         = [x.form for x in currency.credit] | ||||
|                     self._sort_entry_forms(credit_forms) | ||||
|                     for credit_form in credit_forms: | ||||
|                         self._add_entry(credit_form, currency.code.data, | ||||
|                                         self._credit_no) | ||||
|                         self._credit_no = self._credit_no + 1 | ||||
|  | ||||
|         self.collector = Collector | ||||
|  | ||||
|  | ||||
| class ExpenseCurrencyForm(CurrencyForm): | ||||
|     """The form to create or edit a currency in a cash expense transaction.""" | ||||
|     no = IntegerField() | ||||
|     """The order in the transaction.""" | ||||
|     code = StringField( | ||||
|         filters=[strip_text], | ||||
|         validators=[DataRequired(MISSING_CURRENCY), | ||||
|                     CurrencyExists()]) | ||||
|     """The currency code.""" | ||||
|     debit = FieldList(FormField(DebitEntryForm), | ||||
|                       validators=[NeedSomeJournalEntries()]) | ||||
|     """The debit entries.""" | ||||
|     whole_form = BooleanField() | ||||
|     """The pseudo field for the whole form validators.""" | ||||
|  | ||||
|     @property | ||||
|     def debit_total(self) -> Decimal: | ||||
|         """Returns the total amount of the debit journal entries. | ||||
|  | ||||
|         :return: The total amount of the debit journal entries. | ||||
|         """ | ||||
|         return sum([x.amount.data for x in self.debit | ||||
|                     if x.amount.data is not None]) | ||||
|  | ||||
|     @property | ||||
|     def debit_errors(self) -> list[str | LazyString]: | ||||
|         """Returns the debit journal entry errors, without the errors in their | ||||
|         sub-forms. | ||||
|  | ||||
|         :return: | ||||
|         """ | ||||
|         return [x for x in self.debit.errors | ||||
|                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||
|  | ||||
|  | ||||
| class ExpenseTransactionForm(TransactionForm): | ||||
|     """The form to create or edit a cash expense transaction.""" | ||||
|     date = DateField(default=date.today()) | ||||
|     """The date.""" | ||||
|     currencies = FieldList(FormField(ExpenseCurrencyForm), name="currency", | ||||
|                            validators=[NeedSomeCurrencies()]) | ||||
|     """The journal entries categorized by their currencies.""" | ||||
|     note = TextAreaField(filters=[strip_multiline_text]) | ||||
|     """The note.""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|         class Collector(JournalEntryCollector[ExpenseTransactionForm]): | ||||
|             """The journal entry collector for the cash expense | ||||
|             transactions.""" | ||||
|  | ||||
|             def collect(self) -> None: | ||||
|                 currencies: list[ExpenseCurrencyForm] \ | ||||
|                     = [x.form for x in self.form.currencies] | ||||
|                 self._sort_currency_forms(currencies) | ||||
|                 for currency in currencies: | ||||
|                     # The debit forms | ||||
|                     debit_forms: list[DebitEntryForm] \ | ||||
|                         = [x.form for x in currency.debit] | ||||
|                     self._sort_entry_forms(debit_forms) | ||||
|                     for debit_form in debit_forms: | ||||
|                         self._add_entry(debit_form, currency.code.data, | ||||
|                                         self._debit_no) | ||||
|                         self._debit_no = self._debit_no + 1 | ||||
|  | ||||
|                     # The credit forms | ||||
|                     self._make_cash_entry(list(currency.debit), False, | ||||
|                                           currency.code.data, self._credit_no) | ||||
|                     self._credit_no = self._credit_no + 1 | ||||
|  | ||||
|         self.collector = Collector | ||||
|  | ||||
|  | ||||
| class TransferCurrencyForm(CurrencyForm): | ||||
|     """The form to create or edit a currency in a transfer transaction.""" | ||||
|  | ||||
|     class IsBalanced: | ||||
|         """The validator to check that the total amount of the debit and credit | ||||
|         entries are equal.""" | ||||
|         def __call__(self, form: TransferCurrencyForm, field: BooleanField)\ | ||||
|                 -> None: | ||||
|             if len(form.debit) == 0 or len(form.credit) == 0: | ||||
|                 return | ||||
|             if form.debit_total != form.credit_total: | ||||
|                 raise ValidationError(lazy_gettext( | ||||
|                     "The totals of the debit and credit amounts do not" | ||||
|                     " match.")) | ||||
|  | ||||
|     no = IntegerField() | ||||
|     """The order in the transaction.""" | ||||
|     code = StringField( | ||||
|         filters=[strip_text], | ||||
|         validators=[DataRequired(MISSING_CURRENCY), | ||||
|                     CurrencyExists()]) | ||||
|     """The currency code.""" | ||||
|     debit = FieldList(FormField(DebitEntryForm), | ||||
|                       validators=[NeedSomeJournalEntries()]) | ||||
|     """The debit entries.""" | ||||
|     credit = FieldList(FormField(CreditEntryForm), | ||||
|                        validators=[NeedSomeJournalEntries()]) | ||||
|     """The credit entries.""" | ||||
|     whole_form = BooleanField(validators=[IsBalanced()]) | ||||
|     """The pseudo field for the whole form validators.""" | ||||
|  | ||||
|     @property | ||||
|     def debit_total(self) -> Decimal: | ||||
|         """Returns the total amount of the debit journal entries. | ||||
|  | ||||
|         :return: The total amount of the debit journal entries. | ||||
|         """ | ||||
|         return sum([x.amount.data for x in self.debit | ||||
|                     if x.amount.data is not None]) | ||||
|  | ||||
|     @property | ||||
|     def credit_total(self) -> Decimal: | ||||
|         """Returns the total amount of the credit journal entries. | ||||
|  | ||||
|         :return: The total amount of the credit journal entries. | ||||
|         """ | ||||
|         return sum([x.amount.data for x in self.credit | ||||
|                     if x.amount.data is not None]) | ||||
|  | ||||
|     @property | ||||
|     def debit_errors(self) -> list[str | LazyString]: | ||||
|         """Returns the debit journal entry errors, without the errors in their | ||||
|         sub-forms. | ||||
|  | ||||
|         :return: | ||||
|         """ | ||||
|         return [x for x in self.debit.errors | ||||
|                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||
|  | ||||
|     @property | ||||
|     def credit_errors(self) -> list[str | LazyString]: | ||||
|         """Returns the credit journal entry errors, without the errors in their | ||||
|         sub-forms. | ||||
|  | ||||
|         :return: | ||||
|         """ | ||||
|         return [x for x in self.credit.errors | ||||
|                 if isinstance(x, str) or isinstance(x, LazyString)] | ||||
|  | ||||
|  | ||||
| class TransferTransactionForm(TransactionForm): | ||||
|     """The form to create or edit a transfer transaction.""" | ||||
|     date = DateField(default=date.today()) | ||||
|     """The date.""" | ||||
|     currencies = FieldList(FormField(TransferCurrencyForm), name="currency", | ||||
|                            validators=[NeedSomeCurrencies()]) | ||||
|     """The journal entries categorized by their currencies.""" | ||||
|     note = TextAreaField(filters=[strip_multiline_text]) | ||||
|     """The note.""" | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|         class Collector(JournalEntryCollector[TransferTransactionForm]): | ||||
|             """The journal entry collector for the transfer transactions.""" | ||||
|  | ||||
|             def collect(self) -> None: | ||||
|                 currencies: list[TransferCurrencyForm] \ | ||||
|                     = [x.form for x in self.form.currencies] | ||||
|                 self._sort_currency_forms(currencies) | ||||
|                 for currency in currencies: | ||||
|                     # The debit forms | ||||
|                     debit_forms: list[DebitEntryForm] \ | ||||
|                         = [x.form for x in currency.debit] | ||||
|                     self._sort_entry_forms(debit_forms) | ||||
|                     for debit_form in debit_forms: | ||||
|                         self._add_entry(debit_form, currency.code.data, | ||||
|                                         self._debit_no) | ||||
|                         self._debit_no = self._debit_no + 1 | ||||
|  | ||||
|                     # The credit forms | ||||
|                     credit_forms: list[CreditEntryForm] \ | ||||
|                         = [x.form for x in currency.credit] | ||||
|                     self._sort_entry_forms(credit_forms) | ||||
|                     for credit_form in credit_forms: | ||||
|                         self._add_entry(credit_form, currency.code.data, | ||||
|                                         self._credit_no) | ||||
|                         self._credit_no = self._credit_no + 1 | ||||
|  | ||||
|         self.collector = Collector | ||||
|  | ||||
|  | ||||
| def sort_transactions_in(txn_date: date, exclude: int) -> None: | ||||
|     """Sorts the transactions under a date after changing the date or deleting | ||||
|     a transaction. | ||||
|  | ||||
|     :param txn_date: The date of the transaction. | ||||
|     :param exclude: The transaction ID to exclude. | ||||
|     :return: None. | ||||
|     """ | ||||
|     transactions: list[Transaction] = Transaction.query\ | ||||
|         .filter(Transaction.date == txn_date, | ||||
|                 Transaction.id != exclude)\ | ||||
|         .order_by(Transaction.no).all() | ||||
|     for i in range(len(transactions)): | ||||
|         if transactions[i].no != i + 1: | ||||
|             transactions[i].no = i + 1 | ||||
|  | ||||
|  | ||||
| class TransactionReorderForm: | ||||
|     """The form to reorder the transactions.""" | ||||
|  | ||||
|     def __init__(self, txn_date: date): | ||||
|         """Constructs the form to reorder the transactions in a day. | ||||
|  | ||||
|         :param txn_date: The date. | ||||
|         """ | ||||
|         self.date: date = txn_date | ||||
|         self.is_modified: bool = False | ||||
|  | ||||
|     def save_order(self) -> None: | ||||
|         """Saves the order of the account. | ||||
|  | ||||
|         :return: | ||||
|         """ | ||||
|         transactions: list[Transaction] = Transaction.query\ | ||||
|             .filter(Transaction.date == self.date).all() | ||||
|  | ||||
|         # Collects the specified order. | ||||
|         orders: dict[Transaction, int] = {} | ||||
|         for txn in transactions: | ||||
|             if f"{txn.id}-no" in request.form: | ||||
|                 try: | ||||
|                     orders[txn] = int(request.form[f"{txn.id}-no"]) | ||||
|                 except ValueError: | ||||
|                     pass | ||||
|  | ||||
|         # Missing and invalid orders are appended to the end. | ||||
|         missing: list[Transaction] \ | ||||
|             = [x for x in transactions if x not in orders] | ||||
|         if len(missing) > 0: | ||||
|             next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1 | ||||
|             for txn in missing: | ||||
|                 orders[txn] = next_no | ||||
|  | ||||
|         # Sort by the specified order first, and their original order. | ||||
|         transactions.sort(key=lambda x: (orders[x], x.no)) | ||||
|  | ||||
|         # Update the orders. | ||||
|         with db.session.no_autoflush: | ||||
|             for i in range(len(transactions)): | ||||
|                 if transactions[i].no != i + 1: | ||||
|                     transactions[i].no = i + 1 | ||||
|                     self.is_modified = True | ||||
							
								
								
									
										65
									
								
								src/accounting/transaction/query.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/accounting/transaction/query.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| # The Mia! Accounting Flask Project. | ||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||
|  | ||||
| #  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 transaction query. | ||||
|  | ||||
| """ | ||||
| from datetime import datetime | ||||
|  | ||||
| import sqlalchemy as sa | ||||
| from flask import request | ||||
|  | ||||
| from accounting.models import Transaction | ||||
| from accounting.utils.query import parse_query_keywords | ||||
|  | ||||
|  | ||||
| def get_transaction_query() -> list[Transaction]: | ||||
|     """Returns the transactions, optionally filtered by the query. | ||||
|  | ||||
|     :return: The transactions. | ||||
|     """ | ||||
|     keywords: list[str] = parse_query_keywords(request.args.get("q")) | ||||
|     if len(keywords) == 0: | ||||
|         return Transaction.query\ | ||||
|             .order_by(Transaction.date, Transaction.no).all() | ||||
|     conditions: list[sa.BinaryExpression] = [] | ||||
|     for k in keywords: | ||||
|         sub_conditions: list[sa.BinaryExpression] \ | ||||
|             = [Transaction.note.contains(k)] | ||||
|         date: datetime | ||||
|         try: | ||||
|             date = datetime.strptime(k, "%Y") | ||||
|             sub_conditions.append( | ||||
|                 sa.extract("year", Transaction.date) == date.year) | ||||
|         except ValueError: | ||||
|             pass | ||||
|         try: | ||||
|             date = datetime.strptime(k, "%Y/%m") | ||||
|             sub_conditions.append(sa.and_( | ||||
|                 sa.extract("year", Transaction.date) == date.year, | ||||
|                 sa.extract("month", Transaction.date) == date.month)) | ||||
|         except ValueError: | ||||
|             pass | ||||
|         try: | ||||
|             date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") | ||||
|             sub_conditions.append(sa.and_( | ||||
|                 sa.extract("month", Transaction.date) == date.month, | ||||
|                 sa.extract("day", Transaction.date) == date.day)) | ||||
|         except ValueError: | ||||
|             pass | ||||
|         conditions.append(sa.or_(*sub_conditions)) | ||||
|     return Transaction.query.filter(*conditions)\ | ||||
|         .order_by(Transaction.date, Transaction.no).all() | ||||
							
								
								
									
										119
									
								
								src/accounting/transaction/template.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								src/accounting/transaction/template.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | ||||
| # The Mia! Accounting Flask Project. | ||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25 | ||||
|  | ||||
| #  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 template filters and globals for the transaction management. | ||||
|  | ||||
| """ | ||||
| from datetime import date, timedelta | ||||
| from decimal import Decimal | ||||
| from html import escape | ||||
| from urllib.parse import ParseResult, urlparse, parse_qsl, urlencode, \ | ||||
|     urlunparse | ||||
|  | ||||
| from flask import request, current_app | ||||
| from flask_babel import get_locale | ||||
|  | ||||
| from accounting.locale import gettext | ||||
| from accounting.models import Currency | ||||
|  | ||||
|  | ||||
| def with_type(uri: str) -> str: | ||||
|     """Adds the transaction type to the URI, if it is specified. | ||||
|  | ||||
|     :param uri: The URI. | ||||
|     :return: The result URL, optionally with the transaction type added. | ||||
|     """ | ||||
|     if "as" not in request.args: | ||||
|         return uri | ||||
|     uri_p: ParseResult = urlparse(uri) | ||||
|     params: list[tuple[str, str]] = parse_qsl(uri_p.query) | ||||
|     params = [x for x in params if x[0] != "next"] | ||||
|     params.append(("as", request.args["as"])) | ||||
|     parts: list[str] = list(uri_p) | ||||
|     parts[4] = urlencode(params) | ||||
|     return urlunparse(parts) | ||||
|  | ||||
|  | ||||
| def format_amount(value: Decimal | None) -> str: | ||||
|     """Formats an amount for readability. | ||||
|  | ||||
|     :param value: The amount. | ||||
|     :return: The formatted amount text. | ||||
|     """ | ||||
|     if value is None or value == 0: | ||||
|         return "-" | ||||
|     whole: int = int(value) | ||||
|     frac: Decimal = (value - whole).normalize() | ||||
|     return "{:,}".format(whole) + str(frac)[1:] | ||||
|  | ||||
|  | ||||
| def format_date(value: date) -> str: | ||||
|     """Formats a date to be human-friendly. | ||||
|  | ||||
|     :param value: The date. | ||||
|     :return: The human-friendly date text. | ||||
|     """ | ||||
|     today: date = date.today() | ||||
|     if value == today: | ||||
|         return gettext("Today") | ||||
|     if value == today - timedelta(days=1): | ||||
|         return gettext("Yesterday") | ||||
|     if value == today + timedelta(days=1): | ||||
|         return gettext("Tomorrow") | ||||
|     locale = str(get_locale()) | ||||
|     if locale == "zh" or locale.startswith("zh_"): | ||||
|         if value == today - timedelta(days=2): | ||||
|             return gettext("The day before yesterday") | ||||
|         if value == today + timedelta(days=2): | ||||
|             return gettext("The day after tomorrow") | ||||
|     if locale == "zh" or locale.startswith("zh_"): | ||||
|         weekdays = ["一", "二", "三", "四", "五", "六", "日"] | ||||
|         weekday = weekdays[value.weekday()] | ||||
|     else: | ||||
|         weekday = value.strftime("%a") | ||||
|     if value.year != today.year: | ||||
|         return "{}/{}/{}({})".format( | ||||
|             value.year, value.month, value.day, weekday) | ||||
|     return "{}/{}({})".format(value.month, value.day, weekday) | ||||
|  | ||||
|  | ||||
| def text2html(value: str) -> str: | ||||
|     """Converts plain text into HTML. | ||||
|  | ||||
|     :param value: The plain text. | ||||
|     :return: The HTML. | ||||
|     """ | ||||
|     s: str = escape(value) | ||||
|     s = s.replace("\n", "<br>") | ||||
|     s = s.replace("  ", "  ") | ||||
|     return s | ||||
|  | ||||
|  | ||||
| def currency_options() -> str: | ||||
|     """Returns the currency options. | ||||
|  | ||||
|     :return: The currency options. | ||||
|     """ | ||||
|     return Currency.query.order_by(Currency.code).all() | ||||
|  | ||||
|  | ||||
| def default_currency_code() -> str: | ||||
|     """Returns the default currency code. | ||||
|  | ||||
|     :return: The default currency code. | ||||
|     """ | ||||
|     with current_app.app_context(): | ||||
|         return current_app.config.get("DEFAULT_CURRENCY", "USD") | ||||
							
								
								
									
										223
									
								
								src/accounting/transaction/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								src/accounting/transaction/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | ||||
| # The Mia! Accounting Flask Project. | ||||
| # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 | ||||
|  | ||||
| #  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 transaction management. | ||||
|  | ||||
| """ | ||||
| from datetime import date | ||||
| from urllib.parse import parse_qsl, urlencode | ||||
|  | ||||
| import sqlalchemy as sa | ||||
| from flask import Blueprint, render_template, session, redirect, request, \ | ||||
|     flash, url_for | ||||
| from werkzeug.datastructures import ImmutableMultiDict | ||||
|  | ||||
| from accounting import db | ||||
| from accounting.locale import lazy_gettext | ||||
| from accounting.models import Transaction | ||||
| from accounting.utils.flash_errors import flash_form_errors | ||||
| from accounting.utils.next_uri import inherit_next, or_next | ||||
| from accounting.utils.pagination import Pagination | ||||
| from accounting.utils.permission import has_permission, can_view, can_edit | ||||
| from accounting.utils.user import get_current_user_pk | ||||
| from .dispatcher import TransactionType, get_txn_type, TXN_TYPE_OBJ | ||||
| from .template import with_type, format_amount, format_date, text2html, \ | ||||
|     currency_options, default_currency_code | ||||
| from .forms import sort_transactions_in, TransactionReorderForm | ||||
| from .query import get_transaction_query | ||||
|  | ||||
| bp: Blueprint = Blueprint("transaction", __name__) | ||||
| """The view blueprint for the transaction management.""" | ||||
| bp.add_app_template_filter(with_type, "accounting_txn_with_type") | ||||
| bp.add_app_template_filter(format_amount, "accounting_txn_format_amount") | ||||
| bp.add_app_template_filter(format_date, "accounting_txn_format_date") | ||||
| bp.add_app_template_filter(text2html, "accounting_txn_text2html") | ||||
| bp.add_app_template_global(currency_options, "accounting_txn_currency_options") | ||||
| bp.add_app_template_global(default_currency_code, | ||||
|                            "accounting_txn_default_currency_code") | ||||
|  | ||||
|  | ||||
| @bp.get("", endpoint="list") | ||||
| @has_permission(can_view) | ||||
| def list_transactions() -> str: | ||||
|     """Lists the transactions. | ||||
|  | ||||
|     :return: The transaction list. | ||||
|     """ | ||||
|     transactions: list[Transaction] = get_transaction_query() | ||||
|     pagination: Pagination = Pagination[Transaction](transactions) | ||||
|     return render_template("accounting/transaction/list.html", | ||||
|                            list=pagination.list, pagination=pagination, | ||||
|                            types=TXN_TYPE_OBJ) | ||||
|  | ||||
|  | ||||
| @bp.get("/create/<transactionType:txn_type>", endpoint="create") | ||||
| @has_permission(can_edit) | ||||
| def show_add_transaction_form(txn_type: TransactionType) -> str: | ||||
|     """Shows the form to add a transaction. | ||||
|  | ||||
|     :param txn_type: The transaction type. | ||||
|     :return: The form to add a transaction. | ||||
|     """ | ||||
|     form: txn_type.form | ||||
|     if "form" in session: | ||||
|         form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||
|         del session["form"] | ||||
|         form.validate() | ||||
|     else: | ||||
|         form = txn_type.form() | ||||
|     return txn_type.render_create_template(form) | ||||
|  | ||||
|  | ||||
| @bp.post("/store/<transactionType:txn_type>", endpoint="store") | ||||
| @has_permission(can_edit) | ||||
| def add_transaction(txn_type: TransactionType) -> redirect: | ||||
|     """Adds a transaction. | ||||
|  | ||||
|     :param txn_type: The transaction type. | ||||
|     :return: The redirection to the transaction detail on success, or the | ||||
|         transaction creation form on error. | ||||
|     """ | ||||
|     form: txn_type.form = txn_type.form(request.form) | ||||
|     if not form.validate(): | ||||
|         flash_form_errors(form) | ||||
|         session["form"] = urlencode(list(request.form.items())) | ||||
|         return redirect(inherit_next(with_type( | ||||
|             url_for("accounting.transaction.create", txn_type=txn_type)))) | ||||
|     txn: Transaction = Transaction() | ||||
|     form.populate_obj(txn) | ||||
|     db.session.add(txn) | ||||
|     db.session.commit() | ||||
|     flash(lazy_gettext("The transaction is added successfully"), "success") | ||||
|     return redirect(inherit_next(__get_detail_uri(txn))) | ||||
|  | ||||
|  | ||||
| @bp.get("/<transaction:txn>", endpoint="detail") | ||||
| @has_permission(can_view) | ||||
| def show_transaction_detail(txn: Transaction) -> str: | ||||
|     """Shows the transaction detail. | ||||
|  | ||||
|     :param txn: The transaction. | ||||
|     :return: The detail. | ||||
|     """ | ||||
|     txn_type: TransactionType = get_txn_type(txn) | ||||
|     return txn_type.render_detail_template(txn) | ||||
|  | ||||
|  | ||||
| @bp.get("/<transaction:txn>/edit", endpoint="edit") | ||||
| @has_permission(can_edit) | ||||
| def show_transaction_edit_form(txn: Transaction) -> str: | ||||
|     """Shows the form to edit a transaction. | ||||
|  | ||||
|     :param txn: The transaction. | ||||
|     :return: The form to edit the transaction. | ||||
|     """ | ||||
|     txn_type: TransactionType = get_txn_type(txn) | ||||
|     form: txn_type.form | ||||
|     if "form" in session: | ||||
|         form = txn_type.form(ImmutableMultiDict(parse_qsl(session["form"]))) | ||||
|         del session["form"] | ||||
|         form.validate() | ||||
|     else: | ||||
|         form = txn_type.form(obj=txn) | ||||
|     return txn_type.render_edit_template(txn, form) | ||||
|  | ||||
|  | ||||
| @bp.post("/<transaction:txn>/update", endpoint="update") | ||||
| @has_permission(can_edit) | ||||
| def update_transaction(txn: Transaction) -> redirect: | ||||
|     """Updates a transaction. | ||||
|  | ||||
|     :param txn: The transaction. | ||||
|     :return: The redirection to the transaction detail on success, or the | ||||
|         transaction edit form on error. | ||||
|     """ | ||||
|     txn_type: TransactionType = get_txn_type(txn) | ||||
|     form: txn_type.form = txn_type.form(request.form) | ||||
|     if not form.validate(): | ||||
|         flash_form_errors(form) | ||||
|         session["form"] = urlencode(list(request.form.items())) | ||||
|         return redirect(inherit_next(with_type( | ||||
|             url_for("accounting.transaction.edit", txn=txn)))) | ||||
|     with db.session.no_autoflush: | ||||
|         form.populate_obj(txn) | ||||
|     if not form.is_modified: | ||||
|         flash(lazy_gettext("The transaction was not modified."), "success") | ||||
|         return redirect(inherit_next(with_type(__get_detail_uri(txn)))) | ||||
|     txn.updated_by_id = get_current_user_pk() | ||||
|     txn.updated_at = sa.func.now() | ||||
|     db.session.commit() | ||||
|     flash(lazy_gettext("The transaction is updated successfully."), "success") | ||||
|     return redirect(inherit_next(with_type(__get_detail_uri(txn)))) | ||||
|  | ||||
|  | ||||
| @bp.post("/<transaction:txn>/delete", endpoint="delete") | ||||
| @has_permission(can_edit) | ||||
| def delete_transaction(txn: Transaction) -> redirect: | ||||
|     """Deletes a transaction. | ||||
|  | ||||
|     :param txn: The transaction. | ||||
|     :return: The redirection to the transaction list on success, or the | ||||
|         transaction detail on error. | ||||
|     """ | ||||
|     txn.delete() | ||||
|     sort_transactions_in(txn.date, txn.id) | ||||
|     db.session.commit() | ||||
|     flash(lazy_gettext("The transaction is deleted successfully."), "success") | ||||
|     return redirect(or_next(with_type(url_for("accounting.transaction.list")))) | ||||
|  | ||||
|  | ||||
| @bp.get("/dates/<date:txn_date>", endpoint="order") | ||||
| @has_permission(can_view) | ||||
| def show_transaction_order(txn_date: date) -> str: | ||||
|     """Shows the order of the transactions in a same date. | ||||
|  | ||||
|     :param txn_date: The date. | ||||
|     :return: The order of the transactions in the date. | ||||
|     """ | ||||
|     transactions: list[Transaction] = Transaction.query\ | ||||
|         .filter(Transaction.date == txn_date)\ | ||||
|         .order_by(Transaction.no).all() | ||||
|     return render_template("accounting/transaction/order.html", | ||||
|                            date=txn_date, list=transactions) | ||||
|  | ||||
|  | ||||
| @bp.post("/dates/<date:txn_date>", endpoint="sort") | ||||
| @has_permission(can_edit) | ||||
| def sort_accounts(txn_date: date) -> redirect: | ||||
|     """Reorders the transactions in a date. | ||||
|  | ||||
|     :param txn_date: The date. | ||||
|     :return: The redirection to the incoming account or the account list.  The | ||||
|         reordering operation does not fail. | ||||
|     """ | ||||
|     form: TransactionReorderForm = TransactionReorderForm(txn_date) | ||||
|     form.save_order() | ||||
|     if not form.is_modified: | ||||
|         flash(lazy_gettext("The order was not modified."), "success") | ||||
|         return redirect(or_next(url_for("accounting.account.list"))) | ||||
|     db.session.commit() | ||||
|     flash(lazy_gettext("The order is updated successfully."), "success") | ||||
|     return redirect(or_next(url_for("accounting.account.list"))) | ||||
|  | ||||
|  | ||||
| def __get_detail_uri(txn: Transaction) -> str: | ||||
|     """Returns the detail URI of a transaction. | ||||
|  | ||||
|     :param txn: The transaction. | ||||
|     :return: The detail URI of the transaction. | ||||
|     """ | ||||
|     return url_for("accounting.transaction.detail", txn=txn) | ||||
		Reference in New Issue
	
	Block a user