Compare commits

..

No commits in common. "74b81d3e2344b6c81721e57102371ccb57c40aee" and "3dda6531b579d6a63c118d0deafbdee3743b6e0c" have entirely different histories.

16 changed files with 109 additions and 159 deletions

View File

@ -597,14 +597,14 @@ class JournalEntry(db.Model):
"""True for a debit entry, or False for a credit entry.""" """True for a debit entry, or False for a credit entry."""
no = db.Column(db.Integer, nullable=False) no = db.Column(db.Integer, nullable=False)
"""The entry number under the transaction and debit or credit.""" """The entry number under the transaction and debit or credit."""
original_entry_id = db.Column(db.Integer, offset_original_id = db.Column(db.Integer,
db.ForeignKey(id, onupdate="CASCADE"), db.ForeignKey(id, onupdate="CASCADE"),
nullable=True) nullable=True)
"""The ID of the original entry.""" """The ID of the original entry to offset."""
original_entry = db.relationship("JournalEntry", back_populates="offsets", offset_original = db.relationship("JournalEntry", back_populates="offsets",
remote_side=id, passive_deletes=True) remote_side=id, passive_deletes=True)
"""The original entry.""" """The original entry to offset."""
offsets = db.relationship("JournalEntry", back_populates="original_entry") offsets = db.relationship("JournalEntry", back_populates="offset_original")
"""The offset entries.""" """The offset entries."""
currency_code = db.Column(db.String, currency_code = db.Column(db.String,
db.ForeignKey(Currency.code, onupdate="CASCADE"), db.ForeignKey(Currency.code, onupdate="CASCADE"),

View File

@ -68,10 +68,7 @@ class EntryCollector:
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntry.query.join(Transaction).filter(*conditions)\ return JournalEntry.query.filter(*conditions)\
.order_by(Transaction.date,
JournalEntry.is_debit,
JournalEntry.no)\
.options(selectinload(JournalEntry.account), .options(selectinload(JournalEntry.account),
selectinload(JournalEntry.currency), selectinload(JournalEntry.currency),
selectinload(JournalEntry.transaction)).all() selectinload(JournalEntry.transaction)).all()

View File

@ -27,15 +27,10 @@ class OptionLink:
"""Constructs an option link. """Constructs an option link.
:param title: The title. :param title: The title.
:param url: The URL. :param url: The URI.
:param is_active: True if active, or False otherwise :param is_active: True if active, or False otherwise
:param fa_icon: The font-awesome icon, if any.
""" """
self.title: str = title self.title: str = title
"""The title."""
self.url: str = url self.url: str = url
"""The URL."""
self.is_active: bool = is_active self.is_active: bool = is_active
"""True if active, or False otherwise."""
self.fa_icon: str | None = fa_icon self.fa_icon: str | None = fa_icon
"""The font-awesome icon, if any."""

View File

@ -45,91 +45,78 @@ class AccountSelector {
*/ */
#prefix; #prefix;
/**
* The button to clear the account
* @type {HTMLButtonElement}
*/
#clearButton
/**
* The query input
* @type {HTMLInputElement}
*/
#query;
/**
* The error message when the query has no result
* @type {HTMLParagraphElement}
*/
#queryNoResult;
/**
* The option list
* @type {HTMLUListElement}
*/
#optionList;
/**
* The options
* @type {HTMLLIElement[]}
*/
#options;
/**
* The more item to show all accounts
* @type {HTMLLIElement}
*/
#more;
/** /**
* Constructs an account selector. * Constructs an account selector.
* *
* @param modal {HTMLDivElement} the account selector modal * @param modal {HTMLFormElement} the account selector modal
*/ */
constructor(modal) { constructor(modal) {
this.#entryType = modal.dataset.entryType; this.#entryType = modal.dataset.entryType;
this.#prefix = "accounting-account-selector-" + modal.dataset.entryType; this.#prefix = "accounting-account-selector-" + modal.dataset.entryType;
this.#query = document.getElementById(this.#prefix + "-query"); this.#init();
this.#queryNoResult = document.getElementById(this.#prefix + "-option-no-result"); }
this.#optionList = document.getElementById(this.#prefix + "-option-list");
// noinspection JSValidateTypes /**
this.#options = Array.from(document.getElementsByClassName(this.#prefix + "-option")); * Initializes the account selector.
this.#more = document.getElementById(this.#prefix + "-more"); *
this.#clearButton = document.getElementById(this.#prefix + "-btn-clear"); */
this.#more.onclick = () => { #init() {
this.#more.classList.add("d-none"); const formAccountControl = document.getElementById("accounting-entry-form-account-control");
this.#filterOptions(); const formAccount = document.getElementById("accounting-entry-form-account");
const more = document.getElementById(this.#prefix + "-more");
const btnClear = document.getElementById(this.#prefix + "-btn-clear");
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
more.onclick = () => {
more.classList.add("d-none");
this.#filterAccountOptions();
}; };
this.#clearButton.onclick = () => { this.#initializeAccountQuery();
AccountSelector.#formAccountControl.classList.remove("accounting-not-empty"); btnClear.onclick = () => {
AccountSelector.#formAccount.innerText = ""; formAccountControl.classList.remove("accounting-not-empty");
AccountSelector.#formAccount.dataset.code = ""; formAccount.innerText = "";
AccountSelector.#formAccount.dataset.text = ""; formAccount.dataset.code = "";
formAccount.dataset.text = "";
validateJournalEntryAccount(); validateJournalEntryAccount();
}; };
for (const option of this.#options) { for (const option of options) {
option.onclick = () => { option.onclick = () => {
AccountSelector.#formAccountControl.classList.add("accounting-not-empty"); formAccountControl.classList.add("accounting-not-empty");
AccountSelector.#formAccount.innerText = option.dataset.content; formAccount.innerText = option.dataset.content;
AccountSelector.#formAccount.dataset.code = option.dataset.code; formAccount.dataset.code = option.dataset.code;
AccountSelector.#formAccount.dataset.text = option.dataset.content; formAccount.dataset.text = option.dataset.content;
validateJournalEntryAccount(); validateJournalEntryAccount();
}; };
} }
this.#query.addEventListener("input", () => { }
this.#filterOptions();
/**
* Initializes the query on the account options.
*
*/
#initializeAccountQuery() {
const query = document.getElementById(this.#prefix + "-query");
query.addEventListener("input", () => {
this.#filterAccountOptions();
}); });
} }
/** /**
* Filters the options. * Filters the account options.
* *
*/ */
#filterOptions() { #filterAccountOptions() {
const codesInUse = this.#getCodesUsedInForm(); const query = document.getElementById(this.#prefix + "-query");
const optionList = document.getElementById(this.#prefix + "-option-list");
if (optionList === null) {
console.log(this.#prefix + "-option-list");
}
const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
const more = document.getElementById(this.#prefix + "-more");
const queryNoResult = document.getElementById(this.#prefix + "-option-no-result");
const codesInUse = this.#getAccountCodeUsedInForm();
let shouldAnyShow = false; let shouldAnyShow = false;
for (const option of this.#options) { for (const option of options) {
const shouldShow = this.#shouldOptionShow(option, this.#more, codesInUse, this.#query); const shouldShow = this.#shouldAccountOptionShow(option, more, codesInUse, query);
if (shouldShow) { if (shouldShow) {
option.classList.remove("d-none"); option.classList.remove("d-none");
shouldAnyShow = true; shouldAnyShow = true;
@ -137,12 +124,12 @@ class AccountSelector {
option.classList.add("d-none"); option.classList.add("d-none");
} }
} }
if (!shouldAnyShow && this.#more.classList.contains("d-none")) { if (!shouldAnyShow && more.classList.contains("d-none")) {
this.#optionList.classList.add("d-none"); optionList.classList.add("d-none");
this.#queryNoResult.classList.remove("d-none"); queryNoResult.classList.remove("d-none");
} else { } else {
this.#optionList.classList.remove("d-none"); optionList.classList.remove("d-none");
this.#queryNoResult.classList.add("d-none"); queryNoResult.classList.add("d-none");
} }
} }
@ -151,9 +138,10 @@ class AccountSelector {
* *
* @return {string[]} the account codes that are used in the form * @return {string[]} the account codes that are used in the form
*/ */
#getCodesUsedInForm() { #getAccountCodeUsedInForm() {
const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code")); const accountCodes = Array.from(document.getElementsByClassName("accounting-" + this.#prefix + "-account-code"));
const inUse = [AccountSelector.#formAccount.dataset.code]; const formAccount = document.getElementById("accounting-entry-form-account");
const inUse = [formAccount.dataset.code];
for (const accountCode of accountCodes) { for (const accountCode of accountCodes) {
inUse.push(accountCode.value); inUse.push(accountCode.value);
} }
@ -161,15 +149,15 @@ class AccountSelector {
} }
/** /**
* Returns whether an option should show. * Returns whether an account option should show.
* *
* @param option {HTMLLIElement} the option * @param option {HTMLLIElement} the account option
* @param more {HTMLLIElement} the more element * @param more {HTMLLIElement} the more account element
* @param inUse {string[]} the account codes that are used in the form * @param inUse {string[]} the account codes that are used in the form
* @param query {HTMLInputElement} the query element, if any * @param query {HTMLInputElement} the query element, if any
* @return {boolean} true if the option should show, or false otherwise * @return {boolean} true if the account option should show, or false otherwise
*/ */
#shouldOptionShow(option, more, inUse, query) { #shouldAccountOptionShow(option, more, inUse, query) {
const isQueryMatched = () => { const isQueryMatched = () => {
if (query.value === "") { if (query.value === "") {
return true; return true;
@ -192,28 +180,33 @@ class AccountSelector {
} }
/** /**
* The callback when the account selector is shown. * Initializes the account selector when it is shown.
* *
*/ */
#onOpen() { initShow() {
this.#query.value = ""; const formAccount = document.getElementById("accounting-entry-form-account");
this.#more.classList.remove("d-none"); const query = document.getElementById(this.#prefix + "-query")
this.#filterOptions(); const more = document.getElementById(this.#prefix + "-more");
for (const option of this.#options) { const options = Array.from(document.getElementsByClassName(this.#prefix + "-option"));
if (option.dataset.code === AccountSelector.#formAccount.dataset.code) { const btnClear = document.getElementById(this.#prefix + "-btn-clear");
query.value = "";
more.classList.remove("d-none");
this.#filterAccountOptions();
for (const option of options) {
if (option.dataset.code === formAccount.dataset.code) {
option.classList.add("active"); option.classList.add("active");
} else { } else {
option.classList.remove("active"); option.classList.remove("active");
} }
} }
if (AccountSelector.#formAccount.dataset.code === "") { if (formAccount.dataset.code === "") {
this.#clearButton.classList.add("btn-secondary"); btnClear.classList.add("btn-secondary");
this.#clearButton.classList.remove("btn-danger"); btnClear.classList.remove("btn-danger");
this.#clearButton.disabled = true; btnClear.disabled = true;
} else { } else {
this.#clearButton.classList.add("btn-danger"); btnClear.classList.add("btn-danger");
this.#clearButton.classList.remove("btn-secondary"); btnClear.classList.remove("btn-secondary");
this.#clearButton.disabled = false; btnClear.disabled = false;
} }
} }
@ -223,32 +216,11 @@ class AccountSelector {
*/ */
static #selectors = {} static #selectors = {}
/**
* The journal entry form.
* @type {HTMLFormElement}
*/
static #entryForm;
/**
* The control of the account on the journal entry form
* @type {HTMLDivElement}
*/
static #formAccountControl;
/**
* The account on the journal entry form
* @type {HTMLDivElement}
*/
static #formAccount;
/** /**
* Initializes the account selectors. * Initializes the account selectors.
* *
*/ */
static initialize() { static initialize() {
this.#entryForm = document.getElementById("accounting-entry-form");
this.#formAccountControl = document.getElementById("accounting-entry-form-account-control");
this.#formAccount = document.getElementById("accounting-entry-form-account");
const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal")); const modals = Array.from(document.getElementsByClassName("accounting-account-selector-modal"));
for (const modal of modals) { for (const modal of modals) {
const selector = new AccountSelector(modal); const selector = new AccountSelector(modal);
@ -262,14 +234,17 @@ class AccountSelector {
* *
*/ */
static #initializeTransactionForm() { static #initializeTransactionForm() {
this.#formAccountControl.onclick = () => this.#selectors[this.#entryForm.dataset.entryType].#onOpen(); const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.onclick = () => this.#selectors[entryForm.dataset.entryType].initShow();
} }
/** /**
* Initializes the account selector for the journal entry form. * Initializes the account selector for the journal entry form.
*x *x
*/ */
static initializeJournalEntryForm() { static initializeJournalEntryForm() {
this.#formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + this.#entryForm.dataset.entryType + "-modal"; const entryForm = document.getElementById("accounting-entry-form");
const formAccountControl = document.getElementById("accounting-entry-form-account-control");
formAccountControl.dataset.bsTarget = "#accounting-account-selector-" + entryForm.dataset.entryType + "-modal";
} }
} }

View File

@ -88,10 +88,10 @@ First written: 2023/2/25
<ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list"> <ul id="accounting-currency-{{ currency_index }}-credit-list" class="list-group accounting-entry-list">
{% for entry_form in credit_forms %} {% for entry_form in credit_forms %}
{% with currency_index = currency_index, {% with currency_index = currency_index,
entry_id = entry_form.eid.data,
entry_type = "credit", entry_type = "credit",
entry_index = loop.index, entry_index = loop.index,
only_one_entry_form = debit_forms|length == 1, only_one_entry_form = debit_forms|length == 1,
entry_id = entry_form.eid.data,
account_code_data = entry_form.account_code.data|accounting_default, account_code_data = entry_form.account_code.data|accounting_default,
account_code_error = entry_form.account_code.errors, account_code_error = entry_form.account_code.errors,
account_text = entry_form.account_text, account_text = entry_form.account_text,

View File

@ -33,11 +33,11 @@ from accounting import db
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Transaction, Account, JournalEntry, \ from accounting.models import Transaction, Account, JournalEntry, \
TransactionCurrency TransactionCurrency
from accounting.transaction.utils.account_option import AccountOption from accounting.transaction.summary_editor import SummaryEditor
from accounting.transaction.utils.summary_editor import SummaryEditor
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .account_option import AccountOption
from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \ from .currency import CurrencyForm, IncomeCurrencyForm, ExpenseCurrencyForm, \
TransferCurrencyForm TransferCurrencyForm
from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm from .journal_entry import JournalEntryForm, DebitEntryForm, CreditEntryForm
@ -80,6 +80,8 @@ class TransactionForm(FlaskForm):
"""The journal entry collector. The default is the base abstract """The journal entry collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" 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: def populate_obj(self, obj: Transaction) -> None:
"""Populates the form data into a transaction object. """Populates the form data into a transaction object.

View File

@ -26,8 +26,8 @@ from flask_wtf import FlaskForm
from accounting.models import Transaction from accounting.models import Transaction
from accounting.template_globals import default_currency_code from accounting.template_globals import default_currency_code
from accounting.utils.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
from accounting.transaction.forms import TransactionForm, \ from .form import TransactionForm, IncomeTransactionForm, \
IncomeTransactionForm, ExpenseTransactionForm, TransferTransactionForm ExpenseTransactionForm, TransferTransactionForm
class TransactionOperator(ABC): class TransactionOperator(ABC):

View File

@ -1,19 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# 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 utilities for the transaction management.
"""

View File

@ -33,10 +33,10 @@ from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.txn_types import TransactionType from accounting.utils.txn_types import TransactionType
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import sort_transactions_in, TransactionReorderForm from .form import sort_transactions_in, TransactionReorderForm
from .operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
from .template_filters import with_type, to_transfer, format_amount_input, \ from .template_filters import with_type, to_transfer, format_amount_input, \
text2html text2html
from .utils.operators import TransactionOperator, TXN_TYPE_TO_OP, get_txn_op
bp: Blueprint = Blueprint("transaction", __name__) bp: Blueprint = Blueprint("transaction", __name__)
"""The view blueprint for the transaction management.""" """The view blueprint for the transaction management."""

View File

@ -66,7 +66,7 @@ class SummeryEditorTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.transaction.utils.summary_editor import SummaryEditor from accounting.transaction.summary_editor import SummaryEditor
for form in get_form_data(self.csrf_token): for form in get_form_data(self.csrf_token):
add_txn(self.client, form) add_txn(self.client, form)
with self.app.app_context(): with self.app.app_context():