Compare commits

...

17 Commits

Author SHA1 Message Date
imacat 46c6767a90 Add missing semicolon in journal-entry-account-selector.js constructor 2026-04-17 06:56:53 +08:00
imacat 381298adb1 Mark internal classes as private in JavaScript modules 2026-04-17 06:11:17 +08:00
imacat bfa42000d8 Fix indentation in unapplied and unmatched account report templates 2026-04-16 20:41:08 +08:00
imacat 0d02f41417 Fix the typo in the variable name from conform to confirm in period-chooser.js 2026-04-16 20:41:06 +08:00
imacat a26f1942f8 Fix incorrect comment reference in balance-sheet.html template 2026-04-16 20:40:49 +08:00
imacat 0a7bcdd9ec Remove period-chooser script from unapplied and unmatched report templates 2026-04-16 20:40:37 +08:00
imacat b6ea944eb5 Fix the typo in the block name from as_trasfer to as_transfer in the journal entry detail templates 2026-04-15 13:35:21 +08:00
imacat be9f4f3d83 Fix the receipt and transfer journal entry details to show credit_total instead of debit_total for the credit section total 2026-04-15 13:35:21 +08:00
imacat dc42a05959 Migrate from Flask-SQLAlchemy to Flask-SQLAlchemy-Lite 2026-04-15 13:35:21 +08:00
imacat 9c6cc1f3eb Replace db.Model with DeclarativeBase from SQLAlchemy for Flask-SQLAlchemy-Lite migration 2026-04-15 13:35:21 +08:00
imacat e6d25882fc Replace Flask-SQLAlchemy helpers (db.relationship, db.ForeignKey, etc.) with plain SQLAlchemy equivalents 2026-04-15 13:35:21 +08:00
imacat 970c2e9946 Migrate from SQLAlchemy 1.x legacy Query API to 2.x style select/delete statements 2026-04-15 13:35:10 +08:00
imacat 356950e2c7 Replace typing.Type with built-in type[] for Python 3.12. 2026-04-05 23:49:16 +08:00
imacat cca3f89bf1 Replace absolute imports with relative imports 2026-04-05 23:49:16 +08:00
imacat 674b0de3b2 Fix various type hints 2026-04-05 23:49:12 +08:00
imacat 29dfc6c5a4 Fix pycodestyle styling issues 2026-04-05 07:05:20 +08:00
imacat aa3bc1ed69 Add Claude Code and Codex files to .gitignore 2026-04-04 23:40:55 +08:00
98 changed files with 1098 additions and 958 deletions
+2
View File
@@ -25,6 +25,8 @@ venv
.DS_Store .DS_Store
.idea .idea
.claude
.codex
instance instance
flask_session flask_session
+8 -3
View File
@@ -13,7 +13,7 @@ The following is an example configuration for *Mia! Accounting*.
from flask import Response, redirect from flask import Response, redirect
from .auth import current_user() from .auth import current_user()
from .modules import User from .modules import Base, User
def create_app(test_config=None) -> Flask: def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__) app: Flask = Flask(__name__)
@@ -37,7 +37,11 @@ The following is an example configuration for *Mia! Accounting*.
return redirect("/login") return redirect("/login")
@property @property
def cls(self) -> t.Type[User]: def base(self) -> type[DeclarativeBase]:
return Base
@property
def cls(self) -> type[User]:
return User return User
@property @property
@@ -49,7 +53,8 @@ The following is an example configuration for *Mia! Accounting*.
return current_user() return current_user()
def get_by_username(self, username: str) -> User | None: def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first() return db.session.scalar(
sa.select(User).where(User.username == username))
def get_pk(self, user: User) -> int: def get_pk(self, user: User) -> int:
return user.id return user.id
+1 -1
View File
@@ -35,7 +35,7 @@ classifiers = [
dependencies = [ dependencies = [
"Flask", "Flask",
"SQLAlchemy >= 2", "SQLAlchemy >= 2",
"Flask-SQLAlchemy", "Flask-SQLAlchemy-Lite",
"Flask-WTF", "Flask-WTF",
"Flask-Babel >= 3", "Flask-Babel >= 3",
"Flask-Babel-JS", "Flask-Babel-JS",
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,9 +20,9 @@
from pathlib import Path from pathlib import Path
from flask import Flask, Blueprint from flask import Flask, Blueprint
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy_lite import SQLAlchemy
from accounting.utils.user import UserUtilityInterface from .utils.user import UserUtilityInterface
VERSION: str = "1.6.1" VERSION: str = "1.6.1"
"""The package version.""" """The package version."""
+9 -8
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,9 +23,9 @@ from typing import Any
import click import click
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from .. import db
from accounting.models import BaseAccount, Account, AccountL10n from ..models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk from ..utils.user import get_user_pk
type AccountData = tuple[int, str, int, str, str, str, bool] type AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number, """The format of the account data, as a list of (ID, base account code, number,
@@ -36,13 +36,14 @@ def init_accounts_command(username: str) -> None:
"""Initializes the accounts.""" """Initializes the accounts."""
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
bases: list[BaseAccount] = BaseAccount.query\ bases: list[BaseAccount] = db.session.scalars(
.filter(db.func.length(BaseAccount.code) == 4)\ sa.select(BaseAccount).where(sa.func.length(BaseAccount.code) == 4)
.order_by(BaseAccount.code).all() .order_by(BaseAccount.code)).unique().all()
if len(bases) == 0: if len(bases) == 0:
raise click.Abort raise click.Abort
existing: list[Account] = Account.query.all() existing: list[Account] = \
db.session.scalars(sa.select(Account)).unique().all()
existing_base_code: set[str] = {x.base_code for x in existing} existing_base_code: set[str] = {x.base_code for x in existing}
bases_to_add: list[BaseAccount] = [x for x in bases bases_to_add: list[BaseAccount] = [x for x in bases
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import Account from ..models import Account
class AccountConverter(BaseConverter): class AccountConverter(BaseConverter):
+18 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,12 +23,12 @@ from flask_wtf import FlaskForm
from wtforms import StringField, BooleanField from wtforms import StringField, BooleanField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db from .. import db
from accounting.locale import lazy_gettext from ..locale import lazy_gettext
from accounting.models import BaseAccount, Account from ..models import BaseAccount, Account
from accounting.utils.random_id import new_id from ..utils.random_id import new_id
from accounting.utils.strip_text import strip_text from ..utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from ..utils.user import get_current_user_pk
class BaseAccountExists: class BaseAccountExists:
@@ -97,8 +97,9 @@ class AccountForm(FlaskForm):
if obj.base_code is not None: if obj.base_code is not None:
sort_accounts_in(obj.base_code, obj.id) sort_accounts_in(obj.base_code, obj.id)
sort_accounts_in(self.base_code.data, obj.id) sort_accounts_in(self.base_code.data, obj.id)
count: int = Account.query\ count: int = db.session.scalar(
.filter(Account.base_code == self.base_code.data).count() sa.select(sa.func.count(Account.id))
.where(Account.base_code == self.base_code.data))
obj.base_code = self.base_code.data obj.base_code = self.base_code.data
obj.no = count + 1 obj.no = count + 1
obj.title = self.title.data obj.title = self.title.data
@@ -137,9 +138,10 @@ class AccountForm(FlaskForm):
:return: The selectable base accounts. :return: The selectable base accounts.
""" """
return BaseAccount.query\ return db.session.scalars(
.filter(sa.func.char_length(BaseAccount.code) == 4)\ sa.select(BaseAccount)
.order_by(BaseAccount.code).all() .where(sa.func.char_length(BaseAccount.code) == 4)
.order_by(BaseAccount.code)).unique()
def sort_accounts_in(base_code: str, exclude: int) -> None: def sort_accounts_in(base_code: str, exclude: int) -> None:
@@ -150,10 +152,10 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
:param exclude: The account ID to exclude. :param exclude: The account ID to exclude.
:return: None. :return: None.
""" """
accounts: list[Account] = Account.query\ accounts: list[Account] = db.session.scalars(
.filter(Account.base_code == base_code, sa.select(Account)
Account.id != exclude)\ .where(Account.base_code == base_code, Account.id != exclude)
.order_by(Account.no).all() .order_by(Account.no)).unique().all()
for i in range(len(accounts)): for i in range(len(accounts)):
if accounts[i].no != i + 1: if accounts[i].no != i + 1:
accounts[i].no = i + 1 accounts[i].no = i + 1
+18 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,9 +20,10 @@
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting.locale import gettext from .. import db
from accounting.models import Account, AccountL10n from ..locale import gettext
from accounting.utils.query import parse_query_keywords from ..models import Account, AccountL10n
from ..utils.query import parse_query_keywords
def get_account_query() -> list[Account]: def get_account_query() -> list[Account]:
@@ -32,17 +33,20 @@ def get_account_query() -> list[Account]:
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return Account.query.order_by(Account.base_code, Account.no).all() return db.session.scalars(
code: sa.BinaryExpression = Account.base_code + "-" \ sa.select(Account)
.order_by(Account.base_code, Account.no)).unique().all()
code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String), + sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no, sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1) sa.String)) + 1)
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
l10n: list[AccountL10n] = AccountL10n.query\ l10n: list[AccountL10n] = db.session.scalars(
.filter(AccountL10n.title.icontains(k)).all() sa.select(AccountL10n)
l10n_matches: set[str] = {x.account_id for x in l10n} .where(AccountL10n.title.icontains(k))).all()
sub_conditions: list[sa.BinaryExpression] \ l10n_matches: set[int] = {x.account_id for x in l10n}
sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k), = [Account.base_code.contains(k),
Account.title_l10n.icontains(k), Account.title_l10n.icontains(k),
code.contains(k), code.contains(k),
@@ -51,5 +55,6 @@ def get_account_query() -> list[Account]:
sub_conditions.append(Account.is_need_offset) sub_conditions.append(Account.is_need_offset)
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\ return db.session.scalars(
.order_by(Account.base_code, Account.no).all() sa.select(Account).where(*conditions)
.order_by(Account.base_code, Account.no)).unique().all()
+17 -17
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,20 +21,20 @@ from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, flash, \ from flask import Blueprint, render_template, session, redirect, flash, \
url_for, request url_for, request, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Account, BaseAccount
from accounting.utils.cast import s
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 can_view, has_permission, can_edit
from accounting.utils.user import get_current_user_pk
from .forms import AccountForm, sort_accounts_in, AccountReorderForm from .forms import AccountForm, sort_accounts_in, AccountReorderForm
from .queries import get_account_query from .queries import get_account_query
from .. import db
from ..locale import lazy_gettext
from ..models import Account, BaseAccount
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import can_view, has_permission, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("account", __name__) bp: Blueprint = Blueprint("account", __name__)
"""The view blueprint for the account management.""" """The view blueprint for the account management."""
@@ -47,8 +47,8 @@ def list_accounts() -> str:
:return: The account list. :return: The account list.
""" """
accounts: list[BaseAccount] = get_account_query() accounts: list[Account] = get_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts) pagination: Pagination = Pagination[Account](accounts)
return render_template("accounting/account/list.html", return render_template("accounting/account/list.html",
list=pagination.list, pagination=pagination) list=pagination.list, pagination=pagination)
@@ -72,7 +72,7 @@ def show_add_account_form() -> str:
@bp.post("store", endpoint="store") @bp.post("store", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_account() -> redirect: def add_account() -> Response:
"""Adds an account. """Adds an account.
:return: The redirection to the account detail on success, or the account :return: The redirection to the account detail on success, or the account
@@ -123,7 +123,7 @@ def show_account_edit_form(account: Account) -> str:
@bp.post("<account:account>/update", endpoint="update") @bp.post("<account:account>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_account(account: Account) -> redirect: def update_account(account: Account) -> Response:
"""Updates an account. """Updates an account.
:param account: The account. :param account: The account.
@@ -150,7 +150,7 @@ def update_account(account: Account) -> redirect:
@bp.post("<account:account>/delete", endpoint="delete") @bp.post("<account:account>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_account(account: Account) -> redirect: def delete_account(account: Account) -> Response:
"""Deletes an account. """Deletes an account.
:param account: The account. :param account: The account.
@@ -180,7 +180,7 @@ def show_account_order(base: BaseAccount) -> str:
@bp.post("bases/<baseAccount:base>", endpoint="sort") @bp.post("bases/<baseAccount:base>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_accounts(base: BaseAccount) -> redirect: def sort_accounts(base: BaseAccount) -> Response:
"""Reorders the accounts under a base account. """Reorders the accounts under a base account.
:param base: The base account. :param base: The base account.
+7 -8
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,21 +21,20 @@ import csv
import sqlalchemy as sa import sqlalchemy as sa
from accounting import data_dir from .. import db, data_dir
from accounting import db from ..models import BaseAccount, BaseAccountL10n
from accounting.models import BaseAccount, BaseAccountL10n from ..utils.title_case import title_case
from accounting.utils.title_case import title_case
def init_base_accounts_command() -> None: def init_base_accounts_command() -> None:
"""Initializes the base accounts.""" """Initializes the base accounts."""
if BaseAccount.query.first() is not None: if db.session.scalar(sa.select(BaseAccount)) is not None:
return return
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
account_data: list[dict[str, str]] = [{"code": x["code"], account_data: list[dict[str, str]] = \
"title_l10n": title_case(x["title"])} [{"code": x["code"], "title_l10n": title_case(x["title"])}
for x in data] for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")] locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"], l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,8 +20,8 @@
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting import db from .. import db
from accounting.models import BaseAccount from ..models import BaseAccount
class BaseAccountConverter(BaseConverter): class BaseAccountConverter(BaseConverter):
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,8 +20,9 @@
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting.models import BaseAccount, BaseAccountL10n from .. import db
from accounting.utils.query import parse_query_keywords from ..models import BaseAccount, BaseAccountL10n
from ..utils.query import parse_query_keywords
def get_base_account_query() -> list[BaseAccount]: def get_base_account_query() -> list[BaseAccount]:
@@ -31,14 +32,17 @@ def get_base_account_query() -> list[BaseAccount]:
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return BaseAccount.query.order_by(BaseAccount.code).all() return db.session.scalars(
conditions: list[sa.BinaryExpression] = [] sa.select(BaseAccount).order_by(BaseAccount.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
l10n: list[BaseAccountL10n] = BaseAccountL10n.query\ l10n: list[BaseAccountL10n] = db.session.scalars(
.filter(BaseAccountL10n.title.icontains(k)).all() sa.select(BaseAccountL10n)
.where((BaseAccountL10n.title.icontains(k)))).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(BaseAccount.code.contains(k), conditions.append(sa.or_(BaseAccount.code.contains(k),
BaseAccount.title_l10n.icontains(k), BaseAccount.title_l10n.icontains(k),
BaseAccount.code.in_(l10n_matches))) BaseAccount.code.in_(l10n_matches)))
return BaseAccount.query.filter(*conditions)\ return db.session.scalars(
.order_by(BaseAccount.code).all() sa.select(BaseAccount).where(*conditions)
.order_by(BaseAccount.code)).unique().all()
+4 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/26
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,10 +19,10 @@
""" """
from flask import Blueprint, render_template from flask import Blueprint, render_template
from accounting.models import BaseAccount
from accounting.utils.pagination import Pagination
from accounting.utils.permission import has_permission, can_view
from .queries import get_base_account_query from .queries import get_base_account_query
from ..models import BaseAccount
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view
bp: Blueprint = Blueprint("base-account", __name__) bp: Blueprint = Blueprint("base-account", __name__)
"""The view blueprint for the base account management.""" """The view blueprint for the base account management."""
@@ -50,4 +50,3 @@ def show_account_detail(account: BaseAccount) -> str:
:return: The detail. :return: The detail.
""" """
return render_template("accounting/base-account/detail.html", obj=account) return render_template("accounting/base-account/detail.html", obj=account)
+14 -12
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,16 +20,16 @@
import os import os
import click import click
import sqlalchemy as sa
from flask.cli import with_appcontext from flask.cli import with_appcontext
from accounting import db from . import db
from accounting.account import init_accounts_command from .account import init_accounts_command
from accounting.base_account import init_base_accounts_command from .base_account import init_base_accounts_command
from accounting.currency import init_currencies_command from .currency import init_currencies_command
from accounting.models import BaseAccount, Account from .models import BaseAccount, Account
from accounting.utils.title_case import title_case from .utils.title_case import title_case
from accounting.utils.user import has_user, get_user_pk from .utils.user import base_cls, has_user, get_user_pk
import sqlalchemy as sa
def __validate_username(ctx: click.core.Context, param: click.core.Option, def __validate_username(ctx: click.core.Context, param: click.core.Option,
@@ -62,12 +62,14 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
def init_db_command(username: str, skip_accounts: bool, def init_db_command(username: str, skip_accounts: bool,
skip_currencies: bool) -> None: skip_currencies: bool) -> None:
"""Initializes the accounting database.""" """Initializes the accounting database."""
db.create_all() base_cls.metadata.create_all(db.engine)
init_base_accounts_command() init_base_accounts_command()
if not skip_accounts: if not skip_accounts:
init_accounts_command(username) init_accounts_command(username)
print("OK 1")
if not skip_currencies: if not skip_currencies:
init_currencies_command(username) init_currencies_command(username)
print("OK 2")
db.session.commit() db.session.commit()
click.echo("Accounting database initialized.") click.echo("Accounting database initialized.")
@@ -81,12 +83,12 @@ def titleize_command(username: str) -> None:
"""Capitalize the account titles.""" """Capitalize the account titles."""
updater_pk: int = get_user_pk(username) updater_pk: int = get_user_pk(username)
updated: int = 0 updated: int = 0
for base in BaseAccount.query: for base in db.session.scalars(sa.select(BaseAccount)).unique():
new_title: str = title_case(base.title_l10n) new_title: str = title_case(base.title_l10n)
if base.title_l10n != new_title: if base.title_l10n != new_title:
base.title_l10n = new_title base.title_l10n = new_title
updated = updated + 1 updated = updated + 1
for account in Account.query: for account in db.session.scalars(sa.select(Account)).unique():
if account.title_l10n.lower() == account.base.title_l10n.lower(): if account.title_l10n.lower() == account.base.title_l10n.lower():
new_title: str = title_case(account.title_l10n) new_title: str = title_case(account.title_l10n)
if account.title_l10n != new_title: if account.title_l10n != new_title:
+6 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,14 +22,15 @@ from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db, data_dir from .. import db, data_dir
from accounting.models import Currency, CurrencyL10n from ..models import Currency, CurrencyL10n
from accounting.utils.user import get_user_pk from ..utils.user import get_user_pk
def init_currencies_command(username: str) -> None: def init_currencies_command(username: str) -> None:
"""Initializes the currencies.""" """Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()} existing_codes: set[str] = \
{x.code for x in db.session.scalars(sa.select(Currency)).unique()}
with open(data_dir / "currencies.csv") as fp: with open(data_dir / "currencies.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,8 +20,8 @@
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting import db from .. import db
from accounting.models import Currency from ..models import Currency
class CurrencyConverter(BaseConverter): class CurrencyConverter(BaseConverter):
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,11 +21,11 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired, Regexp, NoneOf from wtforms.validators import DataRequired, Regexp, NoneOf
from accounting import db from .. import db
from accounting.locale import lazy_gettext from ..locale import lazy_gettext
from accounting.models import Currency from ..models import Currency
from accounting.utils.strip_text import strip_text from ..utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from ..utils.user import get_current_user_pk
class CodeUnique: class CodeUnique:
+13 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,8 +20,9 @@
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting.models import Currency, CurrencyL10n from .. import db
from accounting.utils.query import parse_query_keywords from ..models import Currency, CurrencyL10n
from ..utils.query import parse_query_keywords
def get_currency_query() -> list[Currency]: def get_currency_query() -> list[Currency]:
@@ -31,14 +32,17 @@ def get_currency_query() -> list[Currency]:
""" """
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return Currency.query.order_by(Currency.code).all() return db.session.scalars(
conditions: list[sa.BinaryExpression] = [] sa.select(Currency).order_by(Currency.code)).unique().all()
conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
l10n: list[CurrencyL10n] = CurrencyL10n.query\ l10n: list[CurrencyL10n] = db.session.scalars(
.filter(CurrencyL10n.name.icontains(k)).all() sa.select(CurrencyL10n)
.where(CurrencyL10n.name.icontains(k))).all()
l10n_matches: set[str] = {x.account_code for x in l10n} l10n_matches: set[str] = {x.account_code for x in l10n}
conditions.append(sa.or_(Currency.code.icontains(k), conditions.append(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k), Currency.name_l10n.icontains(k),
Currency.code.in_(l10n_matches))) Currency.code.in_(l10n_matches)))
return Currency.query.filter(*conditions)\ return db.session.scalars(
.order_by(Currency.code).all() sa.select(Currency).where(*conditions)
.order_by(Currency.code)).unique().all()
+14 -14
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,20 +21,20 @@ from urllib.parse import urlencode, parse_qsl
import sqlalchemy as sa import sqlalchemy as sa
from flask import Blueprint, render_template, redirect, session, request, \ from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency
from accounting.utils.cast import s
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 .forms import CurrencyForm from .forms import CurrencyForm
from .queries import get_currency_query from .queries import get_currency_query
from .. import db
from ..locale import lazy_gettext
from ..models import Currency
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next, or_next
from ..utils.pagination import Pagination
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("currency", __name__) bp: Blueprint = Blueprint("currency", __name__)
"""The view blueprint for the currency management.""" """The view blueprint for the currency management."""
@@ -74,7 +74,7 @@ def show_add_currency_form() -> str:
@bp.post("store", endpoint="store") @bp.post("store", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_currency() -> redirect: def add_currency() -> Response:
"""Adds a currency. """Adds a currency.
:return: The redirection to the currency detail on success, or the currency :return: The redirection to the currency detail on success, or the currency
@@ -125,7 +125,7 @@ def show_currency_edit_form(currency: Currency) -> str:
@bp.post("<currency:currency>/update", endpoint="update") @bp.post("<currency:currency>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_currency(currency: Currency) -> redirect: def update_currency(currency: Currency) -> Response:
"""Updates a currency. """Updates a currency.
:param currency: The currency. :param currency: The currency.
@@ -153,7 +153,7 @@ def update_currency(currency: Currency) -> redirect:
@bp.post("<currency:currency>/delete", endpoint="delete") @bp.post("<currency:currency>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_currency(currency: Currency) -> redirect: def delete_currency(currency: Currency) -> Response:
"""Deletes a currency. """Deletes a currency.
:param currency: The currency. :param currency: The currency.
+4 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -24,10 +24,9 @@ from flask_wtf import FlaskForm
from wtforms import StringField, ValidationError from wtforms import StringField, ValidationError
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from accounting import db from . import db
from accounting.locale import lazy_gettext from .locale import lazy_gettext
from accounting.models import Currency, Account from .models import Currency, Account
ACCOUNT_REQUIRED: DataRequired = DataRequired( ACCOUNT_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the account.")) lazy_gettext("Please select the account."))
+4 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ import datetime as dt
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting import db from .. import db
from accounting.models import JournalEntry from ..models import JournalEntry
from accounting.utils.journal_entry_types import JournalEntryType from ..utils.journal_entry_types import JournalEntryType
class JournalEntryConverter(BaseConverter): class JournalEntryConverter(BaseConverter):
+16 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -26,13 +26,13 @@ from wtforms import StringField, ValidationError, FieldList, IntegerField, \
BooleanField, FormField BooleanField, FormField
from wtforms.validators import DataRequired from wtforms.validators import DataRequired
from accounting import db
from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
from ... import db
from ...forms import CurrencyExists
from ...locale import lazy_gettext
from ...models import JournalEntryLineItem
from ...utils.offset_alias import offset_alias
from ...utils.strip_text import strip_text
CURRENCY_REQUIRED: DataRequired = DataRequired( CURRENCY_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please select the currency.")) lazy_gettext("Please select the currency."))
@@ -55,7 +55,7 @@ class SameCurrencyAsOriginalLineItems:
return return
original_line_item_currency_codes: set[str] = set(db.session.scalars( original_line_item_currency_codes: set[str] = set(db.session.scalars(
sa.select(JournalEntryLineItem.currency_code) sa.select(JournalEntryLineItem.currency_code)
.filter(JournalEntryLineItem.id.in_(original_line_item_id))).all()) .where(JournalEntryLineItem.id.in_(original_line_item_id))).all())
for currency_code in original_line_item_currency_codes: for currency_code in original_line_item_currency_codes:
if field.data != currency_code: if field.data != currency_code:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@@ -72,17 +72,17 @@ class KeepCurrencyWhenHavingOffset:
if field.data is None: if field.data is None:
return return
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\ original_line_items: list[JournalEntryLineItem] = db.session.scalars(
= JournalEntryLineItem.query\ sa.select(JournalEntryLineItem)
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)
.filter(JournalEntryLineItem.id .where(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items .in_({x.id.data for x in form.line_items
if x.id.data is not None}))\ if x.id.data is not None}))
.group_by(JournalEntryLineItem.id, .group_by(JournalEntryLineItem.id,
JournalEntryLineItem.currency_code)\ JournalEntryLineItem.currency_code)
.having(sa.func.count(offset.c.id) > 0).all() .having(sa.func.count(offset.c.id) > 0)).unique().all()
for original_line_item in original_line_items: for original_line_item in original_line_items:
if original_line_item.currency_code != field.data: if original_line_item.currency_code != field.data:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@@ -152,7 +152,7 @@ class CurrencyForm(FlaskForm):
line_item_id: set[int] = {x.id.data for x in line_item_forms line_item_id: set[int] = {x.id.data for x in line_item_forms
if x.id.data is not None} if x.id.data is not None}
select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\ select: sa.Select = sa.select(sa.func.count(JournalEntryLineItem.id))\
.filter(JournalEntryLineItem.original_line_item_id .where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id)) .in_(line_item_id))
return db.session.scalar(select) > 0 return db.session.scalar(select) > 0
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,7 +19,6 @@
""" """
import datetime as dt import datetime as dt
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Type
import sqlalchemy as sa import sqlalchemy as sa
from flask_babel import LazyString from flask_babel import LazyString
@@ -28,21 +27,20 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
BooleanField BooleanField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
from .currency import CurrencyForm, CashReceiptCurrencyForm, \ from .currency import CurrencyForm, CashReceiptCurrencyForm, \
CashDisbursementCurrencyForm, TransferCurrencyForm CashDisbursementCurrencyForm, TransferCurrencyForm
from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm from .line_item import LineItemForm, DebitLineItemForm, CreditLineItemForm
from .reorder import sort_journal_entries_in from .reorder import sort_journal_entries_in
from ..utils.account_option import AccountOption
from ..utils.description_editor import DescriptionEditor
from ..utils.original_line_items import get_selectable_original_line_items
from ... import db
from ...locale import lazy_gettext
from ...models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency
from ...utils.random_id import new_id
from ...utils.strip_text import strip_multiline_text
from ...utils.user import get_current_user_pk
DATE_REQUIRED: DataRequired = DataRequired( DATE_REQUIRED: DataRequired = DataRequired(
lazy_gettext("Please fill in the date.")) lazy_gettext("Please fill in the date."))
@@ -123,7 +121,7 @@ class JournalEntryForm(FlaskForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the journal entry is modified during populate_obj().""" """Whether the journal entry is modified during populate_obj()."""
self.collector: Type[LineItemCollector] = LineItemCollector self.collector: type[LineItemCollector] = LineItemCollector
"""The line item collector. The default is the base abstract """The line item 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."""
@@ -154,15 +152,16 @@ class JournalEntryForm(FlaskForm):
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
collector_cls: Type[LineItemCollector] = self.collector collector_cls: type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj) collector: collector_cls = collector_cls(self, obj)
collector.collect() collector.collect()
to_delete: set[int] = {x.id for x in obj.line_items to_delete: set[int] = {x.id for x in obj.line_items
if x.id not in collector.to_keep} if x.id not in collector.to_keep}
if len(to_delete) > 0: if len(to_delete) > 0:
JournalEntryLineItem.query\ db.session.execute(
.filter(JournalEntryLineItem.id.in_(to_delete)).delete() sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.id.in_(to_delete)))
self.is_modified = True self.is_modified = True
if is_new or db.session.is_modified(obj): if is_new or db.session.is_modified(obj):
@@ -197,7 +196,7 @@ class JournalEntryForm(FlaskForm):
if self.max_date is not None and new_date == self.max_date: if self.max_date is not None and new_date == self.max_date:
db_min_no: int | None = db.session.scalar( db_min_no: int | None = db.session.scalar(
sa.select(sa.func.min(JournalEntry.no)) sa.select(sa.func.min(JournalEntry.no))
.filter(JournalEntry.date == new_date)) .where(JournalEntry.date == new_date))
if db_min_no is None: if db_min_no is None:
obj.date = new_date obj.date = new_date
obj.no = 1 obj.no = 1
@@ -207,8 +206,9 @@ class JournalEntryForm(FlaskForm):
sort_journal_entries_in(new_date) sort_journal_entries_in(new_date)
else: else:
sort_journal_entries_in(new_date, obj.id) sort_journal_entries_in(new_date, obj.id)
count: int = JournalEntry.query\ count: int = db.session.scalar(
.filter(JournalEntry.date == new_date).count() sa.select(sa.func.count(JournalEntry.id))
.where(JournalEntry.date == new_date))
obj.date = new_date obj.date = new_date
obj.no = count + 1 obj.no = count + 1
@@ -223,7 +223,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "2" and x.is_need_offset)] if not (x.code[0] == "2" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id) sa.select(JournalEntryLineItem.account_id)
.filter(JournalEntryLineItem.is_debit) .where(JournalEntryLineItem.is_debit)
.group_by(JournalEntryLineItem.account_id)).all()) .group_by(JournalEntryLineItem.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
@@ -240,7 +240,7 @@ class JournalEntryForm(FlaskForm):
if not (x.code[0] == "1" and x.is_need_offset)] if not (x.code[0] == "1" and x.is_need_offset)]
in_use: set[int] = set(db.session.scalars( in_use: set[int] = set(db.session.scalars(
sa.select(JournalEntryLineItem.account_id) sa.select(JournalEntryLineItem.account_id)
.filter(sa.not_(JournalEntryLineItem.is_debit)) .where(sa.not_(JournalEntryLineItem.is_debit))
.group_by(JournalEntryLineItem.account_id)).all()) .group_by(JournalEntryLineItem.account_id)).all())
for account in accounts: for account in accounts:
account.is_in_use = account.id in in_use account.is_in_use = account.id in in_use
@@ -290,7 +290,7 @@ class JournalEntryForm(FlaskForm):
return None return None
select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\ select: sa.Select = sa.select(sa.func.max(JournalEntry.date))\
.join(JournalEntryLineItem)\ .join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.id.in_(original_line_item_id)) .where(JournalEntryLineItem.id.in_(original_line_item_id))
return db.session.scalar(select) return db.session.scalar(select)
@property @property
@@ -303,7 +303,7 @@ class JournalEntryForm(FlaskForm):
if x.id.data is not None} if x.id.data is not None}
select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\ select: sa.Select = sa.select(sa.func.min(JournalEntry.date))\
.join(JournalEntryLineItem)\ .join(JournalEntryLineItem)\
.filter(JournalEntryLineItem.original_line_item_id .where(JournalEntryLineItem.original_line_item_id
.in_(line_item_id)) .in_(line_item_id))
return db.session.scalar(select) return db.session.scalar(select)
+17 -16
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -27,15 +27,15 @@ from sqlalchemy.orm import selectinload
from wtforms import StringField, ValidationError, DecimalField, IntegerField from wtforms import StringField, ValidationError, DecimalField, IntegerField
from wtforms.validators import Optional from wtforms.validators import Optional
from accounting import db from ... import db
from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \ from ...forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
IsCreditAccount IsCreditAccount
from accounting.locale import lazy_gettext from ...locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem from ...models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount from ...template_filters import format_amount
from accounting.utils.random_id import new_id from ...utils.random_id import new_id
from accounting.utils.strip_text import strip_text from ...utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from ...utils.user import get_current_user_pk
class OriginalLineItemExists: class OriginalLineItemExists:
@@ -202,7 +202,7 @@ class NotExceedingOriginalLineItemNetBalance:
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar( offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func) sa.select(offset_total_func)
.filter(JournalEntryLineItem.original_line_item_id .where(JournalEntryLineItem.original_line_item_id
== original_line_item.id, == original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id))) JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None: if offset_total_but_form is None:
@@ -231,7 +231,7 @@ class NotLessThanOffsetTotal:
(JournalEntryLineItem.is_debit != is_debit, (JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\ else_=-JournalEntryLineItem.amount)))\
.filter(JournalEntryLineItem.original_line_item_id == form.id.data) .where(JournalEntryLineItem.original_line_item_id == form.id.data)
offset_total: Decimal | None = db.session.scalar(select_offset_total) offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total: if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@@ -353,13 +353,14 @@ class LineItemForm(FlaskForm):
def get_offsets() -> list[JournalEntryLineItem]: def get_offsets() -> list[JournalEntryLineItem]:
if not self.is_need_offset or self.id.data is None: if not self.is_need_offset or self.id.data is None:
return [] return []
return JournalEntryLineItem.query.join(JournalEntry)\ return db.session.scalars(
.filter(JournalEntryLineItem.original_line_item_id sa.select(JournalEntryLineItem).join(JournalEntry)
== self.id.data)\ .where(JournalEntryLineItem.original_line_item_id
== self.id.data)
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.no)\ JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry), .options(selectinload(JournalEntryLineItem.journal_entry),
selectinload(JournalEntryLineItem.account)).all() selectinload(JournalEntryLineItem.account))).all()
setattr(self, "__offsets", get_offsets()) setattr(self, "__offsets", get_offsets())
return getattr(self, "__offsets") return getattr(self, "__offsets")
+10 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,8 +22,8 @@ import datetime as dt
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting import db from ... import db
from accounting.models import JournalEntry from ...models import JournalEntry
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None: def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
@@ -34,12 +34,12 @@ def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
:param exclude: The journal entry ID to exclude. :param exclude: The journal entry ID to exclude.
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] = [JournalEntry.date == date] conditions: list[sa.ColumnElement[bool]] = [JournalEntry.date == date]
if exclude is not None: if exclude is not None:
conditions.append(JournalEntry.id != exclude) conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\ journal_entries: list[JournalEntry] = db.session.scalars(
.filter(*conditions)\ sa.select(JournalEntry).where(*conditions)
.order_by(JournalEntry.no).all() .order_by(JournalEntry.no)).all()
for i in range(len(journal_entries)): for i in range(len(journal_entries)):
if journal_entries[i].no != i + 1: if journal_entries[i].no != i + 1:
journal_entries[i].no = i + 1 journal_entries[i].no = i + 1
@@ -63,8 +63,9 @@ class JournalEntryReorderForm:
:return: :return:
""" """
journal_entries: list[JournalEntry] = JournalEntry.query\ journal_entries: list[JournalEntry] = db.session.scalars(
.filter(JournalEntry.date == self.date).all() sa.select(JournalEntry)
.where(JournalEntry.date == self.date)).all()
# Collects the specified order. # Collects the specified order.
orders: dict[JournalEntry, int] = {} orders: dict[JournalEntry, int] = {}
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -17,7 +17,7 @@
"""The account option for the journal entry management. """The account option for the journal entry management.
""" """
from accounting.models import Account from ...models import Account
class AccountOption: class AccountOption:
@@ -28,7 +28,7 @@ class AccountOption:
:param account: The account. :param account: The account.
""" """
self.id: str = account.id self.id: int = account.id
"""The account ID.""" """The account ID."""
self.code: str = account.code self.code: str = account.code
"""The account code.""" """The account code."""
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/27
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ from typing import Literal
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from ... import db
from accounting.models import Account, JournalEntryLineItem from ...models import Account, JournalEntryLineItem
from accounting.utils.options import options, Recurring from ...utils.options import options, Recurring
class DescriptionAccount: class DescriptionAccount:
@@ -272,15 +272,17 @@ class DescriptionEditor:
select: sa.Select = sa.Select(debit_credit, tag_type, tag, select: sa.Select = sa.Select(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id, JournalEntryLineItem.account_id,
sa.func.count().label("freq"))\ sa.func.count().label("freq"))\
.filter(JournalEntryLineItem.description.is_not(None), .where(JournalEntryLineItem.description.is_not(None),
JournalEntryLineItem.description.like("_%—_%"), JournalEntryLineItem.description.like("_%—_%"),
JournalEntryLineItem.original_line_item_id.is_(None))\ JournalEntryLineItem.original_line_item_id.is_(None))\
.group_by(debit_credit, tag_type, tag, .group_by(debit_credit, tag_type, tag,
JournalEntryLineItem.account_id) JournalEntryLineItem.account_id)
result: list[sa.Row] = db.session.execute(select).all() result: list[sa.Row] = db.session.execute(select).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query = {x.id: x for x in db.session.scalars(
.filter(Account.id.in_({x.account_id for x in result})).all()} sa.select(Account)
.where(Account.id.in_({x.account_id for x in result})))
.unique()}
debit_credit_dict: dict[Literal["debit", "credit"], debit_credit_dict: dict[Literal["debit", "credit"],
DescriptionDebitCredit] \ DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}} = {x.debit_credit: x for x in {self.debit, self.credit}}
@@ -315,20 +317,21 @@ class DescriptionEditor:
if len(codes) == 0: if len(codes) == 0:
return {} return {}
def get_condition(code0: str) -> sa.BinaryExpression: def get_condition(code0: str) -> sa.ColumnElement[bool]:
m: re.Match = re.match(r"^(\d{4})-(\d{3})$", code0) m: re.Match[str] | None = re.match(r"^(\d{4})-(\d{3})$", code0)
assert m is not None,\ assert m is not None, \
f"Malformed account code \"{code0}\" for regular transactions." f"Malformed account code \"{code0}\" for regular transactions."
return sa.and_(Account.base_code == m.group(1), return sa.and_(Account.base_code == m.group(1),
Account.no == int(m.group(2))) Account.no == int(m.group(2)))
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [get_condition(x) for x in codes] = [get_condition(x) for x in codes]
accounts: dict[str, Account] \ accounts: dict[str, Account] \
= {x.code: x for x in = {x.code: x for x in
Account.query.filter(sa.or_(*conditions)).all()} db.session.scalars(
sa.select(Account).where(sa.or_(*conditions))).unique()}
for code in codes: for code in codes:
assert code in accounts,\ assert code in accounts, \
f"Unknown account \"{code}\" for regular transactions." f"Unknown account \"{code}\" for regular transactions."
return accounts return accounts
+12 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/19
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -18,18 +18,16 @@
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Type
from flask import render_template, request, abort from flask import render_template, request, abort
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from accounting.journal_entry.forms import JournalEntryForm, \ from ..forms import JournalEntryForm, CashReceiptJournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \ CashDisbursementJournalEntryForm, TransferJournalEntryForm
TransferJournalEntryForm from ..forms.line_item import LineItemForm
from accounting.journal_entry.forms.line_item import LineItemForm from ...models import JournalEntry
from accounting.models import JournalEntry from ...template_globals import default_currency_code
from accounting.template_globals import default_currency_code from ...utils.journal_entry_types import JournalEntryType
from accounting.utils.journal_entry_types import JournalEntryType
class JournalEntryOperator(ABC): class JournalEntryOperator(ABC):
@@ -39,7 +37,7 @@ class JournalEntryOperator(ABC):
@property @property
@abstractmethod @abstractmethod
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -100,7 +98,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -170,7 +168,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -243,7 +241,7 @@ class TransferJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> Type[JournalEntryForm]: def form(self) -> type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@@ -334,3 +332,4 @@ def get_journal_entry_op(journal_entry: JournalEntry,
key=lambda x: x.CHECK_ORDER): key=lambda x: x.CHECK_ORDER):
if journal_entry_type.is_my_type(journal_entry): if journal_entry_type.is_my_type(journal_entry):
return journal_entry_type return journal_entry_type
assert False
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/10
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting import db from ... import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem from ...models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.offset_alias import offset_alias from ...utils.offset_alias import offset_alias
def get_selectable_original_line_items( def get_selectable_original_line_items(
@@ -46,8 +46,8 @@ def get_selectable_original_line_items(
(offset.c.id.in_(line_item_id_on_form), 0), (offset.c.id.in_(line_item_id_on_form), 0),
(offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount), (offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset] conditions: list[sa.ColumnElement[bool]] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = [] sub_conditions: list[sa.ColumnElement[bool]] = []
if is_payable: if is_payable:
sub_conditions.append(sa.and_(Account.base_code.startswith("2"), sub_conditions.append(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit))) sa.not_(JournalEntryLineItem.is_debit)))
@@ -61,20 +61,21 @@ def get_selectable_original_line_items(
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(*conditions)\ .where(*conditions)\
.group_by(JournalEntryLineItem.id)\ .group_by(JournalEntryLineItem.id)\
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
net_balances: dict[int, Decimal] \ net_balances: dict[int, Decimal] \
= {x.id: x.net_balance = {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()} for x in db.session.execute(select_net_balances)}
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query\ line_items: list[JournalEntryLineItem] = db.session.scalars(
.filter(JournalEntryLineItem.id.in_({x for x in net_balances}))\ sa.select(JournalEntryLineItem)
.join(JournalEntry)\ .where(JournalEntryLineItem.id.in_({x for x in net_balances}))
.join(JournalEntry)
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no)\ JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency), .options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.account), selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry))).all()
line_items.reverse() line_items.reverse()
for line_item in line_items: for line_item in line_items:
line_item.net_balance = line_item.amount \ line_item.net_balance = line_item.amount \
+19 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,24 +22,24 @@ from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa import sqlalchemy as sa
from flask import Blueprint, render_template, session, redirect, request, \ from flask import Blueprint, render_template, session, redirect, request, \
flash, url_for flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import JournalEntry
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.timezone import get_tz_today
from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm from .forms import sort_journal_entries_in, JournalEntryReorderForm
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 JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \ from .utils.operators import JournalEntryOperator, JOURNAL_ENTRY_TYPE_TO_OP, \
get_journal_entry_op get_journal_entry_op
from .. import db
from ..locale import lazy_gettext
from ..models import JournalEntry
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.journal_entry_types import JournalEntryType
from ..utils.next_uri import inherit_next, or_next
from ..utils.permission import has_permission, can_view, can_edit
from ..utils.timezone import get_tz_today
from ..utils.user import get_current_user_pk
bp: Blueprint = Blueprint("journal-entry", __name__) bp: Blueprint = Blueprint("journal-entry", __name__)
"""The view blueprint for the journal entry management.""" """The view blueprint for the journal entry management."""
@@ -74,7 +74,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
@bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store") @bp.post("store/<journalEntryType:journal_entry_type>", endpoint="store")
@has_permission(can_edit) @has_permission(can_edit)
def add_journal_entry(journal_entry_type: JournalEntryType) -> redirect: def add_journal_entry(journal_entry_type: JournalEntryType) -> Response:
"""Adds a journal entry. """Adds a journal entry.
:param journal_entry_type: The journal entry type. :param journal_entry_type: The journal entry type.
@@ -136,7 +136,7 @@ def show_journal_entry_edit_form(journal_entry: JournalEntry) -> str:
@bp.post("<journalEntry:journal_entry>/update", endpoint="update") @bp.post("<journalEntry:journal_entry>/update", endpoint="update")
@has_permission(can_edit) @has_permission(can_edit)
def update_journal_entry(journal_entry: JournalEntry) -> redirect: def update_journal_entry(journal_entry: JournalEntry) -> Response:
"""Updates a journal entry. """Updates a journal entry.
:param journal_entry: The journal entry. :param journal_entry: The journal entry.
@@ -169,7 +169,7 @@ def update_journal_entry(journal_entry: JournalEntry) -> redirect:
@bp.post("<journalEntry:journal_entry>/delete", endpoint="delete") @bp.post("<journalEntry:journal_entry>/delete", endpoint="delete")
@has_permission(can_edit) @has_permission(can_edit)
def delete_journal_entry(journal_entry: JournalEntry) -> redirect: def delete_journal_entry(journal_entry: JournalEntry) -> Response:
"""Deletes a journal entry. """Deletes a journal entry.
:param journal_entry: The journal entry. :param journal_entry: The journal entry.
@@ -195,16 +195,16 @@ def show_journal_entry_order(date: dt.date) -> str:
:param date: The date. :param date: The date.
:return: The order of the journal entries in the date. :return: The order of the journal entries in the date.
""" """
journal_entries: list[JournalEntry] = JournalEntry.query \ journal_entries: list[JournalEntry] = db.session.scalars(
.filter(JournalEntry.date == date) \ sa.select(JournalEntry).where(JournalEntry.date == date)
.order_by(JournalEntry.no).all() .order_by(JournalEntry.no)).all()
return render_template("accounting/journal-entry/order.html", return render_template("accounting/journal-entry/order.html",
date=date, list=journal_entries) date=date, list=journal_entries)
@bp.post("dates/<date:date>", endpoint="sort") @bp.post("dates/<date:date>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_journal_entries(date: dt.date) -> redirect: def sort_journal_entries(date: dt.date) -> Response:
"""Reorders the journal entries in a date. """Reorders the journal entries in a date.
:param date: The date. :param date: The date.
+112 -100
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,20 +22,19 @@ from __future__ import annotations
import datetime as dt import datetime as dt
import re import re
from decimal import Decimal from decimal import Decimal
from typing import Type, Self from typing import Self
import sqlalchemy as sa import sqlalchemy as sa
from babel import Locale from babel import Locale
from flask_babel import get_locale, get_babel from flask_babel import get_locale, get_babel
from sqlalchemy import text from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from accounting import db from . import db
from accounting.locale import gettext from .locale import gettext
from accounting.utils.user import user_cls, user_pk_column from .utils.user import base_cls, user_cls, user_pk_column
class BaseAccount(db.Model): class BaseAccount(base_cls):
"""A base account.""" """A base account."""
__tablename__ = "accounting_base_accounts" __tablename__ = "accounting_base_accounts"
"""The table name.""" """The table name."""
@@ -44,9 +43,9 @@ class BaseAccount(db.Model):
title_l10n: Mapped[str] = mapped_column("title") title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
l10n: Mapped[list[BaseAccountL10n]] \ l10n: Mapped[list[BaseAccountL10n]] \
= db.relationship(back_populates="account", lazy=False) = relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
accounts: Mapped[list[Account]] = db.relationship(back_populates="base") accounts: Mapped[list[Account]] = relationship(back_populates="base")
"""The descendant accounts under the base account.""" """The descendant accounts under the base account."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -79,16 +78,16 @@ class BaseAccount(db.Model):
return [self.code, self.title_l10n] + [x.title for x in self.l10n] return [self.code, self.title_l10n] + [x.title for x in self.l10n]
class BaseAccountL10n(db.Model): class BaseAccountL10n(base_cls):
"""A localized base account title.""" """A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n" __tablename__ = "accounting_base_accounts_l10n"
"""The table name.""" """The table name."""
account_code: Mapped[str] \ account_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
primary_key=True) primary_key=True)
"""The account code.""" """The account code."""
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n") account: Mapped[BaseAccount] = relationship(back_populates="l10n")
"""The account.""" """The account."""
locale: Mapped[str] = mapped_column(primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
@@ -96,47 +95,47 @@ class BaseAccountL10n(db.Model):
"""The localized title.""" """The localized title."""
class Account(db.Model): class Account(base_cls):
"""An account.""" """An account."""
__tablename__ = "accounting_accounts" __tablename__ = "accounting_accounts"
"""The table name.""" """The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The account ID.""" """The account ID."""
base_code: Mapped[str] \ base_code: Mapped[str] \
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(sa.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE")) ondelete="CASCADE"))
"""The code of the base account.""" """The code of the base account."""
base: Mapped[BaseAccount] = db.relationship(back_populates="accounts") base: Mapped[BaseAccount] = relationship(back_populates="accounts")
"""The base account.""" """The base account."""
no: Mapped[int] = mapped_column(default=text("1")) no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The account number under the base account.""" """The account number under the base account."""
title_l10n: Mapped[str] = mapped_column("title") title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
is_need_offset: Mapped[bool] = mapped_column(default=False) is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items of this account need offset.""" """Whether the journal entry line items of this account need offset."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
l10n: Mapped[list[AccountL10n]] \ l10n: Mapped[list[AccountL10n]] \
= db.relationship(back_populates="account", lazy=False) = relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
line_items: Mapped[list[JournalEntryLineItem]] \ line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="account") = relationship(back_populates="account")
"""The journal entry line items.""" """The journal entry line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
@@ -268,9 +267,10 @@ class Account(db.Model):
:return: None. :return: None.
""" """
AccountL10n.query.filter(AccountL10n.account == self).delete() db.session.execute(sa.delete(AccountL10n)
cls: Type[Self] = self.__class__ .where(AccountL10n.account == self))
cls.query.filter(cls.id == self.id).delete() cls: type[Self] = self.__class__
db.session.execute(sa.delete(cls).where(cls.id == self.id))
@classmethod @classmethod
def find_by_code(cls, code: str) -> Self | None: def find_by_code(cls, code: str) -> Self | None:
@@ -279,11 +279,12 @@ class Account(db.Model):
:param code: The code. :param code: The code.
:return: The account, or None if this account does not exist. :return: The account, or None if this account does not exist.
""" """
m = re.match(r"^([1-9]{4})-(\d{3})$", code) m: re.Match[str] | None = re.match(r"^([1-9]{4})-(\d{3})$", code)
if m is None: if m is None:
return None return None
return cls.query.filter(cls.base_code == m.group(1), return db.session.scalar(
cls.no == int(m.group(2))).first() sa.select(cls).where(cls.base_code == m.group(1),
cls.no == int(m.group(2))))
@classmethod @classmethod
def selectable_debit(cls) -> list[Self]: def selectable_debit(cls) -> list[Self]:
@@ -292,7 +293,9 @@ class Account(db.Model):
:return: The selectable debit accounts. :return: The selectable debit accounts.
""" """
return cls.query.filter(sa.or_(cls.base_code.startswith("1"), return db.session.scalars(
sa.select(cls)
.where(sa.or_(cls.base_code.startswith("1"),
sa.and_(cls.base_code.startswith("2"), sa.and_(cls.base_code.startswith("2"),
sa.not_(cls.is_need_offset)), sa.not_(cls.is_need_offset)),
cls.base_code.startswith("3"), cls.base_code.startswith("3"),
@@ -304,8 +307,8 @@ class Account(db.Model):
cls.base_code.startswith("78"), cls.base_code.startswith("78"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3353")\ cls.base_code != "3353")
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no)).unique().all()
@classmethod @classmethod
def selectable_credit(cls) -> list[Self]: def selectable_credit(cls) -> list[Self]:
@@ -314,7 +317,9 @@ class Account(db.Model):
:return: The selectable debit accounts. :return: The selectable debit accounts.
""" """
return cls.query.filter(sa.or_(sa.and_(cls.base_code.startswith("1"), return db.session.scalars(
sa.select(cls)
.where(sa.or_(sa.and_(cls.base_code.startswith("1"),
sa.not_(cls.is_need_offset)), sa.not_(cls.is_need_offset)),
cls.base_code.startswith("2"), cls.base_code.startswith("2"),
cls.base_code.startswith("3"), cls.base_code.startswith("3"),
@@ -325,8 +330,8 @@ class Account(db.Model):
cls.base_code.startswith("74"), cls.base_code.startswith("74"),
cls.base_code.startswith("8"), cls.base_code.startswith("8"),
cls.base_code.startswith("9")), cls.base_code.startswith("9")),
cls.base_code != "3353")\ cls.base_code != "3353")
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no)).unique().all()
@classmethod @classmethod
def cash(cls) -> Self: def cash(cls) -> Self:
@@ -334,7 +339,9 @@ class Account(db.Model):
:return: The cash account :return: The cash account
""" """
return cls.find_by_code(cls.CASH_CODE) account: Self | None = cls.find_by_code(cls.CASH_CODE)
assert account is not None
return account
@classmethod @classmethod
def accumulated_change(cls) -> Self: def accumulated_change(cls) -> Self:
@@ -342,19 +349,21 @@ class Account(db.Model):
:return: The accumulated-change account :return: The accumulated-change account
""" """
return cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE) account: Self | None = cls.find_by_code(cls.ACCUMULATED_CHANGE_CODE)
assert account is not None
return account
class AccountL10n(db.Model): class AccountL10n(base_cls):
"""A localized account title.""" """A localized account title."""
__tablename__ = "accounting_accounts_l10n" __tablename__ = "accounting_accounts_l10n"
"""The table name.""" """The table name."""
account_id: Mapped[int] \ account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE", = mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
primary_key=True) primary_key=True)
"""The account ID.""" """The account ID."""
account: Mapped[Account] = db.relationship(back_populates="l10n") account: Mapped[Account] = relationship(back_populates="l10n")
"""The account.""" """The account."""
locale: Mapped[str] = mapped_column(primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
@@ -362,7 +371,7 @@ class AccountL10n(db.Model):
"""The localized title.""" """The localized title."""
class Currency(db.Model): class Currency(base_cls):
"""A currency.""" """A currency."""
__tablename__ = "accounting_currencies" __tablename__ = "accounting_currencies"
"""The table name.""" """The table name."""
@@ -371,29 +380,29 @@ class Currency(db.Model):
name_l10n: Mapped[str] = mapped_column("name") name_l10n: Mapped[str] = mapped_column("name")
"""The currency name.""" """The currency name."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] \ updated_by: Mapped[user_cls] \
= db.relationship(foreign_keys=updated_by_id) = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
l10n: Mapped[list[CurrencyL10n]] \ l10n: Mapped[list[CurrencyL10n]] \
= db.relationship(back_populates="currency", lazy=False) = relationship(back_populates="currency", lazy=False)
"""The localized names.""" """The localized names."""
line_items: Mapped[list[JournalEntryLineItem]] \ line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="currency") = relationship(back_populates="currency")
"""The journal entry line items.""" """The journal entry line items."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -458,7 +467,7 @@ class Currency(db.Model):
:return: True if the currency can be deleted, or False otherwise. :return: True if the currency can be deleted, or False otherwise.
""" """
from accounting.template_globals import default_currency_code from .template_globals import default_currency_code
if self.code == default_currency_code(): if self.code == default_currency_code():
return False return False
return len(self.line_items) == 0 return len(self.line_items) == 0
@@ -468,21 +477,22 @@ class Currency(db.Model):
:return: None. :return: None.
""" """
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete() db.session.execute(
cls: Type[Self] = self.__class__ sa.delete(CurrencyL10n)
cls.query.filter(cls.code == self.code).delete() .where(CurrencyL10n.currency_code == self.code))
db.session.delete(self)
class CurrencyL10n(db.Model): class CurrencyL10n(base_cls):
"""A localized currency name.""" """A localized currency name."""
__tablename__ = "accounting_currencies_l10n" __tablename__ = "accounting_currencies_l10n"
"""The table name.""" """The table name."""
currency_code: Mapped[str] \ currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE", = mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
primary_key=True) primary_key=True)
"""The currency code.""" """The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="l10n") currency: Mapped[Currency] = relationship(back_populates="l10n")
"""The currency.""" """The currency."""
locale: Mapped[str] = mapped_column(primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
@@ -533,7 +543,7 @@ class JournalEntryCurrency:
return sum([x.amount for x in self.credit]) return sum([x.amount for x in self.credit])
class JournalEntry(db.Model): class JournalEntry(base_cls):
"""A journal entry.""" """A journal entry."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_journal_entries"
"""The table name.""" """The table name."""
@@ -541,30 +551,30 @@ class JournalEntry(db.Model):
"""The journal entry ID.""" """The journal entry ID."""
date: Mapped[dt.date] date: Mapped[dt.date]
"""The date.""" """The date."""
no: Mapped[int] = mapped_column(default=text("1")) no: Mapped[int] = mapped_column(default=sa.text("1"))
"""The journal entry number under the date.""" """The journal entry number under the date."""
note: Mapped[str | None] note: Mapped[str | None]
"""The note.""" """The note."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
line_items: Mapped[list[JournalEntryLineItem]] \ line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="journal_entry") = relationship(back_populates="journal_entry")
"""The line items.""" """The line items."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -645,48 +655,49 @@ class JournalEntry(db.Model):
:return: None. :return: None.
""" """
JournalEntryLineItem.query\ db.session.execute(
.filter(JournalEntryLineItem.journal_entry_id == self.id).delete() sa.delete(JournalEntryLineItem)
.where(JournalEntryLineItem.journal_entry_id == self.id))
db.session.delete(self) db.session.delete(self)
class JournalEntryLineItem(db.Model): class JournalEntryLineItem(base_cls):
"""A line item in the journal entry.""" """A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items" __tablename__ = "accounting_journal_entry_line_items"
"""The table name.""" """The table name."""
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
"""The line item ID.""" """The line item ID."""
journal_entry_id: Mapped[int] \ journal_entry_id: Mapped[int] \
= mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE", = mapped_column(sa.ForeignKey(JournalEntry.id, onupdate="CASCADE",
ondelete="CASCADE")) ondelete="CASCADE"))
"""The journal entry ID.""" """The journal entry ID."""
journal_entry: Mapped[JournalEntry] \ journal_entry: Mapped[JournalEntry] \
= db.relationship(back_populates="line_items") = relationship(back_populates="line_items")
"""The journal entry.""" """The journal entry."""
is_debit: Mapped[bool] is_debit: Mapped[bool]
"""True for a debit line item, or False for a credit line item.""" """True for a debit line item, or False for a credit line item."""
no: Mapped[int] no: Mapped[int]
"""The line item number under the journal entry and debit or credit.""" """The line item number under the journal entry and debit or credit."""
original_line_item_id: Mapped[int | None] \ original_line_item_id: Mapped[int | None] \
= mapped_column(db.ForeignKey(id, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(id, onupdate="CASCADE"))
"""The ID of the original line item.""" """The ID of the original line item."""
original_line_item: Mapped[JournalEntryLineItem | None] \ original_line_item: Mapped[JournalEntryLineItem | None] \
= db.relationship(remote_side=id, passive_deletes=True) = relationship(remote_side=id, passive_deletes=True)
"""The original line item.""" """The original line item."""
currency_code: Mapped[str] \ currency_code: Mapped[str] \
= mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(Currency.code, onupdate="CASCADE"))
"""The currency code.""" """The currency code."""
currency: Mapped[Currency] = db.relationship(back_populates="line_items") currency: Mapped[Currency] = relationship(back_populates="line_items")
"""The currency.""" """The currency."""
account_id: Mapped[int] \ account_id: Mapped[int] \
= mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(Account.id, onupdate="CASCADE"))
"""The account ID.""" """The account ID."""
account: Mapped[Account] \ account: Mapped[Account] \
= db.relationship(back_populates="line_items", lazy=False) = relationship(back_populates="line_items", lazy=False)
"""The account.""" """The account."""
description: Mapped[str | None] description: Mapped[str | None]
"""The description.""" """The description."""
amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2)) amount: Mapped[Decimal] = mapped_column(sa.Numeric(14, 2))
"""The amount.""" """The amount."""
def __str__(self) -> str: def __str__(self) -> str:
@@ -695,7 +706,7 @@ class JournalEntryLineItem(db.Model):
:return: The string representation of the line item. :return: The string representation of the line item.
""" """
if not hasattr(self, "__str"): if not hasattr(self, "__str"):
from accounting.template_filters import format_date, format_amount from .template_filters import format_date, format_amount
setattr(self, "__str", setattr(self, "__str",
gettext("%(date)s %(description)s %(amount)s", gettext("%(date)s %(description)s %(amount)s",
date=format_date(self.journal_entry.date), date=format_date(self.journal_entry.date),
@@ -811,11 +822,12 @@ class JournalEntryLineItem(db.Model):
:return: The offset items. :return: The offset items.
""" """
if not hasattr(self, "__offsets"): if not hasattr(self, "__offsets"):
cls: Type[Self] = self.__class__ cls: type[Self] = self.__class__
offsets: list[Self] = cls.query.join(JournalEntry)\ offsets: list[Self] = db.session.scalars(
.filter(JournalEntryLineItem.original_line_item_id == self.id)\ sa.select(cls).join(JournalEntry)
.where(cls.original_line_item_id == self.id)
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all() cls.is_debit, cls.no)).unique().all()
setattr(self, "__offsets", offsets) setattr(self, "__offsets", offsets)
return getattr(self, "__offsets") return getattr(self, "__offsets")
@@ -876,29 +888,29 @@ class JournalEntryLineItem(db.Model):
format_amount(self.amount)] format_amount(self.amount)]
class Option(db.Model): class Option(base_cls):
"""An option.""" """An option."""
__tablename__ = "accounting_options" __tablename__ = "accounting_options"
"""The table name.""" """The table name."""
name: Mapped[str] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(primary_key=True)
"""The name.""" """The name."""
value: Mapped[str] = mapped_column(db.Text) value: Mapped[str] = mapped_column(sa.Text)
"""The option value.""" """The option value."""
created_at: Mapped[dt.datetime] \ created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was created.""" """The date and time when this record was created."""
created_by_id: Mapped[int] \ created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record.""" """The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id) created_by: Mapped[user_cls] = relationship(foreign_keys=created_by_id)
"""The user who created the record.""" """The user who created the record."""
updated_at: Mapped[dt.datetime] \ updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True), = mapped_column(sa.DateTime(timezone=True),
server_default=db.func.now()) server_default=sa.func.now())
"""The date and time when this record was last updated.""" """The date and time when this record was last updated."""
updated_by_id: Mapped[int] \ updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE")) = mapped_column(sa.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record.""" """The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id) updated_by: Mapped[user_cls] = relationship(foreign_keys=updated_by_id)
"""The last user who updated the record.""" """The last user who updated the record."""
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,13 +23,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, FieldList, FormField, IntegerField from wtforms import StringField, FieldList, FormField, IntegerField
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting.forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \ from ..forms import ACCOUNT_REQUIRED, CurrencyExists, AccountExists, \
IsDebitAccount, IsCreditAccount IsDebitAccount, IsCreditAccount
from accounting.locale import lazy_gettext from ..locale import lazy_gettext
from accounting.models import Account from ..models import Account
from accounting.utils.current_account import CurrentAccount from ..utils.current_account import CurrentAccount
from accounting.utils.options import Options from ..utils.options import Options
from accounting.utils.strip_text import strip_text from ..utils.strip_text import strip_text
class CurrentAccountExists: class CurrentAccountExists:
+9 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,16 +20,16 @@
from urllib.parse import parse_qsl, urlencode from urllib.parse import parse_qsl, urlencode
from flask import Blueprint, render_template, redirect, session, request, \ from flask import Blueprint, render_template, redirect, session, request, \
flash, url_for flash, url_for, Response
from werkzeug.datastructures import ImmutableMultiDict from werkzeug.datastructures import ImmutableMultiDict
from accounting.locale import lazy_gettext
from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.next_uri import inherit_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_admin
from .forms import OptionForm from .forms import OptionForm
from ..locale import lazy_gettext
from ..utils.cast import s
from ..utils.flash_errors import flash_form_errors
from ..utils.next_uri import inherit_next
from ..utils.options import options
from ..utils.permission import has_permission, can_admin
bp: Blueprint = Blueprint("option", __name__) bp: Blueprint = Blueprint("option", __name__)
"""The view blueprint for the currency management.""" """The view blueprint for the currency management."""
@@ -64,7 +64,7 @@ def show_option_form() -> str:
@bp.post("update", endpoint="update") @bp.post("update", endpoint="update")
@has_permission(can_admin) @has_permission(can_admin)
def update_options() -> redirect: def update_options() -> Response:
"""Updates the options. """Updates the options.
:return: The redirection to the option form. :return: The redirection to the option form.
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,9 +22,9 @@ import re
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
from accounting.models import Account
from accounting.utils.current_account import CurrentAccount
from .period import Period, get_period from .period import Period, get_period
from ..models import Account
from ..utils.current_account import CurrentAccount
class PeriodConverter(BaseConverter): class PeriodConverter(BaseConverter):
+8 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,11 +23,14 @@ This file is largely taken from the NanoParma ERP project, first written in
import datetime as dt import datetime as dt
from collections.abc import Callable from collections.abc import Callable
from accounting.models import JournalEntry import sqlalchemy as sa
from accounting.utils.timezone import get_tz_today
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod LastYear, Today, Yesterday, AllTime, TemplatePeriod, YearPeriod
from ... import db
from ...models import JournalEntry
from ...utils.timezone import get_tz_today
class PeriodChooser: class PeriodChooser:
@@ -62,8 +65,8 @@ class PeriodChooser:
self.url_template: str = get_url(TemplatePeriod()) self.url_template: str = get_url(TemplatePeriod())
"""The URL template.""" """The URL template."""
first: JournalEntry | None \ first: JournalEntry | None = db.session.scalar(
= JournalEntry.query.order_by(JournalEntry.date).first() sa.select(JournalEntry).order_by(JournalEntry.date))
start: dt.date | None = None if first is None else first.date start: dt.date | None = None if first is None else first.date
# Attributes # Attributes
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
""" """
import datetime as dt import datetime as dt
from accounting.locale import gettext from ...locale import gettext
def get_desc(start: dt.date | None, end: dt.date | None) -> str: def get_desc(start: dt.date | None, end: dt.date | None) -> str:
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,7 +21,6 @@ import calendar
import datetime as dt import datetime as dt
import re import re
from collections.abc import Callable from collections.abc import Callable
from typing import Type
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@@ -40,7 +39,7 @@ def get_period(spec: str | None = None) -> Period:
""" """
if spec is None: if spec is None:
return ThisMonth() return ThisMonth()
named_periods: dict[str, Type[Callable[[], Period]]] = { named_periods: dict[str, type[Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(), "this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(), "last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(), "since-last-month": lambda: SinceLastMonth(),
@@ -68,6 +67,7 @@ def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
""" """
if text == "-": if text == "-":
return None, None return None, None
m: re.Match[str] | None
m = re.match(f"^{DATE_SPEC_RE}$", text) m = re.match(f"^{DATE_SPEC_RE}$", text)
if m is not None: if m is not None:
return __get_start(m[1], m[2], m[3]), \ return __get_start(m[1], m[2], m[3]), \
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,10 +19,10 @@
""" """
import datetime as dt import datetime as dt
from accounting.locale import gettext
from accounting.utils.timezone import get_tz_today
from .month_end import month_end from .month_end import month_end
from .period import Period from .period import Period
from ...locale import gettext
from ...utils.timezone import get_tz_today
class ThisMonth(Period): class ThisMonth(Period):
+11 -5
View File
@@ -27,12 +27,18 @@ def get_spec(start: dt.date | None, end: dt.date | None) -> str:
:param end: The end of the period. :param end: The end of the period.
:return: The period specification. :return: The period specification.
""" """
if start is None and end is None:
return "-"
if end is None:
return __get_since_spec(start)
if start is None: if start is None:
return __get_until_spec(end) return "-" if end is None else __get_until_spec(end)
return __get_since_spec(start) if end is None else __get_spec(start, end)
def __get_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification with both start and end.
:param start: The start of the period.
:param end: The end of the period.
:return: The period specification.
"""
try: try:
return __get_year_spec(start, end) return __get_year_spec(start, end)
except ValueError: except ValueError:
+30 -29
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,20 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \ from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, balance_sheet_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, balance_sheet_url, \
income_statement_url
class ReportAccount: class ReportAccount:
@@ -121,9 +119,9 @@ class AccountCollector:
:return: The balances. :return: The balances.
""" """
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(x) for x in {"1", "2", "3"}] = [Account.base_code.startswith(x) for x in {"1", "2", "3"}]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.end is not None: if self.__period.end is not None:
@@ -135,16 +133,17 @@ class AccountCollector:
= sa.select(Account.id, Account.base_code, Account.no, = sa.select(Account.id, Account.base_code, Account.no,
balance_func)\ balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .where(*conditions)\
.group_by(Account.id, Account.base_code, Account.no)\ .group_by(Account.id, Account.base_code, Account.no)\
.having(balance_func != 0)\ .having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
account_balances: list[sa.Row] \ account_balances: list[sa.Row] \
= db.session.execute(select_balance).all() = db.session.execute(select_balance).all()
self.__all_accounts: list[Account] = Account.query\ self.__all_accounts: list[Account] = db.session.scalars(
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}), sa.select(Account)
.where(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351", Account.base_code == "3351",
Account.base_code == "3353")).all() Account.base_code == "3353"))).unique().all()
"""The accounts.""" """The accounts."""
account_by_id: dict[int, Account] \ account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts} = {x.id: x for x in self.__all_accounts}
@@ -180,7 +179,7 @@ class AccountCollector:
""" """
if self.__period.start is None: if self.__period.start is None:
return None return None
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntry.date < self.__period.start] JournalEntry.date < self.__period.start]
return self.__query_balance(conditions) return self.__query_balance(conditions)
@@ -199,7 +198,7 @@ class AccountCollector:
:return: The net income or loss for current period. :return: The net income or loss for current period.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
@@ -208,7 +207,7 @@ class AccountCollector:
return self.__query_balance(conditions) return self.__query_balance(conditions)
@staticmethod @staticmethod
def __query_balance(conditions: list[sa.BinaryExpression])\ def __query_balance(conditions: list[sa.ColumnElement[bool]])\
-> Decimal: -> Decimal:
"""Queries the balance. """Queries the balance.
@@ -221,7 +220,7 @@ class AccountCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select_balance: sa.Select = sa.select(balance_func)\ select_balance: sa.Select = sa.select(balance_func)\
.join(JournalEntry).join(Account).filter(*conditions) .join(JournalEntry).join(Account).where(*conditions)
return db.session.scalar(select_balance) return db.session.scalar(select_balance)
def __add_owner_s_equity(self, code: str, amount: Decimal | None, def __add_owner_s_equity(self, code: str, amount: Decimal | None,
@@ -385,11 +384,13 @@ class BalanceSheet(BaseReport):
balances: list[ReportAccount] = AccountCollector( balances: list[ReportAccount] = AccountCollector(
self.__currency, self.__period).accounts self.__currency, self.__period).accounts
titles: list[BaseAccount] = BaseAccount.query\ titles: list[BaseAccount] = db.session.scalars(
.filter(BaseAccount.code.in_({"1", "2", "3"})).all() sa.select(BaseAccount)
subtitles: list[BaseAccount] = BaseAccount.query\ .where(BaseAccount.code.in_({"1", "2", "3"}))).unique().all()
.filter(BaseAccount.code.in_({x.account.base_code[:2] subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
for x in balances})).all() subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
sections: dict[str, Section] = {x.code: Section(x) for x in titles} sections: dict[str, Section] = {x.code: Section(x) for x in titles}
subsections: dict[str, Subsection] = {x.code: Subsection(x) subsections: dict[str, Subsection] = {x.code: Subsection(x)
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -24,21 +24,19 @@ import sqlalchemy as sa
from flask import url_for, render_template, Response from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.period import Period, PeriodChooser from ..utils.option_link import OptionLink
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import income_expenses_url
period_spec from ... import db
from accounting.report.utils.option_link import OptionLink from ...locale import gettext
from accounting.report.utils.report_chooser import ReportChooser from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.report_type import ReportType from ...utils.current_account import CurrentAccount
from accounting.report.utils.urls import income_expenses_url from ...utils.pagination import Pagination
from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination
class ReportLineItem: class ReportLineItem:
@@ -119,12 +117,12 @@ class LineItemCollector:
balance_func: sa.Function = sa.func.sum(sa.case( balance_func: sa.Function = sa.func.sum(sa.case(
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select[tuple[Decimal]] = sa.Select(balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code, .where(JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition, self.__account_condition,
JournalEntry.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: Decimal | None = db.session.scalar(select)
if balance is None: if balance is None:
return None return None
line_item: ReportLineItem = ReportLineItem() line_item: ReportLineItem = ReportLineItem()
@@ -144,7 +142,7 @@ class LineItemCollector:
:return: The line items. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
self.__account_condition] self.__account_condition]
if self.__period.start is not None: if self.__period.start is not None:
@@ -152,12 +150,12 @@ class LineItemCollector:
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\ journal_entry_with_account: sa.Select = sa.Select(JournalEntry.id).\
join(JournalEntryLineItem).join(Account).filter(*conditions) join(JournalEntryLineItem).join(Account).where(*conditions)
return [ReportLineItem(x) return [ReportLineItem(x) for x in db.session.scalars(
for x in JournalEntryLineItem.query sa.select(JournalEntryLineItem)
.join(JournalEntry).join(Account) .join(JournalEntry).join(Account)
.filter(JournalEntryLineItem.journal_entry_id .where(JournalEntryLineItem.journal_entry_id
.in_(journal_entry_with_account), .in_(journal_entry_with_account),
JournalEntryLineItem.currency_code JournalEntryLineItem.currency_code
== self.__currency.code, == self.__currency.code,
@@ -167,10 +165,10 @@ class LineItemCollector:
JournalEntryLineItem.is_debit, JournalEntryLineItem.is_debit,
JournalEntryLineItem.no) JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.journal_entry))] selectinload(JournalEntryLineItem.journal_entry)))]
@property @property
def __account_condition(self) -> sa.BinaryExpression: def __account_condition(self) -> sa.ColumnElement[bool]:
if self.__account.code == CurrentAccount.CURRENT_AL_CODE: if self.__account.code == CurrentAccount.CURRENT_AL_CODE:
return CurrentAccount.sql_condition() return CurrentAccount.sql_condition()
return Account.id == self.__account.id return Account.id == self.__account.id
@@ -345,7 +343,7 @@ class PageParams(BasePageParams):
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\ .join(Account)\
.filter(JournalEntryLineItem.currency_code == self.currency.code, .where(JournalEntryLineItem.currency_code == self.currency.code,
CurrentAccount.sql_condition())\ CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id) .group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x), options.extend([OptionLink(str(x),
@@ -354,8 +352,10 @@ class PageParams(BasePageParams):
CurrentAccount(x), CurrentAccount(x),
self.period), self.period),
x.id == self.account.id) x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use)) for x in db.session.scalars(
.order_by(Account.base_code, Account.no).all()]) sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no))
.unique()])
return options return options
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,19 +22,18 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, BaseAccount, Account, JournalEntry, \ from ..utils.base_report import BaseReport
from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from ..utils.option_link import OptionLink
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ..utils.urls import ledger_url, income_statement_url
from ... import db
from ...locale import gettext
from ...models import Currency, BaseAccount, Account, JournalEntry, \
JournalEntryLineItem JournalEntryLineItem
from accounting.report.period import Period, PeriodChooser
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \
period_spec
from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, income_statement_url
class ReportAccount: class ReportAccount:
@@ -219,11 +218,14 @@ class IncomeStatement(BaseReport):
""" """
balances: list[ReportAccount] = self.__query_balances() balances: list[ReportAccount] = self.__query_balances()
titles: list[BaseAccount] = BaseAccount.query\ title_codes: set[str] = {"4", "5", "6", "7", "8", "9"}
.filter(BaseAccount.code.in_({"4", "5", "6", "7", "8", "9"})).all() titles: list[BaseAccount] = db.session.scalars(
subtitles: list[BaseAccount] = BaseAccount.query\ sa.select(BaseAccount)
.filter(BaseAccount.code.in_({x.account.base_code[:2] .where(BaseAccount.code.in_(title_codes))).unique().all()
for x in balances})).all() subtitle_codes: set[str] = {x.account.base_code[:2] for x in balances}
subtitles: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)
.where(BaseAccount.code.in_(subtitle_codes))).unique().all()
total_titles: dict[str, str] \ total_titles: dict[str, str] \
= {"4": gettext("Total Operating Revenue"), = {"4": gettext("Total Operating Revenue"),
@@ -254,9 +256,9 @@ class IncomeStatement(BaseReport):
:return: The balances. :return: The balances.
""" """
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.startswith(str(x)) for x in range(4, 10)] = [Account.base_code.startswith(str(x)) for x in range(4, 10)]
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
sa.or_(*sub_conditions)] sa.or_(*sub_conditions)]
if self.__period.start is not None: if self.__period.start is not None:
@@ -268,14 +270,15 @@ class IncomeStatement(BaseReport):
else_=JournalEntryLineItem.amount)).label("balance") else_=JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .where(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\ .having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all() balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query = {x.id: x for x in db.session.scalars(
.filter(Account.id.in_([x.id for x in balances])).all()} sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
return [ReportAccount(account=accounts[x.id], return [ReportAccount(account=accounts[x.id],
amount=x.balance, amount=x.balance,
url=ledger_url(self.__currency, url=ledger_url(self.__currency,
+18 -18
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -24,18 +24,17 @@ import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext from ..period import Period, PeriodChooser
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_page_params import BasePageParams
JournalEntryLineItem from ..utils.base_report import BaseReport
from accounting.report.period import Period, PeriodChooser from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import journal_url
period_spec from ... import db
from accounting.report.utils.report_chooser import ReportChooser from ...locale import gettext
from accounting.report.utils.report_type import ReportType from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.urls import journal_url from ...utils.pagination import Pagination
from accounting.utils.pagination import Pagination
class ReportLineItem: class ReportLineItem:
@@ -185,20 +184,21 @@ class Journal(BaseReport):
:return: The line items. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.ColumnElement[bool]] = []
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return JournalEntryLineItem.query.join(JournalEntry)\ return db.session.scalars(
.filter(*conditions)\ sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date, .order_by(JournalEntry.date,
JournalEntry.no, JournalEntry.no,
JournalEntryLineItem.is_debit.desc(), JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no)\ JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency), selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry))).all()
def csv(self) -> Response: def csv(self) -> Response:
"""Returns the report as CSV for download. """Returns the report as CSV for download.
+24 -25
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -24,20 +24,18 @@ import sqlalchemy as sa
from flask import url_for, render_template, Response from flask import url_for, render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.period import Period, PeriodChooser from ..utils.option_link import OptionLink
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import ledger_url
period_spec from ... import db
from accounting.report.utils.option_link import OptionLink from ...locale import gettext
from accounting.report.utils.report_chooser import ReportChooser from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.report_type import ReportType from ...utils.pagination import Pagination
from accounting.report.utils.urls import ledger_url
from accounting.utils.pagination import Pagination
class ReportLineItem: class ReportLineItem:
@@ -117,7 +115,7 @@ class LineItemCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\ select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(JournalEntryLineItem.currency_code == self.__currency.code, .where(JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id, JournalEntryLineItem.account_id == self.__account.id,
JournalEntry.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
@@ -139,22 +137,22 @@ class LineItemCollector:
:return: The line items. :return: The line items.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code, = [JournalEntryLineItem.currency_code == self.__currency.code,
JournalEntryLineItem.account_id == self.__account.id] JournalEntryLineItem.account_id == self.__account.id]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
if self.__period.end is not None: if self.__period.end is not None:
conditions.append(JournalEntry.date <= self.__period.end) conditions.append(JournalEntry.date <= self.__period.end)
return [ReportLineItem(x) for x in JournalEntryLineItem.query return [ReportLineItem(x) for x in db.session.scalars(
.join(JournalEntry) sa.select(JournalEntryLineItem).join(JournalEntry)
.filter(*conditions) .where(*conditions)
.order_by(JournalEntry.date, .order_by(JournalEntry.date,
JournalEntry.no, JournalEntry.no,
JournalEntryLineItem.is_debit.desc(), JournalEntryLineItem.is_debit.desc(),
JournalEntryLineItem.no) JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.journal_entry)) .options(selectinload(JournalEntryLineItem.journal_entry)))
.all()] .unique()]
def __get_total(self) -> ReportLineItem | None: def __get_total(self) -> ReportLineItem | None:
"""Composes the total line item. """Composes the total line item.
@@ -310,12 +308,13 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(JournalEntryLineItem.currency_code == self.currency.code)\ .where(JournalEntryLineItem.currency_code == self.currency.code)\
.group_by(JournalEntryLineItem.account_id) .group_by(JournalEntryLineItem.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period), return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id) x.id == self.account.id)
for x in Account.query.filter(Account.id.in_(in_use)) for x in db.session.scalars(
.order_by(Account.base_code, Account.no).all()] sa.select(Account).where(Account.id.in_(in_use))
.order_by(Account.base_code, Account.no)).unique()]
class Ledger(BaseReport): class Ledger(BaseReport):
+28 -26
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -24,17 +24,18 @@ import sqlalchemy as sa
from flask import Response, render_template, request from flask import Response, render_template, request
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext
from accounting.models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, JournalEntryLineItem
from accounting.report.utils.base_page_params import BasePageParams
from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download
from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType
from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows from .journal import get_csv_rows
from ..utils.base_page_params import BasePageParams
from ..utils.base_report import BaseReport
from ..utils.csv_export import csv_download
from ..utils.report_chooser import ReportChooser
from ..utils.report_type import ReportType
from ... import db
from ...locale import gettext
from ...models import Currency, CurrencyL10n, Account, AccountL10n, \
JournalEntry, JournalEntryLineItem
from ...utils.pagination import Pagination
from ...utils.query import parse_query_keywords
class LineItemCollector: class LineItemCollector:
@@ -53,9 +54,9 @@ class LineItemCollector:
keywords: list[str] = parse_query_keywords(request.args.get("q")) keywords: list[str] = parse_query_keywords(request.args.get("q"))
if len(keywords) == 0: if len(keywords) == 0:
return [] return []
conditions: list[sa.BinaryExpression] = [] conditions: list[sa.ColumnElement[bool]] = []
for k in keywords: for k in keywords:
sub_conditions: list[sa.BinaryExpression] \ sub_conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.description.icontains(k), = [JournalEntryLineItem.description.icontains(k),
JournalEntryLineItem.account_id.in_( JournalEntryLineItem.account_id.in_(
self.__get_account_condition(k)), self.__get_account_condition(k)),
@@ -69,15 +70,16 @@ class LineItemCollector:
except ArithmeticError: except ArithmeticError:
pass pass
conditions.append(sa.or_(*sub_conditions)) conditions.append(sa.or_(*sub_conditions))
return JournalEntryLineItem.query.join(JournalEntry)\ return db.session.scalars(
.filter(*conditions)\ sa.select(JournalEntryLineItem).join(JournalEntry)
.where(*conditions)
.order_by(JournalEntry.date, .order_by(JournalEntry.date,
JournalEntry.no, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.is_debit,
JournalEntryLineItem.no)\ JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.account), .options(selectinload(JournalEntryLineItem.account),
selectinload(JournalEntryLineItem.currency), selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry))).all()
@staticmethod @staticmethod
def __get_account_condition(k: str) -> sa.Select: def __get_account_condition(k: str) -> sa.Select:
@@ -86,20 +88,20 @@ class LineItemCollector:
:param k: The keyword. :param k: The keyword.
:return: The condition to filter the account. :return: The condition to filter the account.
""" """
code: sa.BinaryExpression = Account.base_code + "-" \ code: sa.ColumnElement[str] = Account.base_code + "-" \
+ sa.func.substr("000" + sa.cast(Account.no, sa.String), + sa.func.substr("000" + sa.cast(Account.no, sa.String),
sa.func.char_length(sa.cast(Account.no, sa.func.char_length(sa.cast(Account.no,
sa.String)) + 1) sa.String)) + 1)
select_l10n: sa.Select = sa.select(AccountL10n.account_id)\ select_l10n: sa.Select = sa.select(AccountL10n.account_id)\
.filter(AccountL10n.title.icontains(k)) .where(AccountL10n.title.icontains(k))
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [Account.base_code.contains(k), = [Account.base_code.contains(k),
Account.title_l10n.icontains(k), Account.title_l10n.icontains(k),
code.contains(k), code.contains(k),
Account.id.in_(select_l10n)] Account.id.in_(select_l10n)]
if k in gettext("Needs Offset"): if k in gettext("Needs Offset"):
conditions.append(Account.is_need_offset) conditions.append(Account.is_need_offset)
return sa.select(Account.id).filter(sa.or_(*conditions)) return sa.select(Account.id).where(sa.or_(*conditions))
@staticmethod @staticmethod
def __get_currency_condition(k: str) -> sa.Select: def __get_currency_condition(k: str) -> sa.Select:
@@ -109,9 +111,9 @@ class LineItemCollector:
:return: The condition to filter the currency. :return: The condition to filter the currency.
""" """
select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\ select_l10n: sa.Select = sa.select(CurrencyL10n.currency_code)\
.filter(CurrencyL10n.name.icontains(k)) .where(CurrencyL10n.name.icontains(k))
return sa.select(Currency.code).filter( return sa.select(Currency.code)\
sa.or_(Currency.code.icontains(k), .where(sa.or_(Currency.code.icontains(k),
Currency.name_l10n.icontains(k), Currency.name_l10n.icontains(k),
Currency.code.in_(select_l10n))) Currency.code.in_(select_l10n)))
@@ -122,7 +124,7 @@ class LineItemCollector:
:param k: The keyword. :param k: The keyword.
:return: The condition to filter the journal entry. :return: The condition to filter the journal entry.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntry.note.icontains(k)] = [JournalEntry.note.icontains(k)]
date: dt.datetime date: dt.datetime
try: try:
@@ -153,7 +155,7 @@ class LineItemCollector:
sa.extract("day", JournalEntry.date) == date.day)) sa.extract("day", JournalEntry.date) == date.day))
except ValueError: except ValueError:
pass pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions)) return sa.select(JournalEntry.id).where(sa.or_(*conditions))
class PageParams(BasePageParams): class PageParams(BasePageParams):
+17 -18
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,19 +22,17 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from flask import Response, render_template from flask import Response, render_template
from accounting import db from ..period import Period, PeriodChooser
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download, period_spec
from accounting.report.period import Period, PeriodChooser from ..utils.option_link import OptionLink
from accounting.report.utils.base_page_params import BasePageParams from ..utils.report_chooser import ReportChooser
from accounting.report.utils.base_report import BaseReport from ..utils.report_type import ReportType
from accounting.report.utils.csv_export import BaseCSVRow, csv_download, \ from ..utils.urls import ledger_url, trial_balance_url
period_spec from ... import db
from accounting.report.utils.option_link import OptionLink from ...locale import gettext
from accounting.report.utils.report_chooser import ReportChooser from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url, trial_balance_url
class ReportAccount: class ReportAccount:
@@ -178,7 +176,7 @@ class TrialBalance(BaseReport):
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.ColumnElement[bool]] \
= [JournalEntryLineItem.currency_code == self.__currency.code] = [JournalEntryLineItem.currency_code == self.__currency.code]
if self.__period.start is not None: if self.__period.start is not None:
conditions.append(JournalEntry.date >= self.__period.start) conditions.append(JournalEntry.date >= self.__period.start)
@@ -189,14 +187,15 @@ class TrialBalance(BaseReport):
else_=-JournalEntryLineItem.amount)).label("balance") else_=-JournalEntryLineItem.amount)).label("balance")
select_balances: sa.Select = sa.select(Account.id, balance_func)\ select_balances: sa.Select = sa.select(Account.id, balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(*conditions)\ .where(*conditions)\
.group_by(Account.id)\ .group_by(Account.id)\
.having(balance_func != 0)\ .having(balance_func != 0)\
.order_by(Account.base_code, Account.no) .order_by(Account.base_code, Account.no)
balances: list[sa.Row] = db.session.execute(select_balances).all() balances: list[sa.Row] = db.session.execute(select_balances).all()
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query = {x.id: x for x in db.session.scalars(
.filter(Account.id.in_([x.id for x in balances])).all()} sa.select(Account)
.where(Account.id.in_([x.id for x in balances]))).unique()}
self.__accounts = [ReportAccount(account=accounts[x.id], self.__accounts = [ReportAccount(account=accounts[x.id],
amount=x.balance, amount=x.balance,
url=ledger_url(self.__currency, url=ledger_url(self.__currency,
+20 -19
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,22 +20,22 @@
import datetime as dt import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa
from flask import render_template, Response from flask import render_template, Response
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntry, \ from ..utils.base_report import BaseReport
JournalEntryLineItem from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_page_params import BasePageParams from ..utils.option_link import OptionLink
from accounting.report.utils.base_report import BaseReport from ..utils.report_chooser import ReportChooser
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.report_type import ReportType
from accounting.report.utils.option_link import OptionLink from ..utils.unapplied import get_accounts_with_unapplied, get_net_balances
from accounting.report.utils.report_chooser import ReportChooser from ..utils.urls import unapplied_url
from accounting.report.utils.report_type import ReportType from ... import db
from accounting.report.utils.unapplied import get_accounts_with_unapplied, \ from ...locale import gettext
get_net_balances from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
from accounting.report.utils.urls import unapplied_url from ...utils.pagination import Pagination
from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@@ -178,13 +178,14 @@ class UnappliedOriginalLineItems(BaseReport):
""" """
net_balances: dict[int, Decimal | None] \ net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account) = get_net_balances(self.__currency, self.__account)
line_items: list[JournalEntryLineItem] = JournalEntryLineItem.query \ line_items: list[JournalEntryLineItem] = db.session.scalars(
.join(Account).join(JournalEntry) \ sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.filter(JournalEntryLineItem.id.in_(net_balances)) \ .where(JournalEntryLineItem.id.in_(net_balances))
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \ JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency), .options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
for line_item in line_items: for line_item in line_items:
line_item.net_balance = line_item.amount \ line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \ if net_balances[line_item.id] is None \
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,16 +22,16 @@ from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account from ..utils.base_report import BaseReport
from accounting.report.utils.base_page_params import BasePageParams from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_report import BaseReport from ..utils.option_link import OptionLink
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.report_chooser import ReportChooser
from accounting.report.utils.option_link import OptionLink from ..utils.report_type import ReportType
from accounting.report.utils.report_chooser import ReportChooser from ..utils.unapplied import get_accounts_with_unapplied
from accounting.report.utils.report_type import ReportType from ..utils.urls import unapplied_url
from accounting.report.utils.unapplied import get_accounts_with_unapplied from ...locale import gettext
from accounting.report.utils.urls import unapplied_url from ...models import Currency, Account
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
+13 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,18 +23,18 @@ from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from flask_babel import LazyString from flask_babel import LazyString
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account, JournalEntryLineItem from ..utils.base_report import BaseReport
from accounting.report.utils.base_page_params import BasePageParams from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_report import BaseReport from ..utils.offset_matcher import OffsetMatcher, OffsetPair
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.option_link import OptionLink
from accounting.report.utils.offset_matcher import OffsetMatcher, OffsetPair from ..utils.report_chooser import ReportChooser
from accounting.report.utils.option_link import OptionLink from ..utils.report_type import ReportType
from accounting.report.utils.report_chooser import ReportChooser from ..utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.report_type import ReportType from ..utils.urls import unmatched_url
from accounting.report.utils.unmatched import get_accounts_with_unmatched from ...locale import gettext
from accounting.report.utils.urls import unmatched_url from ...models import Currency, Account, JournalEntryLineItem
from accounting.utils.pagination import Pagination from ...utils.pagination import Pagination
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/17
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -22,16 +22,16 @@ from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
from accounting.locale import gettext from ..utils.base_page_params import BasePageParams
from accounting.models import Currency, Account from ..utils.base_report import BaseReport
from accounting.report.utils.base_page_params import BasePageParams from ..utils.csv_export import BaseCSVRow, csv_download
from accounting.report.utils.base_report import BaseReport from ..utils.option_link import OptionLink
from accounting.report.utils.csv_export import BaseCSVRow, csv_download from ..utils.report_chooser import ReportChooser
from accounting.report.utils.option_link import OptionLink from ..utils.report_type import ReportType
from accounting.report.utils.report_chooser import ReportChooser from ..utils.unmatched import get_accounts_with_unmatched
from accounting.report.utils.report_type import ReportType from ..utils.urls import unmatched_url
from accounting.report.utils.unmatched import get_accounts_with_unmatched from ...locale import gettext
from accounting.report.utils.urls import unmatched_url from ...models import Currency, Account
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,7 +19,7 @@
""" """
from decimal import Decimal from decimal import Decimal
from accounting.template_filters import format_amount as core_format_amount from ..template_filters import format_amount as core_format_amount
def format_amount(value: Decimal | None) -> str | None: def format_amount(value: Decimal | None) -> str | None:
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/6
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,18 +19,17 @@
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable from collections.abc import Callable
from typing import Type
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \ from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse urlunparse
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
from accounting import db
from accounting.models import Currency, JournalEntryLineItem
from accounting.utils.journal_entry_types import JournalEntryType
from .option_link import OptionLink from .option_link import OptionLink
from .report_chooser import ReportChooser from .report_chooser import ReportChooser
from ... import db
from ...models import Currency, JournalEntryLineItem
from ...utils.journal_entry_types import JournalEntryType
class BasePageParams(ABC): class BasePageParams(ABC):
@@ -53,7 +52,7 @@ class BasePageParams(ABC):
""" """
@property @property
def journal_entry_types(self) -> Type[JournalEntryType]: def journal_entry_types(self) -> type[JournalEntryType]:
"""Returns the journal entry types. """Returns the journal entry types.
:return: The journal entry types. :return: The journal entry types.
@@ -85,5 +84,6 @@ class BasePageParams(ABC):
sa.select(JournalEntryLineItem.currency_code) sa.select(JournalEntryLineItem.currency_code)
.group_by(JournalEntryLineItem.currency_code)).all()) .group_by(JournalEntryLineItem.currency_code)).all())
return [OptionLink(str(x), get_url(x), x.code == active_currency.code) return [OptionLink(str(x), get_url(x), x.code == active_currency.code)
for x in Currency.query.filter(Currency.code.in_(in_use)) for x in db.session.scalars(
.order_by(Currency.code).all()] sa.select(Currency).where(Currency.code.in_(in_use))
.order_by(Currency.code)).unique()]
+15 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -26,7 +26,7 @@ from urllib.parse import quote
from flask import Response from flask import Response
from accounting.report.period import Period from ..period import Period
class BaseCSVRow(ABC): class BaseCSVRow(ABC):
@@ -66,15 +66,19 @@ def period_spec(period: Period) -> str:
""" """
start: str | None = __get_start_str(period.start) start: str | None = __get_start_str(period.start)
end: str | None = __get_end_str(period.end) end: str | None = __get_end_str(period.end)
if period.start is None and period.end is None: if start is None:
return "all-time" return "all-time" if end is None else f"until-{end}"
if start == end: return f"since-{start}" if end is None else __get_spec(start, end)
return start
if period.start is None:
return f"until-{end}" def __get_spec(start: str, end: str) -> str:
if period.end is None: """Constructs the period specification with both start and end
return f"since-{start}"
return f"{start}-{end}" :param start: The start date.
:param end: The end date.
:return: The period specification.
"""
return start if start == end else f"{start}-{end}"
def __get_start_str(start: dt.date | None) -> str | None: def __get_start_str(start: dt.date | None) -> str | None:
+14 -13
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,10 +23,10 @@ import sqlalchemy as sa
from flask_babel import LazyString from flask_babel import LazyString
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from accounting.locale import lazy_gettext from ..utils.unapplied import get_net_balances
from accounting.models import Currency, Account, JournalEntry, \ from ... import db
JournalEntryLineItem from ...locale import lazy_gettext
from accounting.report.utils.unapplied import get_net_balances from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
class OffsetPair: class OffsetPair:
@@ -54,7 +54,7 @@ class OffsetMatcher:
:param currency: The currency. :param currency: The currency.
:param account: The account. :param account: The account.
""" """
self.__currency: Account = currency self.__currency: Currency = currency
"""The currency.""" """The currency."""
self.__account: Account = account self.__account: Account = account
"""The account.""" """The account."""
@@ -105,7 +105,7 @@ class OffsetMatcher:
""" """
net_balances: dict[int, Decimal | None] \ net_balances: dict[int, Decimal | None] \
= get_net_balances(self.__currency, self.__account) = get_net_balances(self.__currency, self.__account)
unmatched_offset_condition: sa.BinaryExpression \ unmatched_offset_condition: sa.ColumnElement[bool] \
= sa.and_(Account.id == self.__account.id, = sa.and_(Account.id == self.__account.id,
JournalEntryLineItem.currency_code JournalEntryLineItem.currency_code
== self.__currency.code, == self.__currency.code,
@@ -114,14 +114,15 @@ class OffsetMatcher:
JournalEntryLineItem.is_debit), JournalEntryLineItem.is_debit),
sa.and_(Account.base_code.startswith("1"), sa.and_(Account.base_code.startswith("1"),
sa.not_(JournalEntryLineItem.is_debit)))) sa.not_(JournalEntryLineItem.is_debit))))
self.line_items = JournalEntryLineItem.query \ self.line_items = db.session.scalars(
.join(Account).join(JournalEntry) \ sa.select(JournalEntryLineItem).join(Account).join(JournalEntry)
.filter(sa.or_(JournalEntryLineItem.id.in_(net_balances), .where(sa.or_(JournalEntryLineItem.id.in_(net_balances),
unmatched_offset_condition)) \ unmatched_offset_condition))
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
JournalEntryLineItem.is_debit, JournalEntryLineItem.no) \ JournalEntryLineItem.is_debit, JournalEntryLineItem.no)
.options(selectinload(JournalEntryLineItem.currency), .options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry)))\
.unique().all()
for line_item in self.line_items: for line_item in self.line_items:
line_item.is_offset = line_item.id not in net_balances line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items if not x.is_offset] self.unapplied = [x for x in self.line_items if not x.is_offset]
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/4
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -25,18 +25,18 @@ from collections.abc import Iterator
from flask_babel import LazyString from flask_babel import LazyString
from accounting import db
from accounting.locale import gettext
from accounting.models import Currency, Account
from accounting.report.period import Period, get_period
from accounting.template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount
from accounting.utils.permission import can_edit
from .option_link import OptionLink from .option_link import OptionLink
from .report_type import ReportType from .report_type import ReportType
from .urls import journal_url, ledger_url, income_expenses_url, \ from .urls import journal_url, ledger_url, income_expenses_url, \
trial_balance_url, income_statement_url, balance_sheet_url, \ trial_balance_url, income_statement_url, balance_sheet_url, \
unapplied_url, unmatched_url unapplied_url, unmatched_url
from ..period import Period, get_period
from ... import db
from ...locale import gettext
from ...models import Currency, Account
from ...template_globals import default_currency_code
from ...utils.current_account import CurrentAccount
from ...utils.permission import can_edit
class ReportChooser: class ReportChooser:
+9 -9
View File
@@ -22,21 +22,21 @@ from enum import Enum
class ReportType(Enum): class ReportType(Enum):
"""The report types.""" """The report types."""
JOURNAL: str = "journal" JOURNAL = "journal"
"""The journal.""" """The journal."""
LEDGER: str = "ledger" LEDGER = "ledger"
"""The ledger.""" """The ledger."""
INCOME_EXPENSES: str = "income-expenses" INCOME_EXPENSES = "income-expenses"
"""The income and expenses log.""" """The income and expenses log."""
TRIAL_BALANCE: str = "trial-balance" TRIAL_BALANCE = "trial-balance"
"""The trial balance.""" """The trial balance."""
INCOME_STATEMENT: str = "income-statement" INCOME_STATEMENT = "income-statement"
"""The income statement.""" """The income statement."""
BALANCE_SHEET: str = "balance-sheet" BALANCE_SHEET = "balance-sheet"
"""The balance sheet.""" """The balance sheet."""
UNAPPLIED: str = "unapplied" UNAPPLIED = "unapplied"
"""The unapplied original line items.""" """The unapplied original line items."""
UNMATCHED: str = "unmatched" UNMATCHED = "unmatched"
"""The unmatched offsets.""" """The unmatched offsets."""
SEARCH: str = "search" SEARCH = "search"
"""The search.""" """The search."""
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/7
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,10 +21,9 @@ from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from ... import db
from accounting.models import Currency, Account, JournalEntry, \ from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
JournalEntryLineItem from ...utils.offset_alias import offset_alias
from accounting.utils.offset_alias import offset_alias
def get_accounts_with_unapplied(currency: Currency) -> list[Account]: def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
@@ -46,7 +45,7 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(Account.is_need_offset, .where(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code, JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)), sa.not_(JournalEntryLineItem.is_debit)),
@@ -59,13 +58,14 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
= sa.func.count(JournalEntryLineItem.id).label("count") = sa.func.count(JournalEntryLineItem.id).label("count")
select: sa.Select = sa.select(Account.id, count_func)\ select: sa.Select = sa.select(Account.id, count_func)\
.join(JournalEntryLineItem, isouter=True)\ .join(JournalEntryLineItem, isouter=True)\
.filter(JournalEntryLineItem.id.in_(select_unapplied))\ .where(JournalEntryLineItem.id.in_(select_unapplied))\
.group_by(Account.id)\ .group_by(Account.id)\
.having(count_func > 0) .having(count_func > 0)
counts: dict[int, int] \ counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)} = {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\ accounts: list[Account] = db.session.scalars(
.order_by(Account.base_code, Account.no).all() sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts: for account in accounts:
account.count = counts[account.id] account.count = counts[account.id]
return accounts return accounts
@@ -92,7 +92,7 @@ def get_net_balances(currency: Currency, account: Account) \
.join(offset, .join(offset,
JournalEntryLineItem.id == offset.c.original_line_item_id, JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \ isouter=True) \
.filter(Account.id == account.id, .where(Account.id == account.id,
JournalEntryLineItem.currency_code == currency.code, JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)), sa.not_(JournalEntryLineItem.is_debit)),
@@ -101,4 +101,4 @@ def get_net_balances(currency: Currency, account: Account) \
.group_by(JournalEntryLineItem.id) \ .group_by(JournalEntryLineItem.id) \
.having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0)) .having(sa.or_(sa.func.count(offset.c.id) == 0, net_balance != 0))
return {x.id: x.net_balance return {x.id: x.net_balance
for x in db.session.execute(select_net_balances).all()} for x in db.session.execute(select_net_balances)}
+7 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/8
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,9 +19,8 @@
""" """
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from ... import db
from accounting.models import Currency, Account, JournalEntry, \ from ...models import Currency, Account, JournalEntry, JournalEntryLineItem
JournalEntryLineItem
def get_accounts_with_unmatched(currency: Currency) -> list[Account]: def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
@@ -36,7 +35,7 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
select: sa.Select = sa.select(Account.id, count_func)\ select: sa.Select = sa.select(Account.id, count_func)\
.select_from(Account)\ .select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\ .join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset, .where(Account.is_need_offset,
JournalEntryLineItem.currency_code == currency.code, JournalEntryLineItem.currency_code == currency.code,
JournalEntryLineItem.original_line_item_id.is_(None), JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
@@ -47,8 +46,9 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
.having(count_func > 0) .having(count_func > 0)
counts: dict[int, int] \ counts: dict[int, int] \
= {x.id: x.count for x in db.session.execute(select)} = {x.id: x.count for x in db.session.execute(select)}
accounts: list[Account] = Account.query.filter(Account.id.in_(counts))\ accounts: list[Account] = db.session.scalars(
.order_by(Account.base_code, Account.no).all() sa.select(Account).where(Account.id.in_(counts))
.order_by(Account.base_code, Account.no)).unique().all()
for account in accounts: for account in accounts:
account.count = counts[account.id] account.count = counts[account.id]
return accounts return accounts
+6 -6
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/9
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,11 +19,11 @@
""" """
from flask import url_for from flask import url_for
from accounting.models import Currency, Account from ...models import Currency, Account
from accounting.report.period import Period from ...report.period import Period
from accounting.template_globals import default_currency_code from ...template_globals import default_currency_code
from accounting.utils.current_account import CurrentAccount from ...utils.current_account import CurrentAccount
from accounting.utils.options import options from ...utils.options import options
def journal_url(period: Period) \ def journal_url(period: Period) \
+11 -11
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,15 +19,6 @@
""" """
from flask import Blueprint, request, Response, redirect, flash from flask import Blueprint, request, Response, redirect, flash
from accounting import db
from accounting.locale import lazy_gettext
from accounting.models import Currency, Account
from accounting.template_globals import default_currency_code
from accounting.utils.cast import s
from accounting.utils.current_account import CurrentAccount
from accounting.utils.next_uri import or_next
from accounting.utils.options import options
from accounting.utils.permission import has_permission, can_view, can_edit
from .period import Period, get_period from .period import Period, get_period
from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \ from .reports import Journal, Ledger, IncomeExpenses, TrialBalance, \
IncomeStatement, BalanceSheet, Search IncomeStatement, BalanceSheet, Search
@@ -38,6 +29,15 @@ from .reports.unmatched_accounts import AccountsWithUnmatchedOffsets
from .template_filters import format_amount from .template_filters import format_amount
from .utils.offset_matcher import OffsetMatcher from .utils.offset_matcher import OffsetMatcher
from .utils.urls import unmatched_url from .utils.urls import unmatched_url
from .. import db
from ..locale import lazy_gettext
from ..models import Currency, Account
from ..template_globals import default_currency_code
from ..utils.cast import s
from ..utils.current_account import CurrentAccount
from ..utils.next_uri import or_next
from ..utils.options import options
from ..utils.permission import has_permission, can_view, can_edit
bp: Blueprint = Blueprint("accounting-report", __name__) bp: Blueprint = Blueprint("accounting-report", __name__)
"""The view blueprint for the reports.""" """The view blueprint for the reports."""
@@ -391,7 +391,7 @@ def get_unmatched(currency: Currency, account: Account) -> str | Response:
@bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>", @bp.post("match-offsets/<currency:currency>/<needOffsetAccount:account>",
endpoint="match-offsets") endpoint="match-offsets")
@has_permission(can_edit) @has_permission(can_edit)
def match_offsets(currency: Currency, account: Account) -> redirect: def match_offsets(currency: Currency, account: Account) -> Response:
"""Matches the original line items with their offsets. """Matches the original line items with their offsets.
:return: Redirection to the view of the unmatched offsets. :return: Redirection to the view of the unmatched offsets.
+1
View File
@@ -339,6 +339,7 @@ class BaseAccountSelector {
/** /**
* A base account option. * A base account option.
* *
* @private
*/ */
class BaseAccountOption { class BaseAccountOption {
@@ -344,6 +344,7 @@ class DescriptionEditor {
/** /**
* An account option in the description editor. * An account option in the description editor.
* *
* @private
*/ */
class DescriptionEditorAccount extends JournalEntryAccount { class DescriptionEditorAccount extends JournalEntryAccount {
@@ -415,6 +416,7 @@ class DescriptionEditorAccount extends JournalEntryAccount {
/** /**
* A suggested account. * A suggested account.
* *
* @private
*/ */
class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount { class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
@@ -432,6 +434,7 @@ class DescriptionEditorSuggestedAccount extends DescriptionEditorAccount {
/** /**
* The account option that is specified or confirmed by the user. * The account option that is specified or confirmed by the user.
* *
* @private
*/ */
class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount { class DescriptionEditorConfirmedAccount extends DescriptionEditorAccount {
@@ -25,6 +25,7 @@
/** /**
* The account selector. * The account selector.
* *
* @private
*/ */
class JournalEntryAccountSelector { class JournalEntryAccountSelector {
@@ -89,7 +90,7 @@ class JournalEntryAccountSelector {
* @param debitCredit {string} either "debit" or "credit" * @param debitCredit {string} either "debit" or "credit"
*/ */
constructor(lineItemEditor, debitCredit) { constructor(lineItemEditor, debitCredit) {
this.lineItemEditor = lineItemEditor this.lineItemEditor = lineItemEditor;
this.#debitCredit = debitCredit; this.#debitCredit = debitCredit;
const prefix = `accounting-account-selector-${debitCredit}`; const prefix = `accounting-account-selector-${debitCredit}`;
this.#query = document.getElementById(`${prefix}-query`); this.#query = document.getElementById(`${prefix}-query`);
@@ -187,6 +188,7 @@ class JournalEntryAccountSelector {
/** /**
* An account option * An account option
* *
* @private
*/ */
class JournalEntryAccountOption { class JournalEntryAccountOption {
+5
View File
@@ -175,6 +175,7 @@ class OptionForm {
/** /**
* The recurring expenses or incomes sub-form. * The recurring expenses or incomes sub-form.
* *
* @private
*/ */
class RecurringExpenseIncomeSubForm { class RecurringExpenseIncomeSubForm {
@@ -350,6 +351,7 @@ class RecurringExpenseIncomeSubForm {
/** /**
* A recurring item sub-form. * A recurring item sub-form.
* *
* @private
*/ */
class RecurringItemSubForm { class RecurringItemSubForm {
@@ -551,6 +553,7 @@ class RecurringItemSubForm {
/** /**
* The recurring item editor. * The recurring item editor.
* *
* @private
*/ */
class RecurringItemEditor { class RecurringItemEditor {
@@ -829,6 +832,7 @@ class RecurringItemEditor {
/** /**
* The account selector for the recurring item editor. * The account selector for the recurring item editor.
* *
* @private
*/ */
class RecurringAccountSelector { class RecurringAccountSelector {
@@ -941,6 +945,7 @@ class RecurringAccountSelector {
/** /**
* An account in the account selector for the recurring item editor. * An account in the account selector for the recurring item editor.
* *
* @private
*/ */
class RecurringAccount { class RecurringAccount {
@@ -25,6 +25,7 @@
/** /**
* The original line item selector. * The original line item selector.
* *
* @private
*/ */
class OriginalLineItemSelector { class OriginalLineItemSelector {
@@ -190,6 +191,7 @@ class OriginalLineItemSelector {
/** /**
* An original line item. * An original line item.
* *
* @private
*/ */
class OriginalLineItem { class OriginalLineItem {
+5 -4
View File
@@ -2,7 +2,7 @@
* period-chooser.js: The JavaScript for the period chooser * period-chooser.js: The JavaScript for the period chooser
*/ */
/* Copyright (c) 2023 imacat. /* Copyright (c) 2023-2026 imacat.
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@@ -30,6 +30,7 @@ document.addEventListener("DOMContentLoaded", () => {
/** /**
* The period chooser. * The period chooser.
* *
* @private
*/ */
class PeriodChooser { class PeriodChooser {
@@ -320,7 +321,7 @@ class CustomTab extends TabPlane {
* The confirm button * The confirm button
* @type {HTMLButtonElement} * @type {HTMLButtonElement}
*/ */
#conform; #confirm;
/** /**
* Constructs a tab plane. * Constructs a tab plane.
@@ -333,7 +334,7 @@ class CustomTab extends TabPlane {
this.#startError = document.getElementById(`${this.prefix}-start-error`); this.#startError = document.getElementById(`${this.prefix}-start-error`);
this.#end = document.getElementById(`${this.prefix}-end`); this.#end = document.getElementById(`${this.prefix}-end`);
this.#endError = document.getElementById(`${this.prefix}-end-error`); this.#endError = document.getElementById(`${this.prefix}-end-error`);
this.#conform = document.getElementById(`${this.prefix}-confirm`); this.#confirm = document.getElementById(`${this.prefix}-confirm`);
if (this.#start !== null) { if (this.#start !== null) {
this.#start.onchange = () => { this.#start.onchange = () => {
if (this.#validateStart()) { if (this.#validateStart()) {
@@ -345,7 +346,7 @@ class CustomTab extends TabPlane {
this.#start.max = this.#end.value; this.#start.max = this.#end.value;
} }
}; };
this.#conform.onclick = () => { this.#confirm.onclick = () => {
let isValid = true; let isValid = true;
isValid = this.#validateStart() && isValid; isValid = this.#validateStart() && isValid;
isValid = this.#validateEnd() && isValid; isValid = this.#validateEnd() && isValid;
+3 -3
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/25
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,8 +23,8 @@ from typing import Any
from flask_babel import get_locale from flask_babel import get_locale
from accounting.locale import gettext from .locale import gettext
from accounting.utils.timezone import get_tz_today from .utils.timezone import get_tz_today
def format_amount(value: Decimal | None) -> str | None: def format_amount(value: Decimal | None) -> str | None:
+8 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/3
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -17,8 +17,11 @@
"""The template globals. """The template globals.
""" """
from accounting.models import Currency import sqlalchemy as sa
from accounting.utils.options import options
from . import db
from .models import Currency
from .utils.options import options
def currency_options() -> list[Currency]: def currency_options() -> list[Currency]:
@@ -26,7 +29,8 @@ def currency_options() -> list[Currency]:
:return: The currency options. :return: The currency options.
""" """
return Currency.query.order_by(Currency.code).all() return db.session.scalars(
sa.select(Currency).order_by(Currency.code)).unique().all()
def default_currency_code() -> str: def default_currency_code() -> str:
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
detail.html: The cash disbursement journal entry detail detail.html: The cash disbursement journal entry detail
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -21,7 +21,7 @@ First written: 2023/2/26
#} #}
{% extends "accounting/journal-entry/include/detail.html" %} {% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %} {% block as_transfer %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-table-columns"></i> <i class="fa-solid fa-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
detail.html: The account detail detail.html: The account detail
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -41,7 +41,7 @@ First written: 2023/2/26
<span class="d-none d-md-inline">{{ A_("Order") }}</span> <span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a> </a>
{% if accounting_can_edit() %} {% if accounting_can_edit() %}
{% block as_trasfer %}{% endblock %} {% block as_transfer %}{% endblock %}
{% if obj.can_delete %} {% if obj.can_delete %}
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal"> <button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#accounting-delete-modal">
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
detail.html: The cash receipt journal entry detail detail.html: The cash receipt journal entry detail
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -21,7 +21,7 @@ First written: 2023/2/26
#} #}
{% extends "accounting/journal-entry/include/detail.html" %} {% extends "accounting/journal-entry/include/detail.html" %}
{% block as_trasfer %} {% block as_transfer %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
<i class="fa-solid fa-table-columns"></i> <i class="fa-solid fa-table-columns"></i>
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span> <span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
@@ -41,7 +41,7 @@ First written: 2023/2/26
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total"> <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.credit_total|accounting_format_amount }}</div>
</div> </div>
</li> </li>
</ul> </ul>
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
detail.html: The transfer journal entry detail detail.html: The transfer journal entry detail
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -53,7 +53,7 @@ First written: 2023/2/26
<li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total"> <li class="list-group-item accounting-journal-entry-line-item accounting-journal-entry-line-item-total">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<div>{{ A_("Total") }}</div> <div>{{ A_("Total") }}</div>
<div>{{ currency.debit_total|accounting_format_amount }}</div> <div>{{ currency.credit_total|accounting_format_amount }}</div>
</div> </div>
</li> </li>
</ul> </ul>
@@ -1,8 +1,8 @@
{# {#
The Mia! Accounting Project The Mia! Accounting Project
income-statement.html: The income statement balance-sheet.html: The balance sheet
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
unapplied-accounts.html: The account list with unapplied original line items unapplied-accounts.html: The account list with unapplied original line items
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@ First written: 2023/4/8
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unapplied Items") }}{% else %}{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unapplied Items") }}{% else %}{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
unapplied.html: The unapplied original line items unapplied.html: The unapplied original line items
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@ First written: 2023/4/7
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
unmatched-accounts.html: The account list with unmatched offsets unmatched-accounts.html: The account list with unmatched offsets
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@ First written: 2023/4/17
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unmatched Offsets") }}{% else %}{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unmatched Offsets") }}{% else %}{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
@@ -2,7 +2,7 @@
The Mia! Accounting Project The Mia! Accounting Project
unmatched.html: The unmatched offsets unmatched.html: The unmatched offsets
Copyright (c) 2023 imacat. Copyright (c) 2023-2026 imacat.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@@ -23,7 +23,6 @@ First written: 2023/4/17
{% block accounting_scripts %} {% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script> <script src="{{ url_for("accounting.static", filename="js/material-fab-speed-dial.js") }}"></script>
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %} {% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %} {% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
+11 -9
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,8 +21,9 @@ from typing import Self
import sqlalchemy as sa import sqlalchemy as sa
from accounting.locale import gettext from .. import db
from accounting.models import Account from ..locale import gettext
from ..models import Account
class CurrentAccount: class CurrentAccount:
@@ -59,7 +60,7 @@ class CurrentAccount:
:return: The pseudo account for all current assets and liabilities. :return: The pseudo account for all current assets and liabilities.
""" """
account: cls = cls() account: Self = cls()
account.id = 0 account.id = 0
account.code = cls.CURRENT_AL_CODE account.code = cls.CURRENT_AL_CODE
account.title = gettext("current assets and liabilities") account.title = gettext("current assets and liabilities")
@@ -73,14 +74,15 @@ class CurrentAccount:
:return: The current assets and liabilities accounts. :return: The current assets and liabilities accounts.
""" """
accounts: list[cls] = [cls.current_assets_and_liabilities()] accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x) accounts.extend([cls(x)
for x in Account.query for x in db.session.scalars(
.filter(cls.sql_condition()) sa.select(Account).where(cls.sql_condition())
.order_by(Account.base_code, Account.no)]) .order_by(Account.base_code, Account.no))
.unique()])
return accounts return accounts
@classmethod @classmethod
def sql_condition(cls) -> sa.BinaryExpression: def sql_condition(cls) -> sa.ColumnElement[bool]:
"""Returns the SQL condition for the current assets and liabilities """Returns the SQL condition for the current assets and liabilities
accounts. accounts.
+3 -3
View File
@@ -22,9 +22,9 @@ from enum import Enum
class JournalEntryType(Enum): class JournalEntryType(Enum):
"""The journal entry types.""" """The journal entry types."""
CASH_RECEIPT: str = "receipt" CASH_RECEIPT = "receipt"
"""The cash receipt journal entry.""" """The cash receipt journal entry."""
CASH_DISBURSEMENT: str = "disbursement" CASH_DISBURSEMENT = "disbursement"
"""The cash disbursement journal entry.""" """The cash disbursement journal entry."""
TRANSFER: str = "transfer" TRANSFER = "transfer"
"""The transfer journal entry.""" """The transfer journal entry."""
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/15
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,7 +21,7 @@ from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
from accounting.models import JournalEntryLineItem from ..models import JournalEntryLineItem
def offset_alias() -> sa.Alias: def offset_alias() -> sa.Alias:
+5 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/3/22
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -21,10 +21,10 @@ import json
import sqlalchemy as sa import sqlalchemy as sa
from accounting import db from .current_account import CurrentAccount
from accounting.models import Option, Account, Currency from .user import get_current_user_pk
from accounting.utils.current_account import CurrentAccount from .. import db
from accounting.utils.user import get_current_user_pk from ..models import Option, Account, Currency
class RecurringItem: class RecurringItem:
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -25,7 +25,7 @@ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
from flask import request from flask import request
from werkzeug.routing import RequestRedirect from werkzeug.routing import RequestRedirect
from accounting.locale import pgettext from ..locale import pgettext
class Link: class Link:
+2 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -23,7 +23,7 @@ from collections.abc import Callable
from flask import abort, Blueprint, Response from flask import abort, Blueprint, Response
from accounting.utils.user import get_current_user, UserUtilityInterface from .user import get_current_user, UserUtilityInterface
def has_permission(rule: Callable[[], bool]) -> Callable: def has_permission(rule: Callable[[], bool]) -> Callable:
+3 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -35,7 +35,8 @@ def parse_query_keywords(q: str | None) -> list[str]:
return [] return []
keywords: list[str] = [] keywords: list[str] = []
while True: while True:
m: re.Match m: re.Match[str] | None
assert q is not None
m = re.match(r"\"([^\"]+)\"\s+(.+)$", q) m = re.match(r"\"([^\"]+)\"\s+(.+)$", q)
if m is not None: if m is not None:
keywords.append(m.group(1)) keywords.append(m.group(1))
+4 -4
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,12 +20,12 @@ This module should not import any other module from the application.
""" """
from secrets import randbelow from secrets import randbelow
from typing import Type
from accounting import db from .. import db
from ..utils.user import base_cls
def new_id(cls: Type[db.Model]): def new_id(cls: type[base_cls]):
"""Generates and returns a new, unused random ID for the data model. """Generates and returns a new, unused random ID for the data model.
:param cls: The data model. :param cls: The data model.
+1 -2
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2024/6/4 # Author: imacat@mail.imacat.idv.tw (imacat), 2024/6/4
# Copyright (c) 2024 imacat. # Copyright (c) 2024-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,7 +19,6 @@
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import datetime as dt import datetime as dt
import pytz import pytz
+1 -1
View File
@@ -48,7 +48,7 @@ def title_case(s: str) -> str:
return re.sub(r"\w+", __cap_word, s) return re.sub(r"\w+", __cap_word, s)
def __cap_word(m: re.Match) -> str: def __cap_word(m: re.Match[str]) -> str:
"""Capitalize a matched title word. """Capitalize a matched title word.
:param m: The matched title word. :param m: The matched title word.
+17 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Project. # The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -20,14 +20,13 @@ This module should not import any other module from the application.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Type
import sqlalchemy as sa import sqlalchemy as sa
from flask import g, Response from flask import g, Response
from flask_sqlalchemy.model import Model from sqlalchemy.orm import DeclarativeBase
class UserUtilityInterface[T: Model](ABC): class UserUtilityInterface[T: DeclarativeBase](ABC):
"""The interface for the user utilities.""" """The interface for the user utilities."""
@abstractmethod @abstractmethod
@@ -70,7 +69,15 @@ class UserUtilityInterface[T: Model](ABC):
@property @property
@abstractmethod @abstractmethod
def cls(self) -> Type[T]: def base(self) -> type[DeclarativeBase]:
"""Returns the base data model.
:return: The base data model.
"""
@property
@abstractmethod
def cls(self) -> type[T]:
"""Returns the class of the user data model. """Returns the class of the user data model.
:return: The class of the user data model. :return: The class of the user data model.
@@ -110,7 +117,9 @@ class UserUtilityInterface[T: Model](ABC):
__user_utils: UserUtilityInterface __user_utils: UserUtilityInterface
"""The user utilities.""" """The user utilities."""
type user_cls = Model base_cls = DeclarativeBase
"""The base data model."""
type user_cls = DeclarativeBase
"""The user class.""" """The user class."""
user_pk_column: sa.Column = sa.Column(sa.Integer) user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class.""" """The primary key column of the user class."""
@@ -122,8 +131,9 @@ def init_user_utils(utils: UserUtilityInterface) -> None:
:param utils: The user utilities. :param utils: The user utilities.
:return: None. :return: None.
""" """
global __user_utils, user_cls, user_pk_column global __user_utils, base_cls, user_cls, user_pk_column
__user_utils = utils __user_utils = utils
base_cls = utils.base
user_cls = utils.cls user_cls = utils.cls
user_pk_column = utils.pk_column user_pk_column = utils.pk_column
+45 -25
View File
@@ -21,6 +21,7 @@ import datetime as dt
import unittest import unittest
import httpx import httpx
import sqlalchemy as sa
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next from accounting.utils.next_uri import encode_next
@@ -275,7 +276,9 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Account)).unique()},
{CASH.code, BANK.code}) {CASH.code, BANK.code})
# Missing CSRF token # Missing CSRF token
@@ -334,8 +337,8 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped # Success, with spaces to be stripped
response = self.__client.post(store_uri, response = self.__client.post(
data={"csrf_token": self.__csrf_token, store_uri, data={"csrf_token": self.__csrf_token,
"base_code": f" {STOCK.base_code} ", "base_code": f" {STOCK.base_code} ",
"title": f" {STOCK.title} "}) "title": f" {STOCK.title} "})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -352,7 +355,9 @@ class AccountTestCase(unittest.TestCase):
# Success under the same base, with order in a mess. # Success under the same base, with order in a mess.
with self.__app.app_context(): with self.__app.app_context():
stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002") stock_2: Account | None = \
Account.find_by_code(f"{STOCK.base_code}-002")
self.assertIsNotNone(stock_2)
stock_2.no = 66 stock_2.no = 66
db.session.commit() db.session.commit()
@@ -365,12 +370,14 @@ class AccountTestCase(unittest.TestCase):
f"{PREFIX}/{STOCK.base_code}-003") f"{PREFIX}/{STOCK.base_code}-003")
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Account)).unique()},
{CASH.code, BANK.code, STOCK.code, {CASH.code, BANK.code, STOCK.code,
f"{STOCK.base_code}-002", f"{STOCK.base_code}-002", f"{STOCK.base_code}-003"})
f"{STOCK.base_code}-003"})
account: Account = Account.find_by_code(STOCK.code) account: Account | None = Account.find_by_code(STOCK.code)
self.assertIsNotNone(account)
self.assertEqual(account.base_code, STOCK.base_code) self.assertEqual(account.base_code, STOCK.base_code)
self.assertEqual(account.title_l10n, STOCK.title) self.assertEqual(account.title_l10n, STOCK.title)
@@ -395,7 +402,8 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
with self.__app.app_context(): with self.__app.app_context():
account: Account = Account.find_by_code(CASH.code) account: Account | None = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
self.assertEqual(account.base_code, CASH.base_code) self.assertEqual(account.base_code, CASH.base_code)
self.assertEqual(account.title_l10n, f"{CASH.title}-1") self.assertEqual(account.title_l10n, f"{CASH.title}-1")
@@ -462,7 +470,7 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account from accounting.models import Account
detail_uri: str = f"{PREFIX}/{CASH.code}" detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update" update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account account: Account | None
response: httpx.Response response: httpx.Response
response = self.__client.post(update_uri, response = self.__client.post(update_uri,
@@ -504,11 +512,12 @@ class AccountTestCase(unittest.TestCase):
csrf_token: str = get_csrf_token(client) csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}/{CASH.code}" detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update" update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account account: Account | None
response: httpx.Response response: httpx.Response
with self.__app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
self.assertEqual(account.created_by.username, editor_username) self.assertEqual(account.created_by.username, editor_username)
self.assertEqual(account.updated_by.username, editor_username) self.assertEqual(account.updated_by.username, editor_username)
@@ -534,11 +543,12 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account from accounting.models import Account
detail_uri: str = f"{PREFIX}/{CASH.code}" detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update" update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account account: Account | None
response: httpx.Response response: httpx.Response
with self.__app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
self.assertEqual(account.title_l10n, CASH.title) self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual(account.l10n, []) self.assertEqual(account.l10n, [])
@@ -553,6 +563,7 @@ class AccountTestCase(unittest.TestCase):
with self.__app.app_context(): with self.__app.app_context():
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
self.assertEqual(account.title_l10n, CASH.title) self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual({(x.locale, x.title) for x in account.l10n}, self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")}) {("zh_Hant", f"{CASH.title}-zh_Hant")})
@@ -574,8 +585,8 @@ class AccountTestCase(unittest.TestCase):
set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant") set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.__client.post(update_uri, response = self.__client.post(
data={"csrf_token": self.__csrf_token, update_uri, data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code, "base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant-2"}) "title": f"{CASH.title}-zh_Hant-2"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -614,7 +625,9 @@ class AccountTestCase(unittest.TestCase):
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Account)).unique()},
{CASH.code, PETTY.code, BANK.code}) {CASH.code, PETTY.code, BANK.code})
# Cannot delete the cash account # Cannot delete the cash account
@@ -638,7 +651,9 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], list_uri)
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Account)).unique()},
{CASH.code, BANK.code}) {CASH.code, BANK.code})
response = self.__client.get(detail_uri) response = self.__client.get(detail_uri)
@@ -656,8 +671,8 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
for i in range(2, 6): for i in range(2, 6):
response = self.__client.post(f"{PREFIX}/store", response = self.__client.post(
data={"csrf_token": self.__csrf_token, f"{PREFIX}/store", data={"csrf_token": self.__csrf_token,
"base_code": "1111", "base_code": "1111",
"title": "Title"}) "title": "Title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@@ -665,15 +680,20 @@ class AccountTestCase(unittest.TestCase):
f"{PREFIX}/1111-00{i}") f"{PREFIX}/1111-00{i}")
with self.__app.app_context(): with self.__app.app_context():
account_1: Account = Account.find_by_code("1111-001") account_1: Account | None = Account.find_by_code("1111-001")
self.assertIsNotNone(account_1)
id_1: int = account_1.id id_1: int = account_1.id
account_2: Account = Account.find_by_code("1111-002") account_2: Account | None = Account.find_by_code("1111-002")
self.assertIsNotNone(account_2)
id_2: int = account_2.id id_2: int = account_2.id
account_3: Account = Account.find_by_code("1111-003") account_3: Account | None = Account.find_by_code("1111-003")
self.assertIsNotNone(account_3)
id_3: int = account_3.id id_3: int = account_3.id
account_4: Account = Account.find_by_code("1111-004") account_4: Account | None = Account.find_by_code("1111-004")
self.assertIsNotNone(account_4)
id_4: int = account_4.id id_4: int = account_4.id
account_5: Account = Account.find_by_code("1111-005") account_5: Account | None = Account.find_by_code("1111-005")
self.assertIsNotNone(account_5)
id_5: int = account_5.id id_5: int = account_5.id
account_1.no = 3 account_1.no = 3
account_2.no = 5 account_2.no = 5
@@ -707,8 +727,8 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
for i in range(2, 6): for i in range(2, 6):
response = self.__client.post(f"{PREFIX}/store", response = self.__client.post(
data={"csrf_token": self.__csrf_token, f"{PREFIX}/store", data={"csrf_token": self.__csrf_token,
"base_code": "1111", "base_code": "1111",
"title": "Title"}) "title": "Title"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
+20 -14
View File
@@ -29,7 +29,7 @@ from flask import Flask
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from sqlalchemy.sql.ddl import DropTable from sqlalchemy.sql.ddl import DropTable
from test_site import db from test_site import db, Base
from testlib import create_test_app from testlib import create_test_app
@@ -63,7 +63,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
# Drop every accounting table, to see if accounting-init-db # Drop every accounting table, to see if accounting-init-db
# recreates them correctly. # recreates them correctly.
tables: list[sa.Table] \ tables: list[sa.Table] \
= [db.metadata.tables[x] for x in db.metadata.tables = [Base.metadata.tables[x] for x in Base.metadata.tables
if x.startswith("accounting_")] if x.startswith("accounting_")]
for table in tables: for table in tables:
db.session.execute(DropTable(table)) db.session.execute(DropTable(table))
@@ -101,7 +101,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
for x in rows} for x in rows}
with self.__app.app_context(): with self.__app.app_context():
accounts: list[BaseAccount] = BaseAccount.query.all() accounts: list[BaseAccount] = db.session.scalars(
sa.select(BaseAccount)).unique().all()
self.assertEqual(len(accounts), len(data)) self.assertEqual(len(accounts), len(data))
for account in accounts: for account in accounts:
@@ -141,10 +142,14 @@ class ConsoleCommandTestCase(unittest.TestCase):
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
with self.__app.app_context(): with self.__app.app_context():
bases: list[BaseAccount] = BaseAccount.query\ bases: list[BaseAccount] = db.session.scalars(
.filter(sa.func.char_length(BaseAccount.code) == 4).all() sa.select(BaseAccount)
accounts: list[Account] = Account.query.all() .where(sa.func.char_length(BaseAccount.code) == 4))\
l10n: list[AccountL10n] = AccountL10n.query.all() .unique().all()
accounts: list[Account] = db.session.scalars(
sa.select(Account)).unique().all()
l10n: list[AccountL10n] = db.session.scalars(
sa.select(AccountL10n)).all()
self.assertEqual({x.code for x in bases}, self.assertEqual({x.code for x in bases},
{x.base_code for x in accounts}) {x.base_code for x in accounts})
@@ -175,7 +180,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
for x in csv.DictReader(fp)} for x in csv.DictReader(fp)}
with self.__app.app_context(): with self.__app.app_context():
currencies: list[Currency] = Currency.query.all() currencies: list[Currency] = db.session.scalars(
sa.select(Currency)).unique().all()
self.assertEqual(len(currencies), len(data)) self.assertEqual(len(currencies), len(data))
for currency in currencies: for currency in currencies:
@@ -201,7 +207,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
with self.__app.app_context(): with self.__app.app_context():
# Resets the accounts. # Resets the accounts.
tables: list[sa.Table] \ tables: list[sa.Table] \
= [db.metadata.tables[x] for x in db.metadata.tables = [Base.metadata.tables[x] for x in Base.metadata.tables
if x.startswith("accounting_")] if x.startswith("accounting_")]
for table in tables: for table in tables:
db.session.execute(DropTable(table)) db.session.execute(DropTable(table))
@@ -216,9 +222,9 @@ class ConsoleCommandTestCase(unittest.TestCase):
result.output + str(result.exception)) result.output + str(result.exception))
# Turns the titles into lowercase. # Turns the titles into lowercase.
for base in BaseAccount.query: for base in db.session.scalars(sa.select(BaseAccount)).unique():
base.title_l10n = base.title_l10n.lower() base.title_l10n = base.title_l10n.lower()
for account in Account.query: for account in db.session.scalars(sa.select(Account)).unique():
account.title_l10n = account.title_l10n.lower() account.title_l10n = account.title_l10n.lower()
account.created_at \ account.created_at \
= account.created_at - dt.timedelta(seconds=5) = account.created_at - dt.timedelta(seconds=5)
@@ -230,7 +236,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
new_account: Account = Account( new_account: Account = Account(
id=new_id(Account), id=new_id(Account),
base_code="1112", base_code="1112",
no="2", no=2,
title_l10n=custom_title, title_l10n=custom_title,
is_need_offset=False, is_need_offset=False,
created_by_id=creator_pk, created_by_id=creator_pk,
@@ -242,9 +248,9 @@ class ConsoleCommandTestCase(unittest.TestCase):
args=["accounting-titleize", "-u", "editor"]) args=["accounting-titleize", "-u", "editor"])
self.assertEqual(result.exit_code, 0, self.assertEqual(result.exit_code, 0,
result.output + str(result.exception)) result.output + str(result.exception))
for base in BaseAccount.query: for base in db.session.scalars(sa.select(BaseAccount)).unique():
self.__test_title_case(base.title_l10n) self.__test_title_case(base.title_l10n)
for account in Account.query: for account in db.session.scalars(sa.select(Account)).unique():
if account.id != new_account.id: if account.id != new_account.id:
self.__test_title_case(account.title_l10n) self.__test_title_case(account.title_l10n)
self.assertNotEqual(account.created_at, account.updated_at) self.assertNotEqual(account.created_at, account.updated_at)
+13 -4
View File
@@ -21,6 +21,7 @@ import datetime as dt
import unittest import unittest
import httpx import httpx
import sqlalchemy as sa
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next from accounting.utils.next_uri import encode_next
@@ -221,7 +222,9 @@ class CurrencyTestCase(unittest.TestCase):
response: httpx.Response response: httpx.Response
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Currency)).unique()},
{USD.code, EUR.code}) {USD.code, EUR.code})
# Missing CSRF token # Missing CSRF token
@@ -287,7 +290,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Currency)).unique()},
{USD.code, EUR.code, TWD.code}) {USD.code, EUR.code, TWD.code})
currency: Currency = db.session.get(Currency, TWD.code) currency: Currency = db.session.get(Currency, TWD.code)
@@ -554,7 +559,9 @@ class CurrencyTestCase(unittest.TestCase):
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Currency)).unique()},
{USD.code, EUR.code, JPY.code}) {USD.code, EUR.code, JPY.code})
# Cannot delete the default currency # Cannot delete the default currency
@@ -578,7 +585,9 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], list_uri) self.assertEqual(response.headers["Location"], list_uri)
with self.__app.app_context(): with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()}, self.assertEqual(
{x.code
for x in db.session.scalars(sa.select(Currency)).unique()},
{USD.code, EUR.code}) {USD.code, EUR.code})
response = self.__client.get(detail_uri) response = self.__client.get(detail_uri)
+23 -12
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Demonstration Website. # The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,16 +19,16 @@
""" """
import os import os
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Type
import sqlalchemy as sa
from click.testing import Result from click.testing import Result
from flask import Flask, Blueprint, render_template, redirect, Response, \ from flask import Flask, Blueprint, render_template, redirect, Response, \
url_for url_for
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from flask_babel_js import BabelJS from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy_lite import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from sqlalchemy import Column from sqlalchemy.orm import DeclarativeBase
bp: Blueprint = Blueprint("home", __name__) bp: Blueprint = Blueprint("home", __name__)
"""The global blueprint.""" """The global blueprint."""
@@ -40,6 +40,10 @@ db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
class Base(DeclarativeBase):
"""The base class for all models."""
def create_app(is_testing: bool = False, is_skip_accounts: bool = False, def create_app(is_testing: bool = False, is_skip_accounts: bool = False,
is_skip_currencies: bool = False) -> Flask: is_skip_currencies: bool = False) -> Flask:
"""Create and configure the application. """Create and configure the application.
@@ -54,12 +58,13 @@ def create_app(is_testing: bool = False, is_skip_accounts: bool = False,
import accounting import accounting
app: Flask = Flask(__name__) app: Flask = Flask(__name__)
db_uri: str = "sqlite://" if is_testing else "sqlite:///local.sqlite" db_uri: str = "sqlite://" if is_testing \
else "sqlite:///" + os.path.join(app.instance_path, "local.sqlite")
app.config.from_mapping({ app.config.from_mapping({
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)), "SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SESSION_COOKIE_SAMESITE": "Lax", "SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": True, "SESSION_COOKIE_SECURE": True,
"SQLALCHEMY_DATABASE_URI": db_uri, "SQLALCHEMY_ENGINES": {"default": db_uri},
"BABEL_DEFAULT_LOCALE": "en", "BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
}) })
@@ -101,11 +106,15 @@ def create_app(is_testing: bool = False, is_skip_accounts: bool = False,
return redirect(append_next(url_for("auth.login-form"))) return redirect(append_next(url_for("auth.login-form")))
@property @property
def cls(self) -> Type[auth.User]: def base(self) -> type[DeclarativeBase]:
return Base
@property
def cls(self) -> type[auth.User]:
return auth.User return auth.User
@property @property
def pk_column(self) -> Column: def pk_column(self) -> sa.Column:
return auth.User.id return auth.User.id
@property @property
@@ -113,8 +122,8 @@ def create_app(is_testing: bool = False, is_skip_accounts: bool = False,
return auth.current_user() return auth.current_user()
def get_by_username(self, username: str) -> auth.User | None: def get_by_username(self, username: str) -> auth.User | None:
return auth.User.query\ return db.session.scalar(
.filter(auth.User.username == username).first() sa.select(auth.User).where(auth.User.username == username))
def get_pk(self, user: auth.User) -> int: def get_pk(self, user: auth.User) -> int:
return user.id return user.id
@@ -138,10 +147,12 @@ def init_db(app: Flask, is_skip_accounts: bool,
otherwise. otherwise.
:return: None. :return: None.
""" """
db.create_all() Base.metadata.create_all(db.engine)
from .auth import User from .auth import User
for username in ["viewer", "editor", "admin", "nobody"]: for username in ["viewer", "editor", "admin", "nobody"]:
if User.query.filter(User.username == username).first() is None: user: User | None = db.session.scalar(
sa.select(User).where(User.username == username))
if user is None:
db.session.add(User(username=username)) db.session.add(User(username=username))
db.session.commit() db.session.commit()
runner: FlaskCliRunner = app.test_cli_runner() runner: FlaskCliRunner = app.test_cli_runner()
+8 -7
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Demonstration Website. # The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/27
# Copyright (c) 2023 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,17 +19,18 @@
""" """
from collections.abc import Callable from collections.abc import Callable
import sqlalchemy as sa
from flask import Blueprint, render_template, Flask, redirect, url_for, \ from flask import Blueprint, render_template, Flask, redirect, url_for, \
session, request, g, Response, abort session, request, g, Response, abort
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from . import db from . import db, Base
bp: Blueprint = Blueprint("auth", __name__, url_prefix="/") bp: Blueprint = Blueprint("auth", __name__, url_prefix="/")
"""The authentication blueprint.""" """The authentication blueprint."""
class User(db.Model): class User(Base):
"""A user.""" """A user."""
__tablename__ = "users" __tablename__ = "users"
"""The table name.""" """The table name."""
@@ -58,7 +59,7 @@ def show_login_form() -> str | Response:
@bp.post("login", endpoint="login") @bp.post("login", endpoint="login")
def login() -> redirect: def login() -> Response:
"""Logs in the user. """Logs in the user.
:return: The redirection to the home page. :return: The redirection to the home page.
@@ -72,7 +73,7 @@ def login() -> redirect:
@bp.post("logout", endpoint="logout") @bp.post("logout", endpoint="logout")
def logout() -> redirect: def logout() -> Response:
"""Logs out the user. """Logs out the user.
:return: The redirection to the home page. :return: The redirection to the home page.
@@ -91,8 +92,8 @@ def current_user() -> User | None:
if "user" not in session: if "user" not in session:
g.user = None g.user = None
else: else:
g.user = User.query.filter( g.user = db.session.scalar(
User.username == session["user"]).first() sa.select(User).where(User.username == session["user"]))
return g.user return g.user
+6 -5
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Demonstration Website. # The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/13 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/13
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -218,8 +218,8 @@ class BaseTestData(ABC):
self._app: Flask = app self._app: Flask = app
"""The Flask application.""" """The Flask application."""
with self._app.app_context(): with self._app.app_context():
current_user: User | None = User.query\ current_user: User | None = db.session.scalar(
.filter(User.username == username).first() sa.select(User).where(User.username == username))
assert current_user is not None assert current_user is not None
self.__current_user_id: int = current_user.id self.__current_user_id: int = current_user.id
"""The current user ID.""" """The current user ID."""
@@ -259,7 +259,7 @@ class BaseTestData(ABC):
:param credit: The credit account code. :param credit: The credit account code.
:return: The debit line item and credit line item. :return: The debit line item and credit line item.
""" """
return JournalEntryLineItemData(debit, description, amount),\ return JournalEntryLineItemData(debit, description, amount), \
JournalEntryLineItemData(credit, description, amount) JournalEntryLineItemData(credit, description, amount)
def _add_journal_entry(self, journal_entry_data: JournalEntryData) -> None: def _add_journal_entry(self, journal_entry_data: JournalEntryData) -> None:
@@ -272,7 +272,8 @@ class BaseTestData(ABC):
existing_j_id: set[int] = {x["id"] for x in self.__journal_entries} existing_j_id: set[int] = {x["id"] for x in self.__journal_entries}
existing_l_id: set[int] = {x["id"] for x in self.__line_items} existing_l_id: set[int] = {x["id"] for x in self.__line_items}
journal_entry_data.id = self.__new_id(existing_j_id) journal_entry_data.id = self.__new_id(existing_j_id)
date: dt.date = get_today() - dt.timedelta(days=journal_entry_data.days) date: dt.date = get_today() - \
dt.timedelta(days=journal_entry_data.days)
self.__journal_entries.append( self.__journal_entries.append(
{"id": journal_entry_data.id, {"id": journal_entry_data.id,
"date": date, "date": date,
+13 -12
View File
@@ -1,7 +1,7 @@
# The Mia! Accounting Demonstration Website. # The Mia! Accounting Demonstration Website.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/12 # Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/12
# Copyright (c) 2023-2024 imacat. # Copyright (c) 2023-2026 imacat.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License. # you may not use this file except in compliance with the License.
@@ -19,8 +19,9 @@
""" """
import datetime as dt import datetime as dt
import sqlalchemy as sa
from flask import Flask, Blueprint, url_for, flash, redirect, session, \ from flask import Flask, Blueprint, url_for, flash, redirect, session, \
render_template, current_app render_template, current_app, Response
from flask_babel import lazy_gettext from flask_babel import lazy_gettext
from accounting.utils.timezone import get_tz_today from accounting.utils.timezone import get_tz_today
@@ -45,7 +46,7 @@ def reset() -> str:
@bp.post("sample", endpoint="sample") @bp.post("sample", endpoint="sample")
@admin_required @admin_required
def reset_sample() -> redirect: def reset_sample() -> Response:
"""Resets the sample data. """Resets the sample data.
:return: Redirection to the accounting application. :return: Redirection to the accounting application.
@@ -60,7 +61,7 @@ def reset_sample() -> redirect:
@bp.post("reset", endpoint="clean-up") @bp.post("reset", endpoint="clean-up")
@admin_required @admin_required
def clean_up() -> redirect: def clean_up() -> Response:
"""Clean-up the database data. """Clean-up the database data.
:return: Redirection to the accounting application. :return: Redirection to the accounting application.
@@ -83,14 +84,14 @@ def __reset_database() -> None:
from accounting.account import init_accounts_command from accounting.account import init_accounts_command
from accounting.currency import init_currencies_command from accounting.currency import init_currencies_command
JournalEntryLineItem.query.delete() db.session.execute(sa.delete(JournalEntryLineItem))
JournalEntry.query.delete() db.session.execute(sa.delete(JournalEntry))
CurrencyL10n.query.delete() db.session.execute(sa.delete(CurrencyL10n))
Currency.query.delete() db.session.execute(sa.delete(Currency))
AccountL10n.query.delete() db.session.execute(sa.delete(AccountL10n))
Account.query.delete() db.session.execute(sa.delete(Account))
BaseAccountL10n.query.delete() db.session.execute(sa.delete(BaseAccountL10n))
BaseAccount.query.delete() db.session.execute(sa.delete(BaseAccount))
init_base_accounts_command() init_base_accounts_command()
init_accounts_command(session["user"]) init_accounts_command(session["user"])
init_currencies_command(session["user"]) init_currencies_command(session["user"])
+2 -1
View File
@@ -146,7 +146,8 @@ class NextUriTestCase(unittest.TestCase):
# A foreign URI # A foreign URI
next_uri = "https://example.com" next_uri = "https://example.com"
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}") response = client.get(
f"/test-invalid-next?next={quote_plus(next_uri)}")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next", response = client.post("/test-invalid-next",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
+1 -1
View File
@@ -165,7 +165,7 @@ def match_journal_entry_detail(location: str) -> int:
:return: The journal entry ID. :return: The journal entry ID.
:raise AssertionError: When the location is not the journal entry detail. :raise AssertionError: When the location is not the journal entry detail.
""" """
m: re.Match = re.match( m: re.Match[str] | None = re.match(
r"^/accounting/journal-entries/(\d+)\?next=", location) r"^/accounting/journal-entries/(\d+)\?next=", location)
assert m is not None assert m is not None
return int(m.group(1)) return int(m.group(1))
+14 -9
View File
@@ -224,13 +224,14 @@ def __mess_up_debit(form: dict[str, str], currency_prefix: str) \
:return: The messed-up form. :return: The messed-up form.
""" """
key: str key: str
m: re.Match m: re.Match[str] | None
# Remove the office disbursement # Remove the office disbursement
key = [x for x in form key = [x for x in form
if x.startswith(currency_prefix) if x.startswith(currency_prefix)
and form[x] == Accounts.OFFICE][0] and form[x] == Accounts.OFFICE][0]
m = re.match(r"^((.+-)\d+-)account_code$", key) m = re.match(r"^((.+-)\d+-)account_code$", key)
assert m is not None
debit_prefix: str = m.group(2) debit_prefix: str = m.group(2)
line_item_prefix: str = m.group(1) line_item_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{line_item_prefix}amount"]) amount: Decimal = Decimal(form[f"{line_item_prefix}amount"])
@@ -265,13 +266,14 @@ def __mess_up_credit(form: dict[str, str], currency_prefix: str) \
:return: The messed-up form. :return: The messed-up form.
""" """
key: str key: str
m: re.Match m: re.Match[str] | None
# Remove the sales receipt # Remove the sales receipt
key = [x for x in form key = [x for x in form
if x.startswith(currency_prefix) if x.startswith(currency_prefix)
and form[x] == Accounts.SALES][0] and form[x] == Accounts.SALES][0]
m = re.match(r"^((.+-)\d+-)account_code$", key) m = re.match(r"^((.+-)\d+-)account_code$", key)
assert m is not None
credit_prefix: str = m.group(2) credit_prefix: str = m.group(2)
line_item_prefix: str = m.group(1) line_item_prefix: str = m.group(1)
amount: Decimal = Decimal(form[f"{line_item_prefix}amount"]) amount: Decimal = Decimal(form[f"{line_item_prefix}amount"])
@@ -304,7 +306,6 @@ def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
:return: The messed-up form. :return: The messed-up form.
""" """
key: str key: str
m: re.Match
# Remove JPY # Remove JPY
currency_prefix: str = __get_currency_prefix(form, "JPY") currency_prefix: str = __get_currency_prefix(form, "JPY")
@@ -312,7 +313,7 @@ def __mess_up_currencies(form: dict[str, str]) -> dict[str, str]:
# Add AUD # Add AUD
indices: set[int] = set() indices: set[int] = set()
for key in form: for key in form:
m = re.match(r"^currency-(\d+)-code$", key) m: re.Match[str] | None = re.match(r"^currency-(\d+)-code$", key)
if m is not None: if m is not None:
indices.add(int(m.group(1))) indices.add(int(m.group(1)))
new_index: int = max(indices) + 5 + randbelow(20) new_index: int = max(indices) + 5 + randbelow(20)
@@ -363,7 +364,8 @@ def __get_line_item_no_key(form: dict[str, str], currency_prefix: str,
key: str = [x for x in form key: str = [x for x in form
if x.startswith(currency_prefix) if x.startswith(currency_prefix)
and form[x] == code][0] and form[x] == code][0]
m: re.Match = re.match(r"^(.+-\d+-)account_code$", key) m: re.Match[str] | None = re.match(r"^(.+-\d+-)account_code$", key)
assert m is not None
return f"{m.group(1)}no" return f"{m.group(1)}no"
@@ -375,7 +377,8 @@ def __get_currency_prefix(form: dict[str, str], code: str) -> str:
:return: The prefix of the currency. :return: The prefix of the currency.
""" """
key: str = [x for x in form if form[x] == code][0] key: str = [x for x in form if form[x] == code][0]
m: re.Match = re.match(r"^(.+-)code$", key) m: re.Match[str] | None = re.match(r"^(.+-)code$", key)
assert m is not None
return m.group(1) return m.group(1)
@@ -388,7 +391,7 @@ def set_negative_amount(form: dict[str, str]) -> None:
amount_keys: list[str] = [] amount_keys: list[str] = []
prefix: str = "" prefix: str = ""
for key in form.keys(): for key in form.keys():
m: re.Match = re.match(r"^(.+)-\d+-amount$", key) m: re.Match[str] | None = re.match(r"^(.+)-\d+-amount$", key)
if m is None: if m is None:
continue continue
if prefix != "" and prefix != m.group(1): if prefix != "" and prefix != m.group(1):
@@ -407,7 +410,8 @@ def remove_debit_in_a_currency(form: dict[str, str]) -> None:
:return: None. :return: None.
""" """
key: str = [x for x in form if "-debit-" in x][0] key: str = [x for x in form if "-debit-" in x][0]
m: re.Match = re.match(r"^(.+-debit-)", key) m: re.Match[str] | None = re.match(r"^(.+-debit-)", key)
assert m is not None
keys: set[str] = {x for x in form if x.startswith(m.group(1))} keys: set[str] = {x for x in form if x.startswith(m.group(1))}
for key in keys: for key in keys:
del form[key] del form[key]
@@ -420,7 +424,8 @@ def remove_credit_in_a_currency(form: dict[str, str]) -> None:
:return: None. :return: None.
""" """
key: str = [x for x in form if "-credit-" in x][0] key: str = [x for x in form if "-credit-" in x][0]
m: re.Match = re.match(r"^(.+-credit-)", key) m: re.Match[str] | None = re.match(r"^(.+-credit-)", key)
assert m is not None
keys: set[str] = {x for x in form if x.startswith(m.group(1))} keys: set[str] = {x for x in form if x.startswith(m.group(1))}
for key in keys: for key in keys:
del form[key] del form[key]