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