14 Commits

Author SHA1 Message Date
15007ada4f Advanced to version 0.1.0. 2023-02-03 12:57:53 +08:00
e50d6267d5 Revised the options.package_data section in setup.cfg to include the static CSS and JavaScript files. 2023-02-03 12:55:59 +08:00
2359842e80 Added to query the "offset needed" in the get_account_query function. 2023-02-03 12:55:58 +08:00
9497fa371e Updated the translation. 2023-02-03 12:55:58 +08:00
b0ef4fb059 Added the "offset needed" option and badge in the account list, account detail and account form. 2023-02-03 12:55:57 +08:00
9f63db174c Merged the search box to the action button group in the base account list and account list. 2023-02-03 12:55:56 +08:00
9b22331a5a Added the base account detail page that shows its descendant accounts. 2023-02-03 12:55:56 +08:00
cb0dea58f1 Fixed the documentation of the AccountConverter converter. 2023-02-03 12:55:55 +08:00
241ad337c8 Added the "accounting.utils.next_uri" utilities to fixed how the next URI works. 2023-02-03 12:55:55 +08:00
2964f206a6 Revised the test site to use the secret key specified in the environment variables when possible, so that the secret key and session keeps in the development environment. 2023-02-03 12:55:55 +08:00
9118b631e4 Updated the translation. 2023-02-03 12:55:55 +08:00
ce6c8508df Revised the titles of the base account list and the account list, to be clear. 2023-02-03 12:55:54 +08:00
e29b99b0a7 Merged the "accounting.base_account.models" and "accounting.account.models" modules into the new "accounting.modules" module, so that the data models can reference one another. 2023-02-03 12:55:54 +08:00
e9f6b769f4 Added the account management. 2023-02-03 12:55:54 +08:00
35 changed files with 1988 additions and 117 deletions

View File

@ -17,7 +17,7 @@
[metadata]
name = mia-accounting-flask
version = 0.0.0
version = 0.1.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project.
@ -50,7 +50,6 @@ tests_require =
[options.package_data]
accounting =
static/**
templates/**
translations/*/LC_MESSAGES/*.mo
accounting.base_account =
templates/**

View File

@ -18,16 +18,65 @@
"""
import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask import Flask, Blueprint
from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model)
def init_app(app: Flask, url_prefix: str = "/accounting",
class AbstractUserUtils(t.Generic[T], ABC):
"""The abstract user utilities."""
@property
@abstractmethod
def cls(self) -> t.Type[T]:
"""Returns the user class.
:return: The user class.
"""
@property
@abstractmethod
def pk_column(self) -> sa.Column:
"""Returns the primary key column.
:return: The primary key column.
"""
@property
@abstractmethod
def current_user(self) -> T:
"""Returns the current user.
:return: The current user.
"""
@abstractmethod
def get_by_username(self, username: str) -> T | None:
"""Returns the user by her username.
:return: The user by her username, or None if the user was not found.
"""
@abstractmethod
def get_pk(self, user: T) -> int:
"""Returns the primary key of the user.
:return: The primary key of the user.
"""
def init_app(app: Flask, user_utils: AbstractUserUtils,
url_prefix: str = "/accounting",
can_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initialize the application.
:param app: The Flask application.
:param user_utils: The user utilities.
:param url_prefix: The URL prefix of the accounting application.
:param can_view_func: A callback that returns whether the current user can
view the accounting data.
@ -38,7 +87,7 @@ def init_app(app: Flask, url_prefix: str = "/accounting",
# The database instance must be set before loading everything
# in the application.
from .database import set_db
set_db(app.extensions["sqlalchemy"])
set_db(app.extensions["sqlalchemy"], user_utils)
bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix,
@ -54,4 +103,12 @@ def init_app(app: Flask, url_prefix: str = "/accounting",
from . import base_account
base_account.init_app(app, bp)
from . import account
account.init_app(app, bp)
from .utils.next_url import append_next, inherit_next, or_next
bp.add_app_template_filter(append_next, "append_next")
bp.add_app_template_filter(inherit_next, "inherit_next")
bp.add_app_template_filter(or_next, "or_next")
app.register_blueprint(bp)

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,127 @@
# 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 accounting.models import BaseAccount, Account, AccountL10n
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 accounting.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 an account code to an account.
:param value: The account code.
:return: The corresponding 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,129 @@
# 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.database import db, user_utils
from accounting.locale import lazy_gettext
from accounting.models import BaseAccount, Account
from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text
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,55 @@
# 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.locale import gettext
from accounting.models import Account, AccountL10n
from accounting.utils.query import parse_query_keywords
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}
sub_conditions: list[sa.BinaryExpression] \
= [Account.base_code.contains(k),
Account.title_l10n.contains(k),
code.contains(k),
Account.id.in_(l10n_matches)]
if k in gettext("Offset needed"):
sub_conditions.append(Account.is_offset_needed)
conditions.append(sa.or_(*sub_conditions))
return Account.query.filter(*conditions)\
.order_by(Account.base_code, Account.no).all()

View File

@ -0,0 +1,164 @@
# 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.models import Account, BaseAccount
from accounting.utils.next_url import inherit_next, or_next
from accounting.utils.pagination import Pagination
from accounting.utils.permission import can_view, has_permission, can_edit
from .forms import AccountForm, sort_accounts_in
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 .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(inherit_next(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(inherit_next(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(inherit_next(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(inherit_next(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(inherit_next(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(or_next(url_for("accounting.account.list")))

View File

@ -27,6 +27,9 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param app: The Flask application.
:return: None.
"""
from .converters import BaseAccountConverter
app.url_map.converters["baseAccount"] = BaseAccountConverter
from .views import bp as base_account_bp
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")

View File

@ -21,7 +21,7 @@ import click
from flask.cli import with_appcontext
from accounting.database import db
from .models import BaseAccount, BaseAccountL10n
from accounting.models import BaseAccount, BaseAccountL10n
BaseAccountData = tuple[int, str, str, str]
"""The format of the base account data, as a list of (code, English,

View File

@ -0,0 +1,48 @@
# 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 path converters for the base account management.
"""
from flask import abort
from werkzeug.routing import BaseConverter
from accounting.database import db
from accounting.models import BaseAccount
class BaseAccountConverter(BaseConverter):
"""The account converter to convert the account code and to the
corresponding base account in the routes."""
def to_python(self, value: str) -> BaseAccount:
"""Converts an account code to a base account.
:param value: The account code.
:return: The corresponding base account.
"""
account: BaseAccount | None = db.session.get(BaseAccount, value)
if account is None:
abort(404)
return account
def to_url(self, value: BaseAccount) -> str:
"""Converts a base account to its code.
:param value: The base account.
:return: The code.
"""
return value.code

View File

@ -1,73 +0,0 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The data models for the base account management.
"""
from flask import current_app
from flask_babel import get_locale
from accounting.database import db
class BaseAccount(db.Model):
"""A base account."""
__tablename__ = "accounting_base_accounts"
"""The table name."""
code = db.Column(db.String, nullable=False, primary_key=True)
"""The code."""
title_l10n = db.Column("title", db.String, nullable=False)
"""The title."""
l10n = db.relationship("BaseAccountL10n", back_populates="account",
lazy=False)
"""The localized titles."""
def __str__(self) -> str:
"""Returns the string representation of the base account.
:return: The string representation of the base account.
"""
return F"{self.code} {self.title}"
@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
class BaseAccountL10n(db.Model):
"""A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n"
"""The table name."""
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The code of the account."""
account = db.relationship(BaseAccount, back_populates="l10n")
"""The account."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
title = db.Column(db.String, nullable=False)
"""The localized title."""

View File

@ -20,8 +20,8 @@
import sqlalchemy as sa
from flask import request
from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.query import parse_query_keywords
from .models import BaseAccount, BaseAccountL10n
def get_base_account_query() -> list[BaseAccount]:

View File

@ -19,6 +19,7 @@
"""
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
@ -33,9 +34,19 @@ def list_accounts() -> str:
:return: The account list.
"""
from .models import BaseAccount
from .query import get_base_account_query
accounts: list[BaseAccount] = get_base_account_query()
pagination: Pagination = Pagination[BaseAccount](accounts)
return render_template("accounting/base-account/list.html",
list=pagination.list, pagination=pagination)
@bp.get("/<baseAccount:account>", endpoint="detail")
@has_permission(can_view)
def show_account_detail(account: BaseAccount) -> str:
"""Shows the account detail.
:return: The account detail.
"""
return render_template("accounting/base-account/detail.html", obj=account)

View File

@ -22,17 +22,24 @@ initialized at compile time, but as a submodule it is only available at run
time.
"""
from flask_sqlalchemy import SQLAlchemy
from accounting import AbstractUserUtils
db: SQLAlchemy
"""The database instance."""
user_utils: AbstractUserUtils
"""The user utilities."""
def set_db(new_db: SQLAlchemy) -> None:
def set_db(new_db: SQLAlchemy, new_user_utils: AbstractUserUtils) -> None:
"""Sets the database instance.
:param new_db: The database instance.
:param new_user_utils: The user utilities.
:return: None.
"""
global db
global db, user_utils
db = new_db
user_utils = new_user_utils

310
src/accounting/models.py Normal file
View File

@ -0,0 +1,310 @@
# The Mia! Accounting Flask Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
# Copyright (c) 2023 imacat.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The data models.
"""
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.database import db, user_utils
user_cls: db.Model = user_utils.cls
user_pk_column: db.Column = user_utils.pk_column
class BaseAccount(db.Model):
"""A base account."""
__tablename__ = "accounting_base_accounts"
"""The table name."""
code = db.Column(db.String, nullable=False, primary_key=True)
"""The code."""
title_l10n = db.Column("title", db.String, nullable=False)
"""The title."""
l10n = db.relationship("BaseAccountL10n", back_populates="account",
lazy=False)
"""The localized titles."""
accounts = db.relationship("Account", back_populates="base")
"""The descendant accounts under the base account."""
def __str__(self) -> str:
"""Returns the string representation of the base account.
:return: The string representation of the base account.
"""
return F"{self.code} {self.title}"
@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
class BaseAccountL10n(db.Model):
"""A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n"
"""The table name."""
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
ondelete="CASCADE"),
nullable=False, primary_key=True)
"""The code of the account."""
account = db.relationship(BaseAccount, back_populates="l10n")
"""The account."""
locale = db.Column(db.String, nullable=False, primary_key=True)
"""The locale."""
title = db.Column(db.String, nullable=False)
"""The localized title."""
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, back_populates="accounts")
"""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,106 @@
/* The Mia! Accounting Flask Project
* style.css: The style sheet for the accounting application.
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/1
*/
.clickable {
cursor: pointer;
}
.btn-group .btn .search-input {
min-height: calc(1em + .5rem + 2px);
padding: 0 0.5rem;
}
.btn-group .btn .search-label button {
border: none;
background-color: transparent;
color: inherit;
padding-right: 0;
}
/** The account management */
.account {
padding: 2em 1.5em;
margin: 1em;
background-color: #E9ECEF;
border-radius: 0.3em;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
}
.account .account-title {
font-size: 1.8rem;
font-weight: bolder;
}
.account .account-code {
font-size: 1.4rem;
color: #373b3e;
}
.list-base-selector {
height: 20rem;
overflow-y: scroll;
}
/* The Material Design text field (floating form control in Bootstrap) */
.material-text-field {
position: relative;
min-height: calc(3.5rem + 2px);
padding-top: 1.625rem;
}
.material-text-field > .form-label {
position: absolute;
top: 0;
left: 0;
height: calc(3.5rem + 2px);
padding: 1rem 0.75rem;
transform-origin: 0 0;
transition: opacity .1s ease-in-out,transform .1s ease-in-out;
}
.material-text-field.not-empty > .form-label {
opacity: 0.65;
transform: scale(.85) translateY(-.5rem) translateX(.15rem);
}
/* The Material Design floating action buttons */
.material-fab {
position: fixed;
right: 2rem;
bottom: 1rem;
z-index: 10;
flex-direction: column-reverse;
}
.material-fab .btn {
border-radius: 50%;
transform: scale(1.5);
box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0,0,0,.12);
display: block;
margin-top: 2.5rem;
}
.material-fab .btn:hover, .material-fab .btn:focus {
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0,0,0,.12);
}
/* The Material Design form switch */
@media(max-width:767px) {
.form-switch {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
padding-left: 0;
}
}

View File

@ -0,0 +1,136 @@
/* The Mia! Accounting Flask Project
* account-form.js: The JavaScript for the account form
*/
/* 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.
*/
/* Author: imacat@mail.imacat.idv.tw (imacat)
* First written: 2023/2/1
*/
// Initializes the page JavaScript.
document.addEventListener("DOMContentLoaded", function () {
initializeBaseAccountSelector()
document.getElementById("account-base-code")
.onchange = validateBase;
document.getElementById("account-title")
.onchange = validateTitle;
document.getElementById("account-form")
.onsubmit = validateForm;
});
/**
* Initializes the base account selector.
*
* @private
*/
function initializeBaseAccountSelector() {
const selector = document.getElementById("select-base-modal");
const base = document.getElementById("account-base");
const baseCode = document.getElementById("account-base-code");
const baseContent = document.getElementById("account-base-content");
const options = Array.from(document.getElementsByClassName("list-group-item-base"));
const btnClear = document.getElementById("btn-clear-base");
selector.addEventListener("show.bs.modal", function () {
base.classList.add("not-empty");
options.forEach(function (item) {
item.classList.remove("active");
});
const selected = document.getElementById("list-group-item-base-" + baseCode.value);
if (selected !== null) {
selected.classList.add("active");
}
});
selector.addEventListener("hidden.bs.modal", function () {
if (baseCode.value === "") {
base.classList.remove("not-empty");
}
});
options.forEach(function (option) {
option.onclick = function () {
baseCode.value = option.dataset.code;
baseContent.innerText = option.dataset.content;
btnClear.classList.add("btn-danger");
btnClear.classList.remove("btn-secondary")
btnClear.disabled = false;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
};
});
btnClear.onclick = function () {
baseCode.value = "";
baseContent.innerText = "";
btnClear.classList.add("btn-secondary")
btnClear.classList.remove("btn-danger");
btnClear.disabled = true;
validateBase();
bootstrap.Modal.getInstance(selector).hide();
}
}
/**
* Validates the form.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateForm() {
let isValid = true;
isValid = validateBase() && isValid;
isValid = validateTitle() && isValid;
return isValid;
}
/**
* Validates the base account.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateBase() {
const field = document.getElementById("account-base-code");
const error = document.getElementById("account-base-code-error");
const displayField = document.getElementById("account-base");
field.value = field.value.trim();
if (field.value === "") {
displayField.classList.add("is-invalid");
error.innerText = A_("Please select the base account.");
return false;
}
displayField.classList.remove("is-invalid");
error.innerText = "";
return true;
}
/**
* Validates the title.
*
* @returns {boolean} true if valid, or false otherwise
* @private
*/
function validateTitle() {
const field = document.getElementById("account-title");
const error = document.getElementById("account-title-error");
field.value = field.value.trim();
if (field.value === "") {
field.classList.add("is-invalid");
error.innerText = A_("Please fill in the title.");
return false;
}
field.classList.remove("is-invalid");
error.innerText = "";
return true;
}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
create.html: The account creation form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/1
#}
{% extends "accounting/account/include/form.html" %}
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}

View File

@ -0,0 +1,93 @@
{#
The Mia! Accounting Flask Project
detail.html: The account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/31
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
{% if can_edit_accounting() %}
<a class="btn btn-primary d-none d-md-inline" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
<i class="fa-solid fa-gear"></i>
{{ A_("Settings") }}
</a>
<button class="btn btn-danger" type="button" data-bs-toggle="modal" data-bs-target="#delete-modal">
<i class="fa-solid fa-trash"></i>
{{ A_("Delete") }}
</button>
{% endif %}
</div>
{% if can_edit_accounting() %}
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.edit", account=obj)|inherit_next }}">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</div>
{% endif %}
{% if can_edit_accounting() %}
<form id="delete-form" action="{{ url_for("accounting.account.delete", account=obj) }}" method="post">
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="modal fade" id="delete-modal" tabindex="-1" aria-labelledby="delete-model-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="delete-model-label">{{ A_("Delete Account Confirmation") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ A_("Do you really want to delete this account?") }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
<button type="submit" class="btn btn-danger">{{ A_("Confirm") }}</button>
</div>
</div>
</div>
</div>
</form>
{% endif %}
<div class="account col-sm-6">
<div class="account-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div>
{% if obj.is_offset_needed %}
<div>
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
</div>
{% endif %}
<div class="small text-secondary fst-italic">
<div>{{ A_("Created") }} {{ obj.created_at }} {{ obj.created_by }}</div>
<div>{{ A_("Updated") }} {{ obj.updated_at }} {{ obj.updated_by }}</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,28 @@
{#
The Mia! Accounting Flask Project
edit.html: The account edit form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/1
#}
{% extends "accounting/account/include/form.html" %}
{% block header %}{% block title %}{{ A_("%(account)s Settings", account=account) }}{% endblock %}{% endblock %}
{% block back_url %}{{ url_for("accounting.account.detail", account=account)|inherit_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}

View File

@ -0,0 +1,122 @@
{#
The Mia! Accounting Flask Project
form.html: The account form
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/1
#}
{% extends "accounting/base.html" %}
{% block accounting_scripts %}
<script src="{{ url_for("accounting.static", filename="js/account-form.js") }}"></script>
{% endblock %}
{% block content %}
<div class="btn-group btn-actions mb-3">
<a class="btn btn-primary" role="button" href="{% block back_url %}{% endblock %}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<form id="account-form" action="{% block action_url %}{% endblock %}" method="post">
{{ form.csrf_token }}
{% if "next" in request.args %}
<input type="hidden" name="next" value="{{ request.args["next"] }}">
{% endif %}
<div class="form-floating mb-3">
<input id="account-base-code" type="hidden" name="base_code" value="{{ "" if form.base_code.data is none else form.base_code.data }}">
<div id="account-base" class="form-control clickable material-text-field {% if form.base_code.data %} not-empty {% endif %} {% if form.base_code.errors %} is-invalid {% endif %}" data-bs-toggle="modal" data-bs-target="#select-base-modal">
<label id="account-base-label" class="form-label" for="account-base">{{ A_("Base account") }}</label>
<div id="account-base-content">
{% if form.base_code.data %}
{% if form.base_code.errors %}
{{ A_("(Unknown)") }}
{% else %}
{{ form.selected_base }}
{% endif %}
{% endif %}
</div>
</div>
<div id="account-base-code-error" class="invalid-feedback">{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}</div>
</div>
<div class="form-floating mb-3">
<input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ "" if form.title.data is none else form.title.data }}" placeholder=" " required="required">
<label class="form-label" for="account-title">{{ A_("Title") }}</label>
<div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}</div>
</div>
<div class="form-check form-switch mb-3">
<input id="account-is-offset-needed" class="form-check-input" type="checkbox" name="is_offset_needed" value="1" {% if form.is_offset_needed.data %} checked="checked" {% endif %}>
<label class="form-check-label" for="account-is-offset-needed">
{{ A_("The entries in the account need offsets.") }}
</label>
</div>
<div class="d-none d-md-block">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
{{ A_("Save") }}
</button>
</div>
<div class="d-md-none material-fab">
<button class="btn btn-primary" type="submit">
<i class="fa-solid fa-floppy-disk"></i>
</button>
</div>
</form>
<div class="modal fade" id="select-base-modal" tabindex="-1" aria-labelledby="select-base-model-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="base-selector-model-label">{{ A_("Select Base Account") }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="input-group mb-2">
<input id="select-base-query" class="form-control form-control-sm" type="search" placeholder=" " required="required" aria-label="Search">
<label class="input-group-text" for="select-base-query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</div>
<ul class="list-group list-base-selector">
{% for base in form.base_options %}
<li id="list-group-item-base-{{ base.code }}" class="list-group-item list-group-item-base clickable" data-code="{{ base.code }}" data-content="{{ base }}">
{{ base }}
</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ A_("Cancel") }}</button>
{% if form.base_code.data %}
<button id="btn-clear-base" type="button" class="btn btn-danger">{{ A_("Clear") }}</button>
{% else %}
<button id="btn-clear-base" type="button" class="btn btn-secondary" disabled="disabled">{{ A_("Clear") }}</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,71 @@
{#
The Mia! Accounting Flask Project
list.html: The account list
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/1/30
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Account Management") }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-2">
{% if can_edit_accounting() %}
<a class="btn btn-primary text-nowrap d-none d-md-block" href="{{ url_for("accounting.account.create")|append_next }}">
<i class="fa-solid fa-user-plus"></i>
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search">
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="search-input" class="search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% if can_edit_accounting() %}
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create")|append_next }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>
{% endif %}
{% if list %}
{% include "accounting/include/pagination.html" %}
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.account.detail", account=item)|append_next }}">
{{ item }}
{% if item.is_offset_needed %}
<span class="badge rounded-pill bg-info">{{ A_("Offset needed") }}</span>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,49 @@
{#
The Mia! Accounting Flask Project
detail.html: The base account detail
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.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/1
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ obj }}{% endblock %}{% endblock %}
{% block content %}
<div class="btn-group mb-3">
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
<i class="fa-solid fa-circle-chevron-left"></i>
{{ A_("Back") }}
</a>
</div>
<div class="account col-sm-6">
<div class="account-title">{{ obj.title }}</div>
<div class="account-code">{{ obj.code }}</div>
{% if obj.accounts %}
<div>
{% for account in obj.accounts %}
<a class="btn btn-primary" href="{{ url_for("accounting.account.detail", account=account)|append_next }}">
{{ account }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}

