28 Commits

Author SHA1 Message Date
975b00bce9 Advanced to version 0.1.1. 2023-02-03 17:14:47 +08:00
d648538fbb Added onupdate="CASCADE" to the foreign keys. 2023-02-03 17:14:32 +08:00
dde9c38bb8 Fixed the primary key of the Account data model to be not auto-incrementing. 2023-02-03 13:32:19 +08:00
fecf33baa8 Updated the minimal python version to 3.11, as for the use of the typing.Self type hint. 2023-02-03 13:01:03 +08:00
cea2a44226 Added the order and sorting routes to the test_nobody, test_viewer, and test_editor tests of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
b5d87d2387 Revised to allow the viewers to view the account order page. 2023-02-03 12:57:53 +08:00
784e7bde49 Added the test_reorder test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
60280f415d Shortened the variable names in the test_change_base test of the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
f32d268494 Revised the order and sorting routes from "/base/" to "/bases/". 2023-02-03 12:57:53 +08:00
1c1be87f3e Revised the accounting reordering to handle the cases with only one account or no account. 2023-02-03 12:57:53 +08:00
589da0c1c6 Renamed "sorting" to "reorder", and the "sort-form" route to "order". 2023-02-03 12:57:53 +08:00
8363ce6602 Fixed the endpoint name in the account detail template. 2023-02-03 12:57:53 +08:00
6a83f95c9f Added the test_change_base test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
7dc754174c Revised the documentation of the views. 2023-02-03 12:57:53 +08:00
5238168b2d Added support to sort the accounts under the same base account. 2023-02-03 12:57:53 +08:00
eeb05b8616 Removed the unique constraint in the Account data model. 2023-02-03 12:57:53 +08:00
9920377266 Added a missing semicolon in account-form.js. 2023-02-03 12:57:53 +08:00
9f9c40c30e Revised the code to find the next number in the populate_obj method of the AccountForm form. 2023-02-03 12:57:53 +08:00
d368c5e062 Renamed the variable in the new_id function from "new" to "obj_id", to be clear. 2023-02-03 12:57:53 +08:00
4aed2f6ba7 Renamed the "testsite" application to "test_site". 2023-02-03 12:57:53 +08:00
6876fdf75e Added the test_editor test to the AccountTestCase test case. 2023-02-03 12:57:53 +08:00
d9624c7be6 Revised the AccountTestCase test case for simplicity. 2023-02-03 12:57:53 +08:00
8364025668 Split the BaseAccountTestCase into BaseAccountCommandTestCase and BaseAccountTestCase, and rewrote the BaseAccountTestCase for simplicity. 2023-02-03 12:57:53 +08:00
dd3690dd6a Added the AccountTestCase test case with the test_nobody and test_viewer tests. 2023-02-03 12:57:53 +08:00
3312c835fd Added the AccountCommandTestCase test case. 2023-02-03 12:57:53 +08:00
fce9d04896 Removed SQLALCHEMY_ECHO from the test site. 2023-02-03 12:57:53 +08:00
c68786f78a Revised the import in the test_init test of the BaseAccountTestCase test case. 2023-02-03 12:57:53 +08:00
581e803707 Moved the user utilities from the "accounting.database" module to the "accounting.utils.users" module, and simplified its use. 2023-02-03 12:57:53 +08:00
28 changed files with 1043 additions and 164 deletions

View File

@ -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

View File

@ -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,

View File

@ -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)\

View File

@ -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

View File

@ -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")))

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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")

View 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);
}
});

View 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);
}
}

View File

@ -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") }}

View 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 %}

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: Mia! Accounting Flask 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-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 "選擇基本科目"

View File

@ -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

View 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))

View File

@ -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
View 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")

View File

@ -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)

View File

@ -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]:

View File

@ -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 "沒有權限者"

View File

@ -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: