Added the account management.
This commit is contained in:
37
src/accounting/account/__init__.py
Normal file
37
src/accounting/account/__init__.py
Normal file
@ -0,0 +1,37 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The account management.
|
||||
|
||||
"""
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
|
||||
def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
"""Initialize the application.
|
||||
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:param app: The Flask application.
|
||||
:return: None.
|
||||
"""
|
||||
from .converters import AccountConverter
|
||||
app.url_map.converters["account"] = AccountConverter
|
||||
|
||||
from .views import bp as account_bp
|
||||
bp.register_blueprint(account_bp, url_prefix="/accounts")
|
||||
|
||||
from .commands import init_accounts_command
|
||||
app.cli.add_command(init_accounts_command)
|
128
src/accounting/account/commands.py
Normal file
128
src/accounting/account/commands.py
Normal file
@ -0,0 +1,128 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The console commands for the account management.
|
||||
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from secrets import randbelow
|
||||
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting.database import db, user_utils
|
||||
from .models import Account, AccountL10n
|
||||
from ..base_account import BaseAccount
|
||||
|
||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||
"""The format of the account data, as a list of (ID, base account code, number,
|
||||
English, Traditional Chinese, Simplified Chinese, is-offset-needed) tuples."""
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
value: str) -> str:
|
||||
"""Validates the username for the click console command.
|
||||
|
||||
:param ctx: The console command context.
|
||||
:param param: The console command option.
|
||||
:param value: The username.
|
||||
:raise click.BadParameter: When validation fails.
|
||||
:return: The username.
|
||||
"""
|
||||
value = value.strip()
|
||||
if value == "":
|
||||
raise click.BadParameter("Username empty.")
|
||||
user: user_utils.cls | None = user_utils.get_by_username(value)
|
||||
if user is None:
|
||||
raise click.BadParameter(f"User {value} does not exist.")
|
||||
return value
|
||||
|
||||
|
||||
@click.command("accounting-init-accounts")
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@with_appcontext
|
||||
def init_accounts_command(username: str) -> None:
|
||||
"""Initializes the accounts."""
|
||||
creator_pk: int = user_utils.get_pk(user_utils.get_by_username(username))
|
||||
|
||||
bases: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
if len(bases) == 0:
|
||||
click.echo("Please initialize the base accounts with "
|
||||
"\"flask accounting-init-base\" first.")
|
||||
raise click.Abort
|
||||
|
||||
existing: list[Account] = Account.query.all()
|
||||
|
||||
existing_base_code: set[str] = {x.base_code for x in existing}
|
||||
bases_to_add: list[BaseAccount] = [x for x in bases
|
||||
if x.code not in existing_base_code]
|
||||
if len(bases_to_add) == 0:
|
||||
click.echo("No more account to import.")
|
||||
return
|
||||
|
||||
existing_id: set[int] = {x.id for x in existing}
|
||||
|
||||
def get_new_id() -> int:
|
||||
"""Returns a new random account ID.
|
||||
|
||||
:return: The newly-generated random account ID.
|
||||
"""
|
||||
while True:
|
||||
new_id: int = 100000000 + randbelow(900000000)
|
||||
if new_id not in existing_id:
|
||||
existing_id.add(new_id)
|
||||
return new_id
|
||||
|
||||
data: list[AccountData] = []
|
||||
for base in bases_to_add:
|
||||
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
|
||||
is_offset_needed: bool = True if re.match("^[12]1[34]", base.code) \
|
||||
else False
|
||||
data.append((get_new_id(), base.code, 1, base.title_l10n,
|
||||
l10n["zh_Hant"], l10n["zh_Hans"], is_offset_needed))
|
||||
__add_accounting_accounts(data, creator_pk)
|
||||
click.echo(F"{len(data)} added. Accounting accounts initialized.")
|
||||
|
||||
|
||||
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
|
||||
-> None:
|
||||
"""Adds the accounts.
|
||||
|
||||
:param data: A list of (base code, number, title) tuples.
|
||||
:param creator_pk: The primary key of the creator.
|
||||
:return: None.
|
||||
"""
|
||||
accounts: list[Account] = [Account(id=x[0],
|
||||
base_code=x[1],
|
||||
no=x[2],
|
||||
title_l10n=x[3],
|
||||
is_offset_needed=x[6],
|
||||
created_by_id=creator_pk,
|
||||
updated_by_id=creator_pk)
|
||||
for x in data]
|
||||
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
|
||||
locale=y[0],
|
||||
title=y[1])
|
||||
for x in data
|
||||
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
|
||||
db.session.bulk_save_objects(accounts)
|
||||
db.session.bulk_save_objects(l10n)
|
||||
db.session.commit()
|
47
src/accounting/account/converters.py
Normal file
47
src/accounting/account/converters.py
Normal file
@ -0,0 +1,47 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/31
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The path converters for the account management.
|
||||
|
||||
"""
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
|
||||
from .models import Account
|
||||
|
||||
|
||||
class AccountConverter(BaseConverter):
|
||||
"""The account converter to convert the account code and to the
|
||||
corresponding account in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> Account:
|
||||
"""Converts a username to a user account.
|
||||
|
||||
:param value: The username.
|
||||
:return: The corresponding user account.
|
||||
"""
|
||||
account: Account | None = Account.find_by_code(value)
|
||||
if account is None:
|
||||
abort(404)
|
||||
return account
|
||||
|
||||
def to_url(self, value: Account) -> str:
|
||||
"""Converts an account to its code.
|
||||
|
||||
:param value: The account.
|
||||
:return: The code.
|
||||
"""
|
||||
return value.code
|
130
src/accounting/account/forms.py
Normal file
130
src/accounting/account/forms.py
Normal file
@ -0,0 +1,130 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The forms for the account management.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting.base_account import BaseAccount
|
||||
from accounting.database import db, user_utils
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from .models import Account
|
||||
|
||||
|
||||
class BaseAccountExists:
|
||||
"""The validator to check if the base account code exists."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data == "":
|
||||
return
|
||||
if db.session.get(BaseAccount, field.data) is None:
|
||||
raise ValidationError(lazy_gettext(
|
||||
"The base account does not exist."))
|
||||
|
||||
|
||||
class AccountForm(FlaskForm):
|
||||
"""The form to create or edit an account."""
|
||||
base_code = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[
|
||||
DataRequired(lazy_gettext("Please select the base account.")),
|
||||
BaseAccountExists()])
|
||||
"""The code of the base account."""
|
||||
title = StringField(
|
||||
filters=[strip_text],
|
||||
validators=[DataRequired(lazy_gettext("Please fill in the title"))])
|
||||
"""The title."""
|
||||
is_offset_needed = BooleanField()
|
||||
"""Whether the the entries of this account need offsets."""
|
||||
|
||||
def populate_obj(self, obj: Account) -> None:
|
||||
"""Populates the form data into an account object.
|
||||
|
||||
:param obj: The account object.
|
||||
:return: None.
|
||||
"""
|
||||
is_new: bool = obj.id is None
|
||||
prev_base_code: str | None = obj.base_code
|
||||
if is_new:
|
||||
obj.id = new_id(Account)
|
||||
obj.base_code = self.base_code.data
|
||||
if prev_base_code != self.base_code.data:
|
||||
last_same_base: Account = Account.query\
|
||||
.filter(Account.base_code == self.base_code.data)\
|
||||
.order_by(Account.base_code.desc()).first()
|
||||
obj.no = 1 if last_same_base is None else last_same_base.no + 1
|
||||
obj.title = self.title.data
|
||||
obj.is_offset_needed = self.is_offset_needed.data
|
||||
if is_new:
|
||||
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
if prev_base_code is not None \
|
||||
and prev_base_code != self.base_code.data:
|
||||
setattr(self, "__post_update",
|
||||
lambda: sort_accounts_in(prev_base_code, obj.id))
|
||||
|
||||
def post_update(self, obj) -> None:
|
||||
"""The post-processing after the update.
|
||||
|
||||
:return: None
|
||||
"""
|
||||
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
|
||||
obj.updated_by_id = current_user_pk
|
||||
obj.updated_at = sa.func.now()
|
||||
if hasattr(self, "__post_update"):
|
||||
getattr(self, "__post_update")()
|
||||
|
||||
@property
|
||||
def selected_base(self) -> BaseAccount | None:
|
||||
"""The selected base account in the form.
|
||||
|
||||
:return: The selected base account in the form.
|
||||
"""
|
||||
return db.session.get(BaseAccount, self.base_code.data)
|
||||
|
||||
@property
|
||||
def base_options(self) -> list[BaseAccount]:
|
||||
"""The selectable base accounts.
|
||||
|
||||
:return: The selectable base accounts.
|
||||
"""
|
||||
return BaseAccount.query\
|
||||
.filter(sa.func.char_length(BaseAccount.code) == 4)\
|
||||
.order_by(BaseAccount.code).all()
|
||||
|
||||
|
||||
def sort_accounts_in(base_code: str, exclude: int) -> None:
|
||||
"""Sorts the accounts under a base account after changing the base
|
||||
account or deleting an account.
|
||||
|
||||
:param base_code: The code of the base account.
|
||||
:param exclude: The account ID to exclude.
|
||||
:return: None.
|
||||
"""
|
||||
accounts: list[Account] = Account.query\
|
||||
.filter(Account.base_code == base_code,
|
||||
Account.id != exclude)\
|
||||
.order_by(Account.no).all()
|
||||
for i in range(len(accounts)):
|
||||
if accounts[i].no != i + 1:
|
||||
accounts[i].no = i + 1
|
259
src/accounting/account/models.py
Normal file
259
src/accounting/account/models.py
Normal file
@ -0,0 +1,259 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The data models for the account management.
|
||||
|
||||
"""
|
||||
import re
|
||||
import typing as t
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import current_app
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy import text
|
||||
|
||||
from accounting.base_account import BaseAccount
|
||||
from accounting.database import db, user_utils
|
||||
|
||||
user_cls: db.Model = user_utils.cls
|
||||
user_pk_column: db.Column = user_utils.pk_column
|
||||
|
||||
|
||||
class Account(db.Model):
|
||||
"""An account."""
|
||||
__tablename__ = "accounting_accounts"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True)
|
||||
"""The account ID."""
|
||||
base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
|
||||
ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
"""The code of the base account."""
|
||||
base = db.relationship(BaseAccount)
|
||||
"""The base account."""
|
||||
no = db.Column(db.Integer, nullable=False, default=text("1"))
|
||||
"""The account number under the base account."""
|
||||
title_l10n = db.Column("title", db.String, nullable=False)
|
||||
"""The title."""
|
||||
is_offset_needed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
"""Whether the entries of this account need offsets."""
|
||||
created_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
|
||||
nullable=False)
|
||||
"""The ID of the creator."""
|
||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
updated_at = db.Column(db.DateTime(timezone=True), nullable=False,
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
updated_by_id = db.Column(db.Integer, db.ForeignKey(user_pk_column),
|
||||
nullable=False)
|
||||
"""The ID of the updator."""
|
||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||
"""The updator."""
|
||||
l10n = db.relationship("AccountL10n", back_populates="account",
|
||||
lazy=False)
|
||||
"""The localized titles."""
|
||||
db.UniqueConstraint(base_code, no)
|
||||
|
||||
__CASH = "1111-001"
|
||||
"""The code of the cash account,"""
|
||||
__RECEIVABLE = "1141-001"
|
||||
"""The code of the receivable account,"""
|
||||
__PAYABLE = "2141-001"
|
||||
"""The code of the payable account,"""
|
||||
__ACCUMULATED_CHANGE = "3351-001"
|
||||
"""The code of the accumulated-change account,"""
|
||||
__BROUGHT_FORWARD = "3352-001"
|
||||
"""The code of the brought-forward account,"""
|
||||
__NET_CHANGE = "3353-001"
|
||||
"""The code of the net-change account,"""
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Returns the string representation of this account.
|
||||
|
||||
:return: The string representation of this account.
|
||||
"""
|
||||
return F"{self.base_code}-{self.no:03d} {self.title}"
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
"""Returns the code.
|
||||
|
||||
:return: The code.
|
||||
"""
|
||||
return F"{self.base_code}-{self.no:03d}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""Returns the title in the current locale.
|
||||
|
||||
:return: The title in the current locale.
|
||||
"""
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
return self.title_l10n
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
return l10n.title
|
||||
return self.title_l10n
|
||||
|
||||
@title.setter
|
||||
def title(self, value: str) -> None:
|
||||
"""Sets the title in the current locale.
|
||||
|
||||
:param value: The new title.
|
||||
:return: None.
|
||||
"""
|
||||
if self.title_l10n is None:
|
||||
self.title_l10n = value
|
||||
return
|
||||
current_locale = str(get_locale())
|
||||
if current_locale == current_app.config["BABEL_DEFAULT_LOCALE"]:
|
||||
self.title_l10n = value
|
||||
return
|
||||
for l10n in self.l10n:
|
||||
if l10n.locale == current_locale:
|
||||
l10n.title = value
|
||||
return
|
||||
self.l10n.append(AccountL10n(
|
||||
locale=current_locale, title=value))
|
||||
|
||||
@classmethod
|
||||
def find_by_code(cls, code: str) -> t.Self | None:
|
||||
"""Finds an accounting account by its code.
|
||||
|
||||
:param code: The code.
|
||||
:return: The accounting account, or None if this account does not
|
||||
exist.
|
||||
"""
|
||||
m = re.match("^([1-9]{4})-([0-9]{3})$", code)
|
||||
if m is None:
|
||||
return None
|
||||
return cls.query.filter(cls.base_code == m.group(1),
|
||||
cls.no == int(m.group(2))).first()
|
||||
|
||||
@classmethod
|
||||
def debit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
|
||||
:return: The debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
cls.base_code.startswith("2"),
|
||||
cls.base_code.startswith("3"),
|
||||
cls.base_code.startswith("5"),
|
||||
cls.base_code.startswith("6"),
|
||||
cls.base_code.startswith("75"),
|
||||
cls.base_code.startswith("76"),
|
||||
cls.base_code.startswith("77"),
|
||||
cls.base_code.startswith("78"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3351",
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@classmethod
|
||||
def credit(cls) -> list[t.Self]:
|
||||
"""Returns the debit accounts.
|
||||
|
||||
:return: The debit accounts.
|
||||
"""
|
||||
return cls.query.filter(sa.or_(cls.base_code.startswith("1"),
|
||||
cls.base_code.startswith("2"),
|
||||
cls.base_code.startswith("3"),
|
||||
cls.base_code.startswith("4"),
|
||||
cls.base_code.startswith("71"),
|
||||
cls.base_code.startswith("72"),
|
||||
cls.base_code.startswith("73"),
|
||||
cls.base_code.startswith("74"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3351",
|
||||
cls.base_code != "3353")\
|
||||
.order_by(cls.base_code, cls.no).all()
|
||||
|
||||
@classmethod
|
||||
def cash(cls) -> t.Self:
|
||||
"""Returns the cash account.
|
||||
|
||||
:return: The cash account
|
||||
"""
|
||||
return cls.find_by_code(cls.__CASH)
|
||||
|
||||
@classmethod
|
||||
def receivable(cls) -> t.Self:
|
||||
"""Returns the receivable account.
|
||||
|
||||
:return: The receivable account
|
||||
"""
|
||||
return cls.find_by_code(cls.__RECEIVABLE)
|
||||
|
||||
@classmethod
|
||||
def payable(cls) -> t.Self:
|
||||
"""Returns the payable account.
|
||||
|
||||
:return: The payable account
|
||||
"""
|
||||
return cls.find_by_code(cls.__PAYABLE)
|
||||
|
||||
@classmethod
|
||||
def accumulated_change(cls) -> t.Self:
|
||||
"""Returns the accumulated-change account.
|
||||
|
||||
:return: The accumulated-change account
|
||||
"""
|
||||
return cls.find_by_code(cls.__ACCUMULATED_CHANGE)
|
||||
|
||||
@classmethod
|
||||
def brought_forward(cls) -> t.Self:
|
||||
"""Returns the brought-forward account.
|
||||
|
||||
:return: The brought-forward account
|
||||
"""
|
||||
return cls.find_by_code(cls.__BROUGHT_FORWARD)
|
||||
|
||||
@classmethod
|
||||
def net_change(cls) -> t.Self:
|
||||
"""Returns the net-change account.
|
||||
|
||||
:return: The net-change account
|
||||
"""
|
||||
return cls.find_by_code(cls.__NET_CHANGE)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Deletes this accounting account.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
AccountL10n.query.filter(AccountL10n.account == self).delete()
|
||||
cls: t.Type[t.Self] = self.__class__
|
||||
cls.query.filter(cls.id == self.id).delete()
|
||||
|
||||
|
||||
class AccountL10n(db.Model):
|
||||
"""A localized account title."""
|
||||
__tablename__ = "accounting_accounts_l10n"
|
||||
account_id = db.Column(db.Integer, db.ForeignKey(Account.id,
|
||||
ondelete="CASCADE"),
|
||||
nullable=False, primary_key=True)
|
||||
account = db.relationship(Account, back_populates="l10n")
|
||||
locale = db.Column(db.String, nullable=False, primary_key=True)
|
||||
title = db.Column(db.String, nullable=False)
|
||||
db.UniqueConstraint(account_id, locale)
|
50
src/accounting/account/query.py
Normal file
50
src/accounting/account/query.py
Normal file
@ -0,0 +1,50 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The account query.
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
|
||||
from accounting.utils.query import parse_query_keywords
|
||||
from .models import Account, AccountL10n
|
||||
|
||||
|
||||
def get_account_query() -> list[Account]:
|
||||
"""Returns the accounts, optionally filtered by the query.
|
||||
|
||||
:return: The accounts.
|
||||
"""
|
||||
keywords: list[str] = parse_query_keywords(request.args.get("q"))
|
||||
if len(keywords) == 0:
|
||||
return Account.query.order_by(Account.base_code, Account.no).all()
|
||||
code: sa.BinaryExpression = Account.base_code + "-" \
|
||||
+ sa.func.substr("000" + sa.cast(Account.no, sa.String),
|
||||
sa.func.char_length(sa.cast(Account.no,
|
||||
sa.String)) + 1)
|
||||
conditions: list[sa.BinaryExpression] = []
|
||||
for k in keywords:
|
||||
l10n: list[AccountL10n] = AccountL10n.query\
|
||||
.filter(AccountL10n.title.contains(k)).all()
|
||||
l10n_matches: set[str] = {x.account_id for x in l10n}
|
||||
conditions.append(sa.or_(Account.base_code.contains(k),
|
||||
Account.title_l10n.contains(k),
|
||||
code.contains(k),
|
||||
Account.id.in_(l10n_matches)))
|
||||
|
||||
return Account.query.filter(*conditions)\
|
||||
.order_by(Account.base_code, Account.no).all()
|
160
src/accounting/account/views.py
Normal file
160
src/accounting/account/views.py
Normal file
@ -0,0 +1,160 @@
|
||||
# The Mia! Accounting Flask Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
"""The views for the account management.
|
||||
|
||||
"""
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
from flask import Blueprint, render_template, session, redirect, flash, \
|
||||
url_for, request
|
||||
from werkzeug.datastructures import ImmutableMultiDict
|
||||
|
||||
from accounting.database import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
||||
from .forms import AccountForm, sort_accounts_in
|
||||
from .models import Account
|
||||
|
||||
bp: Blueprint = Blueprint("account", __name__)
|
||||
"""The view blueprint for the account management."""
|
||||
|
||||
|
||||
@bp.get("", endpoint="list")
|
||||
@has_permission(can_view)
|
||||
def list_accounts() -> str:
|
||||
"""Lists the base accounts.
|
||||
|
||||
:return: The account list.
|
||||
"""
|
||||
from .models import BaseAccount
|
||||
from .query import get_account_query
|
||||
accounts: list[BaseAccount] = get_account_query()
|
||||
pagination: Pagination = Pagination[BaseAccount](accounts)
|
||||
return render_template("accounting/account/list.html",
|
||||
list=pagination.list, pagination=pagination)
|
||||
|
||||
|
||||
@bp.get("/create", endpoint="create")
|
||||
@has_permission(can_edit)
|
||||
def show_add_account_form() -> str:
|
||||
"""Shows the form to add an account.
|
||||
|
||||
:return: The form to add an account.
|
||||
"""
|
||||
if "form" in session:
|
||||
form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = AccountForm()
|
||||
return render_template("accounting/account/create.html",
|
||||
form=form)
|
||||
|
||||
|
||||
@bp.post("/store", endpoint="store")
|
||||
@has_permission(can_edit)
|
||||
def add_account() -> redirect:
|
||||
"""Adds an account.
|
||||
|
||||
:return: The redirection to the account detail on success, or the account
|
||||
creation form on error.
|
||||
"""
|
||||
form = AccountForm(request.form)
|
||||
if not form.validate():
|
||||
for key in form.errors:
|
||||
for error in form.errors[key]:
|
||||
flash(error, "error")
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(url_for("accounting.account.create"))
|
||||
account: Account = Account()
|
||||
form.populate_obj(account)
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is added successfully"), "success")
|
||||
return redirect(url_for("accounting.account.detail", account=account))
|
||||
|
||||
|
||||
@bp.get("/<account:account>", endpoint="detail")
|
||||
@has_permission(can_view)
|
||||
def show_account_detail(account: Account) -> str:
|
||||
"""Shows the account detail.
|
||||
|
||||
:return: The account detail.
|
||||
"""
|
||||
return render_template("accounting/account/detail.html", obj=account)
|
||||
|
||||
|
||||
@bp.get("/<account:account>/edit", endpoint="edit")
|
||||
@has_permission(can_edit)
|
||||
def show_account_edit_form(account: Account) -> str:
|
||||
"""Shows the form to edit an account.
|
||||
|
||||
:return: The form to edit an account.
|
||||
"""
|
||||
form: AccountForm
|
||||
if "form" in session:
|
||||
form = AccountForm(ImmutableMultiDict(parse_qsl(session["form"])))
|
||||
del session["form"]
|
||||
form.validate()
|
||||
else:
|
||||
form = AccountForm(obj=account)
|
||||
return render_template("accounting/account/edit.html",
|
||||
account=account, form=form)
|
||||
|
||||
|
||||
@bp.post("/<account:account>/update", endpoint="update")
|
||||
@has_permission(can_edit)
|
||||
def update_account(account: Account) -> redirect:
|
||||
"""Updates an account.
|
||||
|
||||
:return: The redirection to the account detail on success, or the account
|
||||
edit form on error.
|
||||
"""
|
||||
form = AccountForm(request.form)
|
||||
if not form.validate():
|
||||
for key in form.errors:
|
||||
for error in form.errors[key]:
|
||||
flash(error, "error")
|
||||
session["form"] = urlencode(list(request.form.items()))
|
||||
return redirect(url_for("accounting.account.edit", account=account))
|
||||
with db.session.no_autoflush:
|
||||
form.populate_obj(account)
|
||||
if not db.session.is_modified(account):
|
||||
flash(lazy_gettext("The account was not modified."), "success")
|
||||
return redirect(url_for("accounting.account.detail", account=account))
|
||||
form.post_update(account)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is updated successfully."), "success")
|
||||
return redirect(url_for("accounting.account.detail", account=account))
|
||||
|
||||
|
||||
@bp.post("/<account:account>/delete", endpoint="delete")
|
||||
@has_permission(can_edit)
|
||||
def delete_account(account: Account) -> redirect:
|
||||
"""Deletes an account.
|
||||
|
||||
:return: The redirection to the account list on success, or the account
|
||||
detail on error.
|
||||
"""
|
||||
for l10n in account.l10n:
|
||||
db.session.delete(l10n)
|
||||
db.session.delete(account)
|
||||
sort_accounts_in(account.base_code, account.id)
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
||||
return redirect(url_for("accounting.account.list"))
|
Reference in New Issue
Block a user