diff --git a/src/accounting/__init__.py b/src/accounting/__init__.py index 203dc2f..edca8b5 100644 --- a/src/accounting/__init__.py +++ b/src/accounting/__init__.py @@ -18,16 +18,65 @@ """ import typing as t +from abc import ABC, abstractmethod +import sqlalchemy as sa from flask import Flask, Blueprint +from flask_sqlalchemy.model import Model + +T = t.TypeVar("T", bound=Model) -def init_app(app: Flask, url_prefix: str = "/accounting", +class AbstractUserUtils(t.Generic[T], ABC): + """The abstract user utilities.""" + + @property + @abstractmethod + def cls(self) -> t.Type[T]: + """Returns the user class. + + :return: The user class. + """ + + @property + @abstractmethod + def pk_column(self) -> sa.Column: + """Returns the primary key column. + + :return: The primary key column. + """ + + @property + @abstractmethod + def current_user(self) -> T: + """Returns the current user. + + :return: The current user. + """ + + @abstractmethod + def get_by_username(self, username: str) -> T | None: + """Returns the user by her username. + + :return: The user by her username, or None if the user was not found. + """ + + @abstractmethod + def get_pk(self, user: T) -> int: + """Returns the primary key of the user. + + :return: The primary key of the user. + """ + + +def init_app(app: Flask, user_utils: AbstractUserUtils, + url_prefix: str = "/accounting", can_view_func: t.Callable[[], bool] | None = None, can_edit_func: t.Callable[[], bool] | None = None) -> None: """Initialize the application. :param app: The Flask application. + :param user_utils: The user utilities. :param url_prefix: The URL prefix of the accounting application. :param can_view_func: A callback that returns whether the current user can view the accounting data. @@ -38,7 +87,7 @@ def init_app(app: Flask, url_prefix: str = "/accounting", # The database instance must be set before loading everything # in the application. from .database import set_db - set_db(app.extensions["sqlalchemy"]) + set_db(app.extensions["sqlalchemy"], user_utils) bp: Blueprint = Blueprint("accounting", __name__, url_prefix=url_prefix, @@ -54,4 +103,7 @@ def init_app(app: Flask, url_prefix: str = "/accounting", from . import base_account base_account.init_app(app, bp) + from . import account + account.init_app(app, bp) + app.register_blueprint(bp) diff --git a/src/accounting/account/__init__.py b/src/accounting/account/__init__.py new file mode 100644 index 0000000..4b4ae56 --- /dev/null +++ b/src/accounting/account/__init__.py @@ -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) diff --git a/src/accounting/account/commands.py b/src/accounting/account/commands.py new file mode 100644 index 0000000..f44fbfc --- /dev/null +++ b/src/accounting/account/commands.py @@ -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() diff --git a/src/accounting/account/converters.py b/src/accounting/account/converters.py new file mode 100644 index 0000000..832520b --- /dev/null +++ b/src/accounting/account/converters.py @@ -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 diff --git a/src/accounting/account/forms.py b/src/accounting/account/forms.py new file mode 100644 index 0000000..a85717b --- /dev/null +++ b/src/accounting/account/forms.py @@ -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 diff --git a/src/accounting/account/models.py b/src/accounting/account/models.py new file mode 100644 index 0000000..4ee5fc4 --- /dev/null +++ b/src/accounting/account/models.py @@ -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) diff --git a/src/accounting/account/query.py b/src/accounting/account/query.py new file mode 100644 index 0000000..88846f2 --- /dev/null +++ b/src/accounting/account/query.py @@ -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() diff --git a/src/accounting/account/views.py b/src/accounting/account/views.py new file mode 100644 index 0000000..fea4035 --- /dev/null +++ b/src/accounting/account/views.py @@ -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("/", 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("//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("//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("//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")) diff --git a/src/accounting/base_account/__init__.py b/src/accounting/base_account/__init__.py index c401932..24f09a0 100644 --- a/src/accounting/base_account/__init__.py +++ b/src/accounting/base_account/__init__.py @@ -19,6 +19,8 @@ """ from flask import Flask, Blueprint +from .models import BaseAccount + def init_app(app: Flask, bp: Blueprint) -> None: """Initialize the application. diff --git a/src/accounting/database.py b/src/accounting/database.py index 24ad4d8..77423db 100644 --- a/src/accounting/database.py +++ b/src/accounting/database.py @@ -22,17 +22,24 @@ initialized at compile time, but as a submodule it is only available at run time. """ + from flask_sqlalchemy import SQLAlchemy +from accounting import AbstractUserUtils + db: SQLAlchemy """The database instance.""" +user_utils: AbstractUserUtils +"""The user utilities.""" -def set_db(new_db: SQLAlchemy) -> None: +def set_db(new_db: SQLAlchemy, new_user_utils: AbstractUserUtils) -> None: """Sets the database instance. :param new_db: The database instance. + :param new_user_utils: The user utilities. :return: None. """ - global db + global db, user_utils db = new_db + user_utils = new_user_utils diff --git a/src/accounting/static/css/style.css b/src/accounting/static/css/style.css new file mode 100644 index 0000000..d13acaa --- /dev/null +++ b/src/accounting/static/css/style.css @@ -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; + } +} diff --git a/src/accounting/static/js/account-form.js b/src/accounting/static/js/account-form.js new file mode 100644 index 0000000..49dbc42 --- /dev/null +++ b/src/accounting/static/js/account-form.js @@ -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; +} diff --git a/src/accounting/templates/accounting/account/create.html b/src/accounting/templates/accounting/account/create.html new file mode 100644 index 0000000..98315a9 --- /dev/null +++ b/src/accounting/templates/accounting/account/create.html @@ -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 %} diff --git a/src/accounting/templates/accounting/account/detail.html b/src/accounting/templates/accounting/account/detail.html new file mode 100644 index 0000000..4e8aa4f --- /dev/null +++ b/src/accounting/templates/accounting/account/detail.html @@ -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 %} + +
+ + + {{ A_("Back") }} + + {% if can_edit_accounting() %} + + + {{ A_("Settings") }} + + + {% endif %} +
+ +{% if can_edit_accounting() %} +
+ + + +
+{% endif %} + +{% if can_edit_accounting() %} +
+ + +
+{% endif %} + + + +{% endblock %} diff --git a/src/accounting/templates/accounting/account/edit.html b/src/accounting/templates/accounting/account/edit.html new file mode 100644 index 0000000..098207b --- /dev/null +++ b/src/accounting/templates/accounting/account/edit.html @@ -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 %} diff --git a/src/accounting/templates/accounting/account/include/form.html b/src/accounting/templates/accounting/account/include/form.html new file mode 100644 index 0000000..0f36bf1 --- /dev/null +++ b/src/accounting/templates/accounting/account/include/form.html @@ -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 %} + +{% endblock %} + +{% block content %} + + + +
+ {{ form.csrf_token }} + {% if "next" in request.args %} + + {% endif %} +
+ +
+ +
+ {% if form.base_code.data %} + {% if form.base_code.errors %} + {{ A_("(Unknown)") }} + {% else %} + {{ form.selected_base }} + {% endif %} + {% endif %} +
+
+
{% if form.base_code.errors %}{{ form.base_code.errors[0] }}{% endif %}
+
+ +
+ + +
{% if form.title.errors %}{{ form.title.errors[0] }}{% endif %}
+
+ +
+ +
+ +
+ +
+
+ + + +{% endblock %} diff --git a/src/accounting/templates/accounting/account/list.html b/src/accounting/templates/accounting/account/list.html new file mode 100644 index 0000000..0e16ac8 --- /dev/null +++ b/src/accounting/templates/accounting/account/list.html @@ -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() %} + + +
+ + + +
+{% endif %} + +
+
+
+
+ + +
+
+
+
+ +{% if list %} + {% include "accounting/include/pagination.html" %} + +
+ {% for item in list %} + + {{ item }} + + {% endfor %} +
+{% else %} +

{{ A_("There is no data.") }}

+{% endif %} + +{% endblock %} diff --git a/src/accounting/templates/accounting/base.html b/src/accounting/templates/accounting/base.html index 1c13acf..a3cb338 100644 --- a/src/accounting/templates/accounting/base.html +++ b/src/accounting/templates/accounting/base.html @@ -21,6 +21,10 @@ First written: 2023/1/27 #} {% extends "base.html" %} +{% block styles %} + +{% endblock %} + {% block scripts %} {% block accounting_scripts %}{% endblock %} diff --git a/src/accounting/templates/accounting/include/nav.html b/src/accounting/templates/accounting/include/nav.html index 39cac80..e52175d 100644 --- a/src/accounting/templates/accounting/include/nav.html +++ b/src/accounting/templates/accounting/include/nav.html @@ -26,6 +26,12 @@ First written: 2023/1/26 {{ A_("Accounting") }}