View File

@ -21,36 +21,32 @@ First written: 2023/1/26
#}
{% extends "accounting/base.html" %}
{% block header %}{% block title %}{{ A_("Base Accounts") }}{% endblock %}{% endblock %}
{% block header %}{% block title %}{{ A_("Base Account Managements") }}{% endblock %}{% endblock %}
{% block content %}
<form action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
<div class="row">
<div class="col-sm-3">
<div class="input-group mb-2">
<input id="query" class="form-control form-control-sm" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<button class="input-group-text" type="submit">
<label for="query">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</label>
</button>
</div>
</div>
</div>
</form>
<div class="btn-group mb-2">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search">
<input id="search-input" class="form-control form-control-sm search-input" type="search" name="q" value="{{ request.args["q"] if "q" in request.args else "" }}" placeholder=" " required="required" aria-label="Search">
<label for="search-input" class="search-label">
<button type="submit">
<i class="fa-solid fa-magnifying-glass"></i>
{{ A_("Search") }}
</button>
</label>
</form>
</div>
{% if list %}
{% include "accounting/include/pagination.html" %}
<ul class="list-group">
{% for item in list %}
<li class="list-group-item list-group-item-action">
{{ item }}
</li>
{% endfor %}
</ul>
<div class="list-group">
{% for item in list %}
<a class="list-group-item list-group-item-action" href="{{ url_for("accounting.base-account.detail", account=item)|append_next }}">
{{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}

View File

@ -21,6 +21,10 @@ First written: 2023/1/27
#}
{% extends "base.html" %}
{% block styles %}
<link rel="stylesheet" type="text/css" href="{{ url_for("accounting.static", filename="css/style.css") }}">
{% endblock %}
{% block scripts %}
<script src="{{ url_for("accounting.babel_catalog") }}"></script>
{% block accounting_scripts %}{% endblock %}

View File

@ -26,6 +26,12 @@ First written: 2023/1/26
{{ A_("Accounting") }}
</span>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.account.") %} active {% endif %}" href="{{ url_for("accounting.account.list") }}">
<i class="fa-solid fa-list"></i>
{{ A_("Accounts") }}
</a>
</li>
<li>
<a class="dropdown-item {% if request.endpoint.startswith("accounting.base-account.") %} active {% endif %}" href="{{ url_for("accounting.base-account.list") }}">
<i class="fa-solid fa-list"></i>

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-01-28 13:37+0800\n"
"PO-Revision-Date: 2023-01-28 13:37+0800\n"
"POT-Creation-Date: 2023-02-01 19:51+0800\n"
"PO-Revision-Date: 2023-02-01 19:52+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -19,23 +19,156 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n"
#: src/accounting/base_account/templates/accounting/base-account/list.html:24
#: src/accounting/templates/accounting/include/nav.html:32
msgid "Base Accounts"
msgstr "基本科目"
#: src/accounting/account/forms.py:39
msgid "The base account does not exist."
msgstr "沒有這個基本科目。"
#: src/accounting/base_account/templates/accounting/base-account/list.html:35
#: src/accounting/account/forms.py:48
#: src/accounting/static/js/account-form.js:110
msgid "Please select the base account."
msgstr "請選擇基本科目。"
#: src/accounting/account/forms.py:53
msgid "Please fill in the title"
msgstr "請填上標題。"
#: src/accounting/account/views.py:88
msgid "The account is added successfully"
msgstr "科目加好了。"
#: src/accounting/account/views.py:140
msgid "The account was not modified."
msgstr "科目未異動。"
#: src/accounting/account/views.py:145
msgid "The account is updated successfully."
msgstr "科目存好了。"
#: src/accounting/account/views.py:163
msgid "The account is deleted successfully."
msgstr "科目刪掉了"
#: src/accounting/static/js/account-form.js:130
msgid "Please fill in the title."
msgstr "請填上標題。"
#: src/accounting/templates/accounting/account/create.html:24
msgid "Add a New Account"
msgstr "新增科目"
#: src/accounting/templates/accounting/account/detail.html:31
#: src/accounting/templates/accounting/account/include/form.html:33
#: src/accounting/templates/accounting/base-account/detail.html:31
msgid "Back"
msgstr "回上頁"
#: src/accounting/templates/accounting/account/detail.html:36
msgid "Settings"
msgstr "設定"
#: src/accounting/templates/accounting/account/detail.html:40
msgid "Delete"
msgstr "刪除"
#: src/accounting/templates/accounting/account/detail.html:63
msgid "Delete Account Confirmation"
msgstr "科目刪除確認"
#: src/accounting/templates/accounting/account/detail.html:67
msgid "Do you really want to delete this account?"
msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/detail.html:70
#: src/accounting/templates/accounting/account/include/form.html:111
msgid "Cancel"
msgstr "取消"
#: src/accounting/templates/accounting/account/detail.html:71
msgid "Confirm"
msgstr "確定"
#: src/accounting/templates/accounting/account/detail.html:84
#: src/accounting/templates/accounting/account/list.html:62
msgid "Offset needed"
msgstr "逐筆核銷"
#: src/accounting/templates/accounting/account/detail.html:88
msgid "Created"
msgstr "建檔"
#: src/accounting/templates/accounting/account/detail.html:89
msgid "Updated"
msgstr "更新"
#: src/accounting/templates/accounting/account/edit.html:24
#, python-format
msgid "%(account)s Settings"
msgstr "%(account)s設定"
#: src/accounting/templates/accounting/account/list.html:24
msgid "Account Management"
msgstr "科目管理"
#: src/accounting/templates/accounting/account/list.html:32
msgid "New"
msgstr "新增"
#: src/accounting/templates/accounting/account/include/form.html:98
#: src/accounting/templates/accounting/account/list.html:40
#: src/accounting/templates/accounting/base-account/list.html:34
msgid "Search"
msgstr "搜尋"
#: src/accounting/base_account/templates/accounting/base-account/list.html:53
#: src/accounting/templates/accounting/account/list.html:68
#: src/accounting/templates/accounting/base-account/list.html:51
msgid "There is no data."
msgstr "沒有資料。"
#: src/accounting/templates/accounting/account/include/form.html:45
msgid "Base account"
msgstr "基本科目"
#: src/accounting/templates/accounting/account/include/form.html:49
msgid "(Unknown)"
msgstr "(不明)"
#: src/accounting/templates/accounting/account/include/form.html:61
msgid "Title"
msgstr "標題"
#: src/accounting/templates/accounting/account/include/form.html:68
msgid "The entries in the account need offsets."
msgstr "帳目要逐筆核銷。"
#: src/accounting/templates/accounting/account/include/form.html:75
msgid "Save"
msgstr "儲存"
#: src/accounting/templates/accounting/account/include/form.html:90
msgid "Select Base Account"
msgstr "選擇基本科目"
#: src/accounting/templates/accounting/account/include/form.html:113
#: src/accounting/templates/accounting/account/include/form.html:115
msgid "Clear"
msgstr "清除"
#: src/accounting/templates/accounting/base-account/list.html:24
msgid "Base Account Managements"
msgstr "基本科目管理"
#: src/accounting/templates/accounting/include/nav.html:26
msgid "Accounting"
msgstr "記帳"
#: src/accounting/templates/accounting/include/nav.html:32
msgid "Accounts"
msgstr "科目"
#: src/accounting/templates/accounting/include/nav.html:38
msgid "Base Accounts"
msgstr "基本科目"
#: src/accounting/utils/pagination.py:146
msgid "Previous"
msgstr "前一頁"

View File

@ -0,0 +1,75 @@
# 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 utilities to handle the next URL.
This module should not import any other module from the application.
"""
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
urlunparse
from flask import request
def append_next(uri: str) -> str:
"""Appends the current URI as the next URI to the query argument.
:param uri: The URI.
:return: The URI with the current URI appended as the next URI.
"""
next_uri: str = request.full_path if request.query_string else request.path
return __set_next(uri, next_uri)
def inherit_next(uri: str) -> str:
"""Inherits the current next URI to the query argument, if exists.
:param uri: The URI.
:return: The URI with the current next URI added at the query argument.
"""
next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.get("next")
if next_uri is None:
return uri
return __set_next(uri, next_uri)
def or_next(uri: str) -> str:
"""Returns the next URI, if exists, or the supplied URI.
:param uri: The URI.
:return: The next URI or the supplied URI.
"""
next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.get("next")
return uri if next_uri is None else next_uri
def __set_next(uri: str, next_uri: str) -> str:
"""Sets the next URI to the query arguments.
:param uri: The URI.
:param next_uri: The next URI.
:return: The URI with the next URI set.
"""
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] == "next"]
params.append(("next", next_uri))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)

View File

@ -98,3 +98,4 @@ def init_app(app: Flask, can_view_func: t.Callable[[], bool] | None = None,
if can_edit_func is not None:
__can_edit_func = can_edit_func
app.jinja_env.globals["can_view_accounting"] = __can_view_func
app.jinja_env.globals["can_edit_accounting"] = __can_edit_func

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 random ID mixin for the data models.
This module should not import any other module from the application.
"""
import typing as t
from secrets import randbelow
from accounting.database import db
def new_id(cls: t.Type):
"""Returns a new random ID for the data model.
:param cls: The data model.
:return: The generated new random ID.
"""
while True:
new: int = 100000000 + randbelow(900000000)
if db.session.get(cls, new) is None:
return new

View File

@ -0,0 +1,32 @@
# 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 text stripper for the form fields.
This module should not import any other module from the application.
"""
def strip_text(s: str | None) -> str | None:
"""The filter to strip the leading and trailing white spaces of text.
:param s: The text input string.
:return: The filtered string.
"""
if s is None:
return None
return s.strip()

View File

@ -54,7 +54,8 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.base_account.models import BaseAccount, BaseAccountL10n
from accounting.models import BaseAccountL10n
from accounting.models import BaseAccount
runner: FlaskCliRunner = self.app.test_cli_runner()
result: Result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)

View File

@ -17,6 +17,7 @@
"""The Mia! Accounting Flask demonstration website.
"""
import os
import typing as t
from secrets import token_urlsafe
@ -26,6 +27,7 @@ from flask.cli import with_appcontext
from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy import Column
bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS()
@ -44,13 +46,14 @@ def create_app(is_testing: bool = False) -> Flask:
app: Flask = Flask(__name__)
db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
app.config.from_mapping({
"SECRET_KEY": token_urlsafe(32),
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
})
if is_testing:
app.config["TESTING"] = True
app.config["SQLALCHEMY_ECHO"] = True
babel_js.init_app(app)
csrf.init_app(app)
@ -65,11 +68,33 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth
auth.init_app(app)
class UserUtils(accounting.AbstractUserUtils[auth.User]):
@property
def cls(self) -> t.Type[auth.User]:
return auth.User
@property
def pk_column(self) -> Column:
return auth.User.id
@property
def current_user(self) -> auth.User:
return auth.current_user()
def get_by_username(self, username: str) -> auth.User | None:
return auth.User.query\
.filter(auth.User.username == username).first()
def get_pk(self, user: auth.User) -> int:
return user.id
can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor"]
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username == "editor"
accounting.init_app(app, can_view_func=can_view, can_edit_func=can_edit)
accounting.init_app(app, user_utils=UserUtils(),
can_view_func=can_view, can_edit_func=can_edit)
return app

View File

@ -35,6 +35,13 @@ class User(db.Model):
username = db.Column(db.String, nullable=False, unique=True)
"""The username."""
def __str__(self) -> str:
"""Returns the string representation of the user.
:return: The string representation of the user.
"""
return self.username
@bp.get("login", endpoint="login-form")
def show_login_form() -> str: