Added the account management.

This commit is contained in:
2023-02-01 15:37:56 +08:00
parent 14638f574e
commit e9f6b769f4
24 changed files with 1549 additions and 5 deletions

View 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)

View 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()

View 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

View 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

View 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)

View 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()

View 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"))