Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
975b00bce9 | |||
d648538fbb | |||
dde9c38bb8 | |||
fecf33baa8 | |||
cea2a44226 | |||
b5d87d2387 | |||
784e7bde49 | |||
60280f415d | |||
f32d268494 | |||
1c1be87f3e | |||
589da0c1c6 | |||
8363ce6602 | |||
6a83f95c9f | |||
7dc754174c | |||
5238168b2d | |||
eeb05b8616 | |||
9920377266 | |||
9f9c40c30e | |||
d368c5e062 | |||
4aed2f6ba7 | |||
6876fdf75e | |||
d9624c7be6 | |||
8364025668 | |||
dd3690dd6a | |||
3312c835fd | |||
fce9d04896 | |||
c68786f78a | |||
581e803707 |
@ -17,7 +17,7 @@
|
||||
|
||||
[metadata]
|
||||
name = mia-accounting-flask
|
||||
version = 0.1.0
|
||||
version = 0.1.1
|
||||
author = imacat
|
||||
author_email = imacat@mail.imacat.idv.tw
|
||||
description = The Mia! Accounting Flask project.
|
||||
@ -36,7 +36,7 @@ classifiers =
|
||||
[options]
|
||||
package_dir =
|
||||
= src
|
||||
python_requires = >=3.10
|
||||
python_requires = >=3.11
|
||||
install_requires =
|
||||
flask
|
||||
Flask-SQLAlchemy
|
||||
|
@ -18,55 +18,11 @@
|
||||
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
from accounting.utils.user import AbstractUserUtils
|
||||
|
||||
|
||||
def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
@ -87,7 +43,9 @@ def init_app(app: Flask, user_utils: AbstractUserUtils,
|
||||
# The database instance must be set before loading everything
|
||||
# in the application.
|
||||
from .database import set_db
|
||||
set_db(app.extensions["sqlalchemy"], user_utils)
|
||||
set_db(app.extensions["sqlalchemy"])
|
||||
from .utils.user import init_user_utils
|
||||
init_user_utils(user_utils)
|
||||
|
||||
bp: Blueprint = Blueprint("accounting", __name__,
|
||||
url_prefix=url_prefix,
|
||||
|
@ -24,8 +24,9 @@ from secrets import randbelow
|
||||
import click
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from accounting.database import db, user_utils
|
||||
from accounting.database import db
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
from accounting.utils.user import has_user, get_user_pk
|
||||
|
||||
AccountData = tuple[int, str, int, str, str, str, bool]
|
||||
"""The format of the account data, as a list of (ID, base account code, number,
|
||||
@ -45,8 +46,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
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:
|
||||
if not has_user(value):
|
||||
raise click.BadParameter(f"User {value} does not exist.")
|
||||
return value
|
||||
|
||||
@ -58,7 +58,7 @@ def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
@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))
|
||||
creator_pk: int = get_user_pk(username)
|
||||
|
||||
bases: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(db.func.length(BaseAccount.code) == 4)\
|
||||
|
@ -18,15 +18,17 @@
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, BooleanField
|
||||
from wtforms.validators import DataRequired, ValidationError
|
||||
|
||||
from accounting.database import db, user_utils
|
||||
from accounting.database import db
|
||||
from accounting.locale import lazy_gettext
|
||||
from accounting.models import BaseAccount, Account
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.strip_text import strip_text
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
|
||||
|
||||
class BaseAccountExists:
|
||||
@ -67,14 +69,14 @@ class AccountForm(FlaskForm):
|
||||
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
|
||||
max_no: int = db.session.scalars(
|
||||
sa.select(sa.func.max(Account.no))
|
||||
.filter(Account.base_code == self.base_code.data)).one()
|
||||
obj.no = 1 if max_no is None else max_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)
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.created_by_id = current_user_pk
|
||||
obj.updated_by_id = current_user_pk
|
||||
if prev_base_code is not None \
|
||||
@ -87,7 +89,7 @@ class AccountForm(FlaskForm):
|
||||
|
||||
:return: None
|
||||
"""
|
||||
current_user_pk: int = user_utils.get_pk(user_utils.current_user)
|
||||
current_user_pk: int = get_current_user_pk()
|
||||
obj.updated_by_id = current_user_pk
|
||||
obj.updated_at = sa.func.now()
|
||||
if hasattr(self, "__post_update"):
|
||||
@ -127,3 +129,48 @@ def sort_accounts_in(base_code: str, exclude: int) -> None:
|
||||
for i in range(len(accounts)):
|
||||
if accounts[i].no != i + 1:
|
||||
accounts[i].no = i + 1
|
||||
|
||||
|
||||
class AccountReorderForm:
|
||||
"""The form to reorder the accounts."""
|
||||
|
||||
def __init__(self, base: BaseAccount):
|
||||
"""Constructs the form to reorder the accounts under a base account.
|
||||
|
||||
:param base: The base account.
|
||||
"""
|
||||
self.base: BaseAccount = base
|
||||
self.is_modified: bool = False
|
||||
|
||||
def save_order(self) -> None:
|
||||
"""Saves the order of the account.
|
||||
|
||||
:return:
|
||||
"""
|
||||
accounts: list[Account] = self.base.accounts
|
||||
|
||||
# Collects the specified order.
|
||||
orders: dict[Account, int] = {}
|
||||
for account in accounts:
|
||||
if f"{account.id}-no" in request.form:
|
||||
try:
|
||||
orders[account] = int(request.form[f"{account.id}-no"])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Missing and invalid orders are appended to the end.
|
||||
missing: list[Account] = [x for x in accounts if x not in orders]
|
||||
if len(missing) > 0:
|
||||
next_no: int = 1 if len(orders) == 0 else max(orders.values()) + 1
|
||||
for account in missing:
|
||||
orders[account] = next_no
|
||||
|
||||
# Sort by the specified order first, and their original order.
|
||||
accounts = sorted(accounts, key=lambda x: (orders[x], x.no, x.code))
|
||||
|
||||
# Update the orders.
|
||||
with db.session.no_autoflush:
|
||||
for i in range(len(accounts)):
|
||||
if accounts[i].no != i + 1:
|
||||
accounts[i].no = i + 1
|
||||
self.is_modified = True
|
||||
|
@ -29,7 +29,7 @@ from accounting.models import Account, BaseAccount
|
||||
from accounting.utils.next_url import inherit_next, or_next
|
||||
from accounting.utils.pagination import Pagination
|
||||
from accounting.utils.permission import can_view, has_permission, can_edit
|
||||
from .forms import AccountForm, sort_accounts_in
|
||||
from .forms import AccountForm, sort_accounts_in, AccountReorderForm
|
||||
|
||||
bp: Blueprint = Blueprint("account", __name__)
|
||||
"""The view blueprint for the account management."""
|
||||
@ -95,7 +95,8 @@ def add_account() -> redirect:
|
||||
def show_account_detail(account: Account) -> str:
|
||||
"""Shows the account detail.
|
||||
|
||||
:return: The account detail.
|
||||
:param account: The account.
|
||||
:return: The detail.
|
||||
"""
|
||||
return render_template("accounting/account/detail.html", obj=account)
|
||||
|
||||
@ -105,7 +106,8 @@ def show_account_detail(account: Account) -> str:
|
||||
def show_account_edit_form(account: Account) -> str:
|
||||
"""Shows the form to edit an account.
|
||||
|
||||
:return: The form to edit an account.
|
||||
:param account: The account.
|
||||
:return: The form to edit the account.
|
||||
"""
|
||||
form: AccountForm
|
||||
if "form" in session:
|
||||
@ -123,6 +125,7 @@ def show_account_edit_form(account: Account) -> str:
|
||||
def update_account(account: Account) -> redirect:
|
||||
"""Updates an account.
|
||||
|
||||
:param account: The account.
|
||||
:return: The redirection to the account detail on success, or the account
|
||||
edit form on error.
|
||||
"""
|
||||
@ -152,6 +155,7 @@ def update_account(account: Account) -> redirect:
|
||||
def delete_account(account: Account) -> redirect:
|
||||
"""Deletes an account.
|
||||
|
||||
:param account: The account.
|
||||
:return: The redirection to the account list on success, or the account
|
||||
detail on error.
|
||||
"""
|
||||
@ -162,3 +166,33 @@ def delete_account(account: Account) -> redirect:
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The account is deleted successfully."), "success")
|
||||
return redirect(or_next(url_for("accounting.account.list")))
|
||||
|
||||
|
||||
@bp.get("/bases/<baseAccount:base>", endpoint="order")
|
||||
@has_permission(can_view)
|
||||
def show_account_order(base: BaseAccount) -> str:
|
||||
"""Shows the order of the accounts under a same base account.
|
||||
|
||||
:param base: The base account.
|
||||
:return: The order of the accounts under the base account.
|
||||
"""
|
||||
return render_template("accounting/account/order.html", base=base)
|
||||
|
||||
|
||||
@bp.post("/bases/<baseAccount:base>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_accounts(base: BaseAccount) -> redirect:
|
||||
"""Reorders the accounts under a base account.
|
||||
|
||||
:param base: The base account.
|
||||
:return: The redirection to the incoming account or the account list. The
|
||||
reordering operation does not fail.
|
||||
"""
|
||||
form: AccountReorderForm = AccountReorderForm(base)
|
||||
form.save_order()
|
||||
if not form.is_modified:
|
||||
flash(lazy_gettext("The order was not modified."), "success")
|
||||
return redirect(or_next(url_for("accounting.account.list")))
|
||||
db.session.commit()
|
||||
flash(lazy_gettext("The order is updated successfully."), "success")
|
||||
return redirect(or_next(url_for("accounting.account.list")))
|
||||
|
@ -46,7 +46,8 @@ def list_accounts() -> str:
|
||||
def show_account_detail(account: BaseAccount) -> str:
|
||||
"""Shows the account detail.
|
||||
|
||||
:return: The account detail.
|
||||
:param account: The account.
|
||||
:return: The detail.
|
||||
"""
|
||||
return render_template("accounting/base-account/detail.html", obj=account)
|
||||
|
||||
|
@ -25,21 +25,15 @@ 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, new_user_utils: AbstractUserUtils) -> None:
|
||||
def set_db(new_db: SQLAlchemy) -> None:
|
||||
"""Sets the database instance.
|
||||
|
||||
:param new_db: The database instance.
|
||||
:param new_user_utils: The user utilities.
|
||||
:return: None.
|
||||
"""
|
||||
global db, user_utils
|
||||
global db
|
||||
db = new_db
|
||||
user_utils = new_user_utils
|
||||
|
@ -25,10 +25,8 @@ from flask import current_app
|
||||
from flask_babel import get_locale
|
||||
from sqlalchemy import text
|
||||
|
||||
from accounting.database import db, user_utils
|
||||
|
||||
user_cls: db.Model = user_utils.cls
|
||||
user_pk_column: db.Column = user_utils.pk_column
|
||||
from accounting.database import db
|
||||
from accounting.utils.user import user_cls, user_pk_column
|
||||
|
||||
|
||||
class BaseAccount(db.Model):
|
||||
@ -71,7 +69,9 @@ class BaseAccountL10n(db.Model):
|
||||
"""A localized base account title."""
|
||||
__tablename__ = "accounting_base_accounts_l10n"
|
||||
"""The table name."""
|
||||
account_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
|
||||
account_code = db.Column(db.String,
|
||||
db.ForeignKey(BaseAccount.code,
|
||||
onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False, primary_key=True)
|
||||
"""The code of the account."""
|
||||
@ -87,9 +87,11 @@ class Account(db.Model):
|
||||
"""An account."""
|
||||
__tablename__ = "accounting_accounts"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True)
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=False)
|
||||
"""The account ID."""
|
||||
base_code = db.Column(db.String, db.ForeignKey(BaseAccount.code,
|
||||
base_code = db.Column(db.String,
|
||||
db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False)
|
||||
"""The code of the base account."""
|
||||
@ -104,7 +106,9 @@ class Account(db.Model):
|
||||
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),
|
||||
created_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the creator."""
|
||||
created_by = db.relationship(user_cls, foreign_keys=created_by_id)
|
||||
@ -112,7 +116,9 @@ class Account(db.Model):
|
||||
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),
|
||||
updated_by_id = db.Column(db.Integer,
|
||||
db.ForeignKey(user_pk_column,
|
||||
onupdate="CASCADE"),
|
||||
nullable=False)
|
||||
"""The ID of the updator."""
|
||||
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
|
||||
@ -120,7 +126,6 @@ class Account(db.Model):
|
||||
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,"""
|
||||
@ -301,7 +306,8 @@ class Account(db.Model):
|
||||
class AccountL10n(db.Model):
|
||||
"""A localized account title."""
|
||||
__tablename__ = "accounting_accounts_l10n"
|
||||
account_id = db.Column(db.Integer, db.ForeignKey(Account.id,
|
||||
account_id = db.Column(db.Integer,
|
||||
db.ForeignKey(Account.id, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
nullable=False, primary_key=True)
|
||||
account = db.relationship(Account, back_populates="l10n")
|
||||
|
@ -23,7 +23,7 @@
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
initializeBaseAccountSelector()
|
||||
initializeBaseAccountSelector();
|
||||
document.getElementById("account-base-code")
|
||||
.onchange = validateBase;
|
||||
document.getElementById("account-title")
|
||||
|
39
src/accounting/static/js/account-order.js
Normal file
39
src/accounting/static/js/account-order.js
Normal file
@ -0,0 +1,39 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* account-order.js: The JavaScript for the account order
|
||||
*/
|
||||
|
||||
/* 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/2
|
||||
*/
|
||||
|
||||
// Initializes the page JavaScript.
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const list = document.getElementById("account-order-list");
|
||||
if (list !== null) {
|
||||
const onReorder = function () {
|
||||
const accounts = Array.from(list.children);
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const no = document.getElementById("account-order-" + accounts[i].dataset.id + "-no");
|
||||
const code = document.getElementById("account-order-" + accounts[i].dataset.id + "-code");
|
||||
no.value = String(i + 1);
|
||||
code.innerText = list.dataset.baseCode + "-" + ("000" + (i + 1)).slice(-3);
|
||||
}
|
||||
};
|
||||
initializeDragAndDropReordering(list, onReorder);
|
||||
}
|
||||
});
|
108
src/accounting/static/js/drag-and-drop-reorder.js
Normal file
108
src/accounting/static/js/drag-and-drop-reorder.js
Normal file
@ -0,0 +1,108 @@
|
||||
/* The Mia! Accounting Flask Project
|
||||
* drag-and-drop-reorder.js: The JavaScript for the reorder a list with drag-and-drop
|
||||
*/
|
||||
|
||||
/* 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/3
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initializes the drag-and-drop reordering on a list.
|
||||
*
|
||||
* @param list {HTMLElement} the list to be reordered
|
||||
* @param onReorder {(function())|*} The callback to reorder the items
|
||||
*/
|
||||
function initializeDragAndDropReordering(list, onReorder) {
|
||||
initializeMouseDragAndDropReordering(list, onReorder);
|
||||
initializeTouchDragAndDropReordering(list, onReorder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the drag-and-drop reordering with mouse.
|
||||
*
|
||||
* @param list {HTMLElement} the list to be reordered
|
||||
* @param onReorder {(function())|*} The callback to reorder the items
|
||||
* @private
|
||||
*/
|
||||
function initializeMouseDragAndDropReordering(list, onReorder) {
|
||||
const items = Array.from(list.children);
|
||||
let dragged = null;
|
||||
items.forEach(function (item) {
|
||||
item.draggable = true;
|
||||
item.addEventListener("dragstart", function () {
|
||||
dragged = item;
|
||||
dragged.classList.add("list-group-item-dark");
|
||||
});
|
||||
item.addEventListener("dragover", function () {
|
||||
onDragOver(dragged, item);
|
||||
onReorder();
|
||||
});
|
||||
item.addEventListener("dragend", function () {
|
||||
dragged.classList.remove("list-group-item-dark");
|
||||
dragged = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the drag-and-drop reordering with touch devices.
|
||||
*
|
||||
* @param list {HTMLElement} the list to be reordered
|
||||
* @param onReorder {(function())|*} The callback to reorder the items
|
||||
* @private
|
||||
*/
|
||||
function initializeTouchDragAndDropReordering(list, onReorder) {
|
||||
const items = Array.from(list.children);
|
||||
items.forEach(function (item) {
|
||||
item.addEventListener("touchstart", function () {
|
||||
item.classList.add("list-group-item-dark");
|
||||
});
|
||||
item.addEventListener("touchmove", function (event) {
|
||||
const touch = event.targetTouches[0];
|
||||
const target = document.elementFromPoint(touch.pageX, touch.pageY);
|
||||
onDragOver(item, target);
|
||||
onReorder();
|
||||
});
|
||||
item.addEventListener("touchend", function () {
|
||||
item.classList.remove("list-group-item-dark");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles when an item is dragged over the other item.
|
||||
*
|
||||
* @param dragged {Element} the item that was dragged
|
||||
* @param target {Element} the other item that was dragged over
|
||||
*/
|
||||
function onDragOver(dragged, target) {
|
||||
if (target.parentElement !== dragged.parentElement || target === dragged) {
|
||||
return;
|
||||
}
|
||||
let isBefore = false;
|
||||
for (let p = target; p !== null; p = p.previousSibling) {
|
||||
if (p === dragged) {
|
||||
isBefore = true;
|
||||
}
|
||||
}
|
||||
if (isBefore) {
|
||||
target.parentElement.insertBefore(dragged, target.nextSibling);
|
||||
} else {
|
||||
target.parentElement.insertBefore(dragged, target);
|
||||
}
|
||||
}
|
@ -35,6 +35,12 @@ First written: 2023/1/31
|
||||
<i class="fa-solid fa-gear"></i>
|
||||
{{ A_("Settings") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.order", base=obj.base)|append_next }}">
|
||||
<i class="fa-solid fa-bars-staggered"></i>
|
||||
{{ A_("Order") }}
|
||||
</a>
|
||||
{% if can_edit_accounting() %}
|
||||
<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") }}
|
||||
|
84
src/accounting/templates/accounting/account/order.html
Normal file
84
src/accounting/templates/accounting/account/order.html
Normal file
@ -0,0 +1,84 @@
|
||||
{#
|
||||
The Mia! Accounting Flask Project
|
||||
order.html: The order of the accounts under a same base account
|
||||
|
||||
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/2
|
||||
#}
|
||||
{% extends "accounting/account/include/form.html" %}
|
||||
|
||||
{% block accounting_scripts %}
|
||||
<script src="{{ url_for("accounting.static", filename="js/drag-and-drop-reorder.js") }}"></script>
|
||||
<script src="{{ url_for("accounting.static", filename="js/account-order.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{{ A_("The Accounts of %(base)s", base=base) }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="btn-group mb-3">
|
||||
<a class="btn btn-primary" href="{{ url_for("accounting.account.list")|or_next }}">
|
||||
<i class="fa-solid fa-circle-chevron-left"></i>
|
||||
{{ A_("Back") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if base.accounts|length > 1 and can_edit_accounting() %}
|
||||
<form action="{{ url_for("accounting.account.sort", base=base) }}" method="post">
|
||||
<input id="csrf_token" type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if "next" in request.args %}
|
||||
<input type="hidden" name="next" value="{{ request.args["next"] }}">
|
||||
{% endif %}
|
||||
<ul id="account-order-list" class="list-group mb-3" data-base-code="{{ base.code }}">
|
||||
{% for account in base.accounts|sort(attribute="no") %}
|
||||
<li class="list-group-item d-flex justify-content-between" data-id="{{ account.id }}">
|
||||
<input id="account-order-{{ account.id }}-no" type="hidden" name="{{ account.id }}-no" value="{{ loop.index }}">
|
||||
<div>
|
||||
<span id="account-order-{{ account.id }}-code">{{ account.code }}</span>
|
||||
{{ account.title }}
|
||||
</div>
|
||||
<i class="fa-solid fa-bars"></i>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
{% elif base.accounts %}
|
||||
<ul class="list-group mb-3">
|
||||
{% for account in base.accounts|sort(attribute="no") %}
|
||||
<li class="list-group-item">
|
||||
{{ account }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>{{ A_("There is no data.") }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
@ -8,8 +8,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-02-01 19:51+0800\n"
|
||||
"PO-Revision-Date: 2023-02-01 19:52+0800\n"
|
||||
"POT-Creation-Date: 2023-02-03 10:15+0800\n"
|
||||
"PO-Revision-Date: 2023-02-03 10:16+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
@ -19,35 +19,49 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
|
||||
#: src/accounting/account/forms.py:39
|
||||
#: src/accounting/account/forms.py:41
|
||||
msgid "The base account does not exist."
|
||||
msgstr "沒有這個基本科目。"
|
||||
|
||||
#: src/accounting/account/forms.py:48
|
||||
#: src/accounting/account/forms.py:50
|
||||
#: src/accounting/static/js/account-form.js:110
|
||||
msgid "Please select the base account."
|
||||
msgstr "請選擇基本科目。"
|
||||
|
||||
#: src/accounting/account/forms.py:53
|
||||
#: src/accounting/account/forms.py:55
|
||||
msgid "Please fill in the title"
|
||||
msgstr "請填上標題。"
|
||||
|
||||
#: src/accounting/account/query.py:50
|
||||
#: src/accounting/templates/accounting/account/detail.html:88
|
||||
#: src/accounting/templates/accounting/account/list.html:62
|
||||
msgid "Offset needed"
|
||||
msgstr "逐筆核銷"
|
||||
|
||||
#: src/accounting/account/views.py:88
|
||||
msgid "The account is added successfully"
|
||||
msgstr "科目加好了。"
|
||||
|
||||
#: src/accounting/account/views.py:140
|
||||
#: src/accounting/account/views.py:143
|
||||
msgid "The account was not modified."
|
||||
msgstr "科目未異動。"
|
||||
|
||||
#: src/accounting/account/views.py:145
|
||||
#: src/accounting/account/views.py:148
|
||||
msgid "The account is updated successfully."
|
||||
msgstr "科目存好了。"
|
||||
|
||||
#: src/accounting/account/views.py:163
|
||||
#: src/accounting/account/views.py:167
|
||||
msgid "The account is deleted successfully."
|
||||
msgstr "科目刪掉了"
|
||||
|
||||
#: src/accounting/account/views.py:194
|
||||
msgid "The order was not modified."
|
||||
msgstr "順序未異動。"
|
||||
|
||||
#: src/accounting/account/views.py:197
|
||||
msgid "The order is updated successfully."
|
||||
msgstr "順序存好了。"
|
||||
|
||||
#: src/accounting/static/js/account-form.js:130
|
||||
msgid "Please fill in the title."
|
||||
msgstr "請填上標題。"
|
||||
@ -58,6 +72,7 @@ msgstr "新增科目"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:31
|
||||
#: src/accounting/templates/accounting/account/include/form.html:33
|
||||
#: src/accounting/templates/accounting/account/order.html:36
|
||||
#: src/accounting/templates/accounting/base-account/detail.html:31
|
||||
msgid "Back"
|
||||
msgstr "回上頁"
|
||||
@ -67,36 +82,35 @@ msgid "Settings"
|
||||
msgstr "設定"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:40
|
||||
msgid "Order"
|
||||
msgstr "次序"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:44
|
||||
msgid "Delete"
|
||||
msgstr "刪除"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:63
|
||||
#: src/accounting/templates/accounting/account/detail.html:67
|
||||
msgid "Delete Account Confirmation"
|
||||
msgstr "科目刪除確認"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:67
|
||||
#: src/accounting/templates/accounting/account/detail.html:71
|
||||
msgid "Do you really want to delete this account?"
|
||||
msgstr "你確定要刪掉這個科目嗎?"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:70
|
||||
#: src/accounting/templates/accounting/account/detail.html:74
|
||||
#: src/accounting/templates/accounting/account/include/form.html:111
|
||||
msgid "Cancel"
|
||||
msgstr "取消"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:71
|
||||
#: src/accounting/templates/accounting/account/detail.html:75
|
||||
msgid "Confirm"
|
||||
msgstr "確定"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:84
|
||||
#: src/accounting/templates/accounting/account/list.html:62
|
||||
msgid "Offset needed"
|
||||
msgstr "逐筆核銷"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:88
|
||||
#: src/accounting/templates/accounting/account/detail.html:92
|
||||
msgid "Created"
|
||||
msgstr "建檔"
|
||||
|
||||
#: src/accounting/templates/accounting/account/detail.html:89
|
||||
#: src/accounting/templates/accounting/account/detail.html:93
|
||||
msgid "Updated"
|
||||
msgstr "更新"
|
||||
|
||||
@ -124,6 +138,16 @@ msgstr "搜尋"
|
||||
msgid "There is no data."
|
||||
msgstr "沒有資料。"
|
||||
|
||||
#: src/accounting/templates/accounting/account/order.html:29
|
||||
#, python-format
|
||||
msgid "The Accounts of %(base)s"
|
||||
msgstr "%(base)s下的科目"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:75
|
||||
#: src/accounting/templates/accounting/account/order.html:61
|
||||
msgid "Save"
|
||||
msgstr "儲存"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:45
|
||||
msgid "Base account"
|
||||
msgstr "基本科目"
|
||||
@ -140,10 +164,6 @@ msgstr "標題"
|
||||
msgid "The entries in the account need offsets."
|
||||
msgstr "帳目要逐筆核銷。"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:75
|
||||
msgid "Save"
|
||||
msgstr "儲存"
|
||||
|
||||
#: src/accounting/templates/accounting/account/include/form.html:90
|
||||
msgid "Select Base Account"
|
||||
msgstr "選擇基本科目"
|
||||
|
@ -32,6 +32,6 @@ def new_id(cls: t.Type):
|
||||
:return: The generated new random ID.
|
||||
"""
|
||||
while True:
|
||||
new: int = 100000000 + randbelow(900000000)
|
||||
if db.session.get(cls, new) is None:
|
||||
return new
|
||||
obj_id: int = 100000000 + randbelow(900000000)
|
||||
if db.session.get(cls, obj_id) is None:
|
||||
return obj_id
|
||||
|
116
src/accounting/utils/user.py
Normal file
116
src/accounting/utils/user.py
Normal file
@ -0,0 +1,116 @@
|
||||
# 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 user utilities.
|
||||
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask_sqlalchemy.model import Model
|
||||
|
||||
T = t.TypeVar("T", bound=Model)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
__user_utils: AbstractUserUtils
|
||||
"""The user utilities."""
|
||||
user_cls: t.Type[Model]
|
||||
"""The user class."""
|
||||
user_pk_column: sa.Column
|
||||
"""The primary key column of the user class."""
|
||||
|
||||
|
||||
def init_user_utils(utils: AbstractUserUtils) -> None:
|
||||
"""Initializes the user utilities.
|
||||
|
||||
:param utils: The user utilities.
|
||||
:return: None.
|
||||
"""
|
||||
global __user_utils, user_cls, user_pk_column
|
||||
__user_utils = utils
|
||||
user_cls = utils.cls
|
||||
user_pk_column = utils.pk_column
|
||||
|
||||
|
||||
def get_current_user_pk() -> int:
|
||||
"""Returns the primary key value of the currently logged-in user.
|
||||
|
||||
:return: The primary key value of the currently logged-in user.
|
||||
"""
|
||||
return __user_utils.get_pk(__user_utils.current_user)
|
||||
|
||||
|
||||
def has_user(username: str) -> bool:
|
||||
"""Returns whether a user by the username exists.
|
||||
|
||||
:param username: The username.
|
||||
:return: True if the user by the username exists, or False otherwise.
|
||||
"""
|
||||
return __user_utils.get_by_username(username) is not None
|
||||
|
||||
|
||||
def get_user_pk(username: str) -> int:
|
||||
"""Returns the primary key value of the user by the username.
|
||||
|
||||
:param username: The username.
|
||||
:return: The primary key value of the user by the username.
|
||||
"""
|
||||
return __user_utils.get_pk(__user_utils.get_by_username(username))
|
@ -28,7 +28,7 @@ from babel.messages.frontend import CommandLineInterface
|
||||
from opencc import OpenCC
|
||||
|
||||
root_dir: Path = Path(__file__).parent.parent
|
||||
translation_dir: Path = root_dir / "tests" / "testsite" / "translations"
|
||||
translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
|
||||
domain: str = "messages"
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ def babel_extract() -> None:
|
||||
/ f"{domain}.po"
|
||||
CommandLineInterface().run([
|
||||
"pybabel", "extract", "-F", str(cfg), "-k", "lazy_gettext", "-k", "A_",
|
||||
"-o", str(pot), str(Path("tests") / "testsite")])
|
||||
"-o", str(pot), str(Path("tests") / "test_site")])
|
||||
if not zh_hant.exists():
|
||||
zh_hant.touch()
|
||||
if not zh_hans.exists():
|
408
tests/test_account.py
Normal file
408
tests/test_account.py
Normal file
@ -0,0 +1,408 @@
|
||||
# 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 test for the account management.
|
||||
|
||||
"""
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import UserClient, get_user_client
|
||||
from test_site import create_app
|
||||
|
||||
|
||||
class AccountCommandTestCase(unittest.TestCase):
|
||||
"""The account console command test case."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Sets up the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_app(is_testing=True)
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
from accounting.database import db
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
result: Result
|
||||
result = runner.invoke(args="init-db")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
if BaseAccount.query.first() is None:
|
||||
result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
AccountL10n.query.delete()
|
||||
Account.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Tests the "accounting-init-account" console command.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
result: Result = runner.invoke(args=["accounting-init-accounts",
|
||||
"-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
with self.app.app_context():
|
||||
bases: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(sa.func.char_length(BaseAccount.code) == 4).all()
|
||||
accounts: list[Account] = Account.query.all()
|
||||
l10n: list[AccountL10n] = AccountL10n.query.all()
|
||||
self.assertEqual({x.code for x in bases},
|
||||
{x.base_code for x in accounts})
|
||||
self.assertEqual(len(accounts), len(bases))
|
||||
self.assertEqual(len(l10n), len(bases) * 2)
|
||||
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
|
||||
for account in accounts:
|
||||
base: BaseAccount = base_dict[account.base_code]
|
||||
self.assertEqual(account.no, 1)
|
||||
self.assertEqual(account.title_l10n, base.title_l10n)
|
||||
self.assertEqual({x.locale: x.title for x in account.l10n},
|
||||
{x.locale: x.title for x in base.l10n})
|
||||
|
||||
|
||||
class AccountTestCase(unittest.TestCase):
|
||||
"""The account test case."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Sets up the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_app(is_testing=True)
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
from accounting.database import db
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
result: Result
|
||||
result = runner.invoke(args="init-db")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
if BaseAccount.query.first() is None:
|
||||
result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
AccountL10n.query.delete()
|
||||
Account.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
editor: UserClient = get_user_client(self, self.app, "editor")
|
||||
self.client: httpx.Client = editor.client
|
||||
self.csrf_token: str = editor.csrf_token
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-001")
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "1112 title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1112-001")
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||
|
||||
response = nobody.client.get("/accounting/accounts")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/accounts/1111-001")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/accounts/create")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": nobody.csrf_token,
|
||||
"base_code": "1113",
|
||||
"title": "1113 title"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/accounts/1111-001/edit")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.post("/accounting/accounts/1111-001/update",
|
||||
data={"csrf_token": nobody.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title #2"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.post("/accounting/accounts/1111-001/delete",
|
||||
data={"csrf_token": nobody.csrf_token})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = nobody.client.get("/accounting/accounts/bases/1111")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
with self.app.app_context():
|
||||
account_id: int = Account.find_by_code("1112-001").id
|
||||
|
||||
response = nobody.client.post("/accounting/accounts/bases/1112",
|
||||
data={"csrf_token": nobody.csrf_token,
|
||||
"next": "/next",
|
||||
f"{account_id}-no": "5"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_viewer(self) -> None:
|
||||
"""Test the permission as viewer.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||
|
||||
response = viewer.client.get("/accounting/accounts")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = viewer.client.get("/accounting/accounts/1111-001")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = viewer.client.get("/accounting/accounts/create")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": viewer.csrf_token,
|
||||
"base_code": "1113",
|
||||
"title": "1113 title"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.get("/accounting/accounts/1111-001/edit")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.post("/accounting/accounts/1111-001/update",
|
||||
data={"csrf_token": viewer.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title #2"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.post("/accounting/accounts/1111-001/delete",
|
||||
data={"csrf_token": viewer.csrf_token})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
response = viewer.client.get("/accounting/accounts/bases/1111")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.app.app_context():
|
||||
account_id: int = Account.find_by_code("1112-001").id
|
||||
|
||||
response = viewer.client.post("/accounting/accounts/bases/1112",
|
||||
data={"csrf_token": viewer.csrf_token,
|
||||
"next": "/next",
|
||||
f"{account_id}-no": "5"})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_editor(self) -> None:
|
||||
"""Test the permission as editor.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get("/accounting/accounts")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get("/accounting/accounts/1111-001")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get("/accounting/accounts/create")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1113",
|
||||
"title": "1113 title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1113-001")
|
||||
|
||||
response = self.client.get("/accounting/accounts/1111-001/edit")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post("/accounting/accounts/1111-001/update",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "1111 title #2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-001")
|
||||
|
||||
response = self.client.post("/accounting/accounts/1111-001/delete",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts")
|
||||
|
||||
response = self.client.get("/accounting/accounts/bases/1111")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.app.app_context():
|
||||
account_id: int = Account.find_by_code("1112-001").id
|
||||
|
||||
response = self.client.post("/accounting/accounts/bases/1112",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": "/next",
|
||||
f"{account_id}-no": "5"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/next")
|
||||
|
||||
def test_change_base(self) -> None:
|
||||
"""Tests to change the base account.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.database import db
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title #1"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-002")
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title #1"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1111-003")
|
||||
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "Title #1"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1112-002")
|
||||
|
||||
with self.app.app_context():
|
||||
id_1: int = Account.find_by_code("1111-001").id
|
||||
id_2: int = Account.find_by_code("1111-002").id
|
||||
id_3: int = Account.find_by_code("1111-003").id
|
||||
id_4: int = Account.find_by_code("1112-001").id
|
||||
id_5: int = Account.find_by_code("1112-002").id
|
||||
|
||||
response = self.client.post("/accounting/accounts/1111-002/update",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "Account #1"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
"/accounting/accounts/1112-003")
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-001")
|
||||
self.assertEqual(db.session.get(Account, id_2).code, "1112-003")
|
||||
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
|
||||
self.assertEqual(db.session.get(Account, id_4).code, "1112-001")
|
||||
self.assertEqual(db.session.get(Account, id_5).code, "1112-002")
|
||||
|
||||
def test_reorder(self) -> None:
|
||||
"""Tests to reorder the accounts under a same base account.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.database import db
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
|
||||
for i in range(2, 6):
|
||||
response = self.client.post("/accounting/accounts/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"/accounting/accounts/1111-00{i}")
|
||||
|
||||
# Normal reorder
|
||||
with self.app.app_context():
|
||||
id_1: int = Account.find_by_code("1111-001").id
|
||||
id_2: int = Account.find_by_code("1111-002").id
|
||||
id_3: int = Account.find_by_code("1111-003").id
|
||||
id_4: int = Account.find_by_code("1111-004").id
|
||||
id_5: int = Account.find_by_code("1111-005").id
|
||||
|
||||
response = self.client.post("/accounting/accounts/bases/1111",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": "/next",
|
||||
f"{id_1}-no": "4",
|
||||
f"{id_2}-no": "1",
|
||||
f"{id_3}-no": "5",
|
||||
f"{id_4}-no": "2",
|
||||
f"{id_5}-no": "3"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"/next")
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
|
||||
self.assertEqual(db.session.get(Account, id_2).code, "1111-001")
|
||||
self.assertEqual(db.session.get(Account, id_3).code, "1111-005")
|
||||
self.assertEqual(db.session.get(Account, id_4).code, "1111-002")
|
||||
self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
|
||||
|
||||
# Malformed orders
|
||||
with self.app.app_context():
|
||||
db.session.get(Account, id_1).no = 3
|
||||
db.session.get(Account, id_2).no = 4
|
||||
db.session.get(Account, id_3).no = 6
|
||||
db.session.get(Account, id_4).no = 8
|
||||
db.session.get(Account, id_5).no = 9
|
||||
db.session.commit()
|
||||
|
||||
response = self.client.post("/accounting/accounts/bases/1111",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": "/next",
|
||||
f"{id_2}-no": "3a",
|
||||
f"{id_3}-no": "5",
|
||||
f"{id_4}-no": "2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"/next")
|
||||
|
||||
with self.app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
|
||||
self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
|
||||
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
|
||||
self.assertEqual(db.session.get(Account, id_4).code, "1111-001")
|
||||
self.assertEqual(db.session.get(Account, id_5).code, "1111-005")
|
@ -25,12 +25,12 @@ from click.testing import Result
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskCliRunner
|
||||
|
||||
from testlib import get_csrf_token
|
||||
from testsite import create_app
|
||||
from testlib import UserClient, get_user_client
|
||||
from test_site import create_app
|
||||
|
||||
|
||||
class BaseAccountTestCase(unittest.TestCase):
|
||||
"""The base account test case."""
|
||||
class BaseAccountCommandTestCase(unittest.TestCase):
|
||||
"""The base account console command test case."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Sets up the test.
|
||||
@ -38,24 +38,22 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
self.app: Flask = create_app(is_testing=True)
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
result: Result = runner.invoke(args="init-db")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
self.client: httpx.Client = httpx.Client(app=self.app,
|
||||
base_url="https://testserver")
|
||||
self.client.headers["Referer"] = "https://testserver"
|
||||
self.csrf_token: str = get_csrf_token(self, self.client, "/login")
|
||||
BaseAccountL10n.query.delete()
|
||||
BaseAccount.query.delete()
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Tests the "accounting-init-base" console command.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import BaseAccountL10n
|
||||
from accounting.models import BaseAccount
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
result: Result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
@ -69,46 +67,69 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
||||
self.assertIn(f"{account.code}-zh_Hant", l10n_keys)
|
||||
|
||||
list_uri: str = "/accounting/base-accounts"
|
||||
|
||||
class BaseAccountTestCase(unittest.TestCase):
|
||||
"""The base account test case."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Sets up the test.
|
||||
This is run once per test.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import BaseAccount
|
||||
self.app: Flask = create_app(is_testing=True)
|
||||
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
result: Result = runner.invoke(args="init-db")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
if BaseAccount.query.first() is None:
|
||||
result = runner.invoke(args="accounting-init-base")
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
self.viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||
self.editor: UserClient = get_user_client(self, self.app, "editor")
|
||||
self.nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
response: httpx.Response
|
||||
nobody: UserClient = get_user_client(self, self.app, "nobody")
|
||||
|
||||
self.__logout()
|
||||
response = self.client.get(list_uri)
|
||||
response = nobody.client.get("/accounting/base-accounts")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
self.__logout()
|
||||
self.__login_as("viewer")
|
||||
response = self.client.get(list_uri)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.__logout()
|
||||
self.__login_as("editor")
|
||||
response = self.client.get(list_uri)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.__logout()
|
||||
self.__login_as("nobody")
|
||||
response = self.client.get(list_uri)
|
||||
response = nobody.client.get("/accounting/base-accounts/1111")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def __logout(self) -> None:
|
||||
"""Logs out the currently logged-in user.
|
||||
def test_viewer(self) -> None:
|
||||
"""Test the permission as viewer.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
response: httpx.Response = self.client.post(
|
||||
"/logout", data={"csrf_token": self.csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/")
|
||||
response: httpx.Response
|
||||
viewer: UserClient = get_user_client(self, self.app, "viewer")
|
||||
|
||||
def __login_as(self, username: str) -> None:
|
||||
"""Logs in as a specific user.
|
||||
response = viewer.client.get("/accounting/base-accounts")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = viewer.client.get("/accounting/base-accounts/1111")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_editor(self) -> None:
|
||||
"""Test the permission as editor.
|
||||
|
||||
:param username: The username.
|
||||
:return: None.
|
||||
"""
|
||||
response: httpx.Response = self.client.post(
|
||||
"/login", data={"csrf_token": self.csrf_token,
|
||||
"username": username})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], "/")
|
||||
response: httpx.Response
|
||||
editor: UserClient = get_user_client(self, self.app, "editor")
|
||||
|
||||
response = editor.client.get("/accounting/base-accounts")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = editor.client.get("/accounting/base-accounts/1111")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -29,6 +29,8 @@ from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf import CSRFProtect
|
||||
from sqlalchemy import Column
|
||||
|
||||
import accounting.utils.user
|
||||
|
||||
bp: Blueprint = Blueprint("home", __name__)
|
||||
babel_js: BabelJS = BabelJS()
|
||||
csrf: CSRFProtect = CSRFProtect()
|
||||
@ -53,7 +55,6 @@ def create_app(is_testing: bool = False) -> Flask:
|
||||
})
|
||||
if is_testing:
|
||||
app.config["TESTING"] = True
|
||||
app.config["SQLALCHEMY_ECHO"] = True
|
||||
|
||||
babel_js.init_app(app)
|
||||
csrf.init_app(app)
|
||||
@ -68,7 +69,7 @@ def create_app(is_testing: bool = False) -> Flask:
|
||||
from . import auth
|
||||
auth.init_app(app)
|
||||
|
||||
class UserUtils(accounting.AbstractUserUtils[auth.User]):
|
||||
class UserUtils(accounting.utils.user.AbstractUserUtils[auth.User]):
|
||||
|
||||
@property
|
||||
def cls(self) -> t.Type[auth.User]:
|
@ -20,35 +20,37 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.11.0\n"
|
||||
|
||||
#: tests/testsite/templates/base.html:23
|
||||
#: tests/test_site/templates/base.html:23
|
||||
msgid "en"
|
||||
msgstr "zh-Hant"
|
||||
|
||||
#: tests/testsite/templates/base.html:43 tests/testsite/templates/home.html:24
|
||||
#: tests/test_site/templates/base.html:43
|
||||
#: tests/test_site/templates/home.html:24
|
||||
msgid "Home"
|
||||
msgstr "首頁"
|
||||
|
||||
#: tests/testsite/templates/base.html:68
|
||||
#: tests/test_site/templates/base.html:68
|
||||
msgid "Log Out"
|
||||
msgstr ""
|
||||
|
||||
#: tests/testsite/templates/base.html:78 tests/testsite/templates/login.html:24
|
||||
#: tests/test_site/templates/base.html:78
|
||||
#: tests/test_site/templates/login.html:24
|
||||
msgid "Log In"
|
||||
msgstr "登入"
|
||||
|
||||
#: tests/testsite/templates/base.html:119
|
||||
#: tests/test_site/templates/base.html:119
|
||||
msgid "Error:"
|
||||
msgstr "錯誤:"
|
||||
|
||||
#: tests/testsite/templates/login.html:30
|
||||
#: tests/test_site/templates/login.html:30
|
||||
msgid "Viewer"
|
||||
msgstr "讀報表者"
|
||||
|
||||
#: tests/testsite/templates/login.html:31
|
||||
#: tests/test_site/templates/login.html:31
|
||||
msgid "Editor"
|
||||
msgstr "記帳者"
|
||||
|
||||
#: tests/testsite/templates/login.html:32
|
||||
#: tests/test_site/templates/login.html:32
|
||||
msgid "Nobody"
|
||||
msgstr "沒有權限者"
|
||||
|
@ -21,6 +21,40 @@ from html.parser import HTMLParser
|
||||
from unittest import TestCase
|
||||
|
||||
import httpx
|
||||
from flask import Flask
|
||||
|
||||
|
||||
class UserClient:
|
||||
"""A user client."""
|
||||
|
||||
def __init__(self, client: httpx.Client, csrf_token: str):
|
||||
"""Constructs a user client.
|
||||
|
||||
:param client: The client.
|
||||
:param csrf_token: The CSRF token.
|
||||
"""
|
||||
self.client: httpx.Client = client
|
||||
self.csrf_token: str = csrf_token
|
||||
|
||||
|
||||
def get_user_client(test_case: TestCase, app: Flask, username: str) \
|
||||
-> UserClient:
|
||||
"""Returns a user client.
|
||||
|
||||
:param test_case: The test case.
|
||||
:param app: The Flask application.
|
||||
:param username: The username.
|
||||
:return: The user client.
|
||||
"""
|
||||
client: httpx.Client = httpx.Client(app=app, base_url="https://testserver")
|
||||
client.headers["Referer"] = "https://testserver"
|
||||
csrf_token: str = get_csrf_token(test_case, client, "/login")
|
||||
response: httpx.Response = client.post("/login",
|
||||
data={"csrf_token": csrf_token,
|
||||
"username": username})
|
||||
test_case.assertEqual(response.status_code, 302)
|
||||
test_case.assertEqual(response.headers["Location"], "/")
|
||||
return UserClient(client, csrf_token)
|
||||
|
||||
|
||||
def get_csrf_token(test_case: TestCase, client: httpx.Client, uri: str) -> str:
|
||||
|
Reference in New Issue
Block a user