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

@ -18,16 +18,65 @@
""" """
import typing as t import typing as t
from abc import ABC, abstractmethod
import sqlalchemy as sa
from flask import Flask, Blueprint 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_view_func: t.Callable[[], bool] | None = None,
can_edit_func: t.Callable[[], bool] | None = None) -> None: can_edit_func: t.Callable[[], bool] | None = None) -> None:
"""Initialize the application. """Initialize the application.
:param app: The Flask application. :param app: The Flask application.
:param user_utils: The user utilities.
:param url_prefix: The URL prefix of the accounting application. :param url_prefix: The URL prefix of the accounting application.
:param can_view_func: A callback that returns whether the current user can :param can_view_func: A callback that returns whether the current user can
view the accounting data. 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 # The database instance must be set before loading everything
# in the application. # in the application.
from .database import set_db from .database import set_db
set_db(app.extensions["sqlalchemy"]) set_db(app.extensions["sqlalchemy"], user_utils)
bp: Blueprint = Blueprint("accounting", __name__, bp: Blueprint = Blueprint("accounting", __name__,
url_prefix=url_prefix, url_prefix=url_prefix,
@ -54,4 +103,7 @@ def init_app(app: Flask, url_prefix: str = "/accounting",
from . import base_account from . import base_account
base_account.init_app(app, bp) base_account.init_app(app, bp)
from . import account
account.init_app(app, bp)
app.register_blueprint(bp) 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,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"))

View File

@ -19,6 +19,8 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .models import BaseAccount
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.

View File

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

View File

@ -0,0 +1,96 @@
/* 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;
}
/** 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,85 @@
{#
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="{{ request.args.get("next") or url_for("accounting.account.list") }}">
<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) + ("?next=" + request.args["next"] if "next" in request.args else "") }}">
<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) + ("?next=" + request.args["next"] if "next" in request.args else "") }}">
<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() }}">
<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>
<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) + ("?next=" + request.args["next"] if "next" in request.args else "") }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.update", account=account) }}{% endblock %}

View File

@ -0,0 +1,115 @@
{#
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="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,73 @@
{#
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_("Accounts") }}{% endblock %}{% endblock %}
{% block content %}
{% if can_edit_accounting() %}
<div class="btn-group mb-3 d-none d-md-block">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create") }}">
<i class="fa-solid fa-user-plus"></i>
{{ A_("New") }}
</a>
</div>
<div class="d-md-none material-fab">
<a class="btn btn-primary" href="{{ url_for("accounting.account.create") }}">
<i class="fa-solid fa-plus"></i>
</a>
</div>
{% endif %}
<form action="{{ url_for("accounting.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>
{% 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) }}">
{{ item }}
</a>
{% endfor %}
</div>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %}

View File

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

View File

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

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: if can_edit_func is not None:
__can_edit_func = can_edit_func __can_edit_func = can_edit_func
app.jinja_env.globals["can_view_accounting"] = __can_view_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

@ -26,6 +26,7 @@ from flask.cli import with_appcontext
from flask_babel_js import BabelJS from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from sqlalchemy import Column
bp: Blueprint = Blueprint("home", __name__) bp: Blueprint = Blueprint("home", __name__)
babel_js: BabelJS = BabelJS() babel_js: BabelJS = BabelJS()
@ -51,6 +52,7 @@ def create_app(is_testing: bool = False) -> Flask:
}) })
if is_testing: if is_testing:
app.config["TESTING"] = True app.config["TESTING"] = True
app.config["SQLALCHEMY_ECHO"] = True
babel_js.init_app(app) babel_js.init_app(app)
csrf.init_app(app) csrf.init_app(app)
@ -65,11 +67,33 @@ def create_app(is_testing: bool = False) -> Flask:
from . import auth from . import auth
auth.init_app(app) 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 \ can_view: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor"] and auth.current_user().username in ["viewer", "editor"]
can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \ can_edit: t.Callable[[], bool] = lambda: auth.current_user() is not None \
and auth.current_user().username == "editor" 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 return app

View File

@ -35,6 +35,13 @@ class User(db.Model):
username = db.Column(db.String, nullable=False, unique=True) username = db.Column(db.String, nullable=False, unique=True)
"""The username.""" """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") @bp.get("login", endpoint="login-form")
def show_login_form() -> str: def show_login_form() -> str: