6 Commits

14 changed files with 89 additions and 132 deletions

View File

@ -13,7 +13,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting Flask' project = 'Mia! Accounting Flask'
copyright = '2023, imacat' copyright = '2023, imacat'
author = 'imacat' author = 'imacat'
release = '0.9.0' release = '0.9.1'
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -17,7 +17,7 @@
[metadata] [metadata]
name = mia-accounting-flask name = mia-accounting-flask
version = 0.9.0 version = 0.9.1
author = imacat author = imacat
author_email = imacat@mail.imacat.idv.tw author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Flask project. description = The Mia! Accounting Flask project.

View File

@ -770,17 +770,10 @@ class JournalEntryLineItem(db.Model):
frac: Decimal = (value - whole).normalize() frac: Decimal = (value - whole).normalize()
return str(whole) + str(abs(frac))[1:] return str(whole) + str(abs(frac))[1:]
journal_entry_day: date = self.journal_entry.date return ["{}/{}/{}".format(self.journal_entry.date.year,
description: str = "" if self.description is None else self.description self.journal_entry.date.month,
return [description, self.journal_entry.date.day),
str(journal_entry_day.year), "" if self.description is None else self.description,
"{}/{}".format(journal_entry_day.year,
journal_entry_day.month),
"{}/{}".format(journal_entry_day.month,
journal_entry_day.day),
"{}/{}/{}".format(journal_entry_day.year,
journal_entry_day.month,
journal_entry_day.day),
format_amount(self.amount)] format_amount(self.amount)]

View File

@ -38,26 +38,49 @@ First written: 2023/3/22
</a> </a>
</div> </div>
<div class="form-floating mb-3"> <table class="table table-striped table-hover table-light" aria-label="{{ A_("Settings") }}">
<input id="accounting-default-currency" class="form-control" value="{{ obj.default_currency_text }}" readonly="readonly"> <tbody>
<label class="form-label" for="accounting-default-currency">{{ A_("Default Currency") }}</label> <tr>
</div> <th scope="row">{{ A_("Default Currency") }}</th>
<td>{{ obj.default_currency_text }}</td>
</tr>
<tr>
<th scope="row">{{ A_("Default Account for the Income and Expenses Log") }}</th>
<td>{{ obj.default_ie_account_code_text }}</td>
</tr>
</tbody>
</table>
<div class="form-floating mb-3"> <h2>{{ A_("Recurring Expense") }}</h2>
<input id="accounting-default-ie-account" class="form-control" value="{{ obj.default_ie_account_code_text }}" readonly="readonly">
<label class="form-label" for="accounting-default-ie-account">{{ A_("Default Account for the Income and Expenses Log") }}</label>
</div>
{% with expense_income = "expense", {% if obj.recurring.expenses %}
label = A_("Recurring Expense"), <ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
recurring_items = obj.recurring.expenses %} {% for recurring_item in obj.recurring.expenses %}
{% include "accounting/option/include/detail-recurring-expense-income.html" %} <li class="list-group-item">
{% endwith %} <div class="small">{{ recurring_item.account_text }}</div>
<div>{{ recurring_item.name }}</div>
<div class="small">{{ recurring_item.description_template }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% with expense_income = "income", <h2>{{ A_("Recurring Income") }}</h2>
label = A_("Recurring Income"),
recurring_items = obj.recurring.incomes %} {% if obj.recurring.incomes %}
{% include "accounting/option/include/detail-recurring-expense-income.html" %} <ul class="list-group mb-3 accounting-list-group-stripped accounting-list-group-hover">
{% endwith %} {% for recurring_item in obj.recurring.incomes %}
<li class="list-group-item">
<div class="small">{{ recurring_item.account_text }}</div>
<div>{{ recurring_item.name }}</div>
<div class="small">{{ recurring_item.description_template }}</div>
</li>
{% endfor %}
</ul>
{% else %}
<p>{{ A_("There is no data.") }}</p>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -1,31 +0,0 @@
{#
The Mia! Accounting Flask Project
detail-recurring-expense-income.html: The recurring expense or income in the option detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<div id="accounting-recurring-{{ expense_income }}" class="form-control mb-3 accounting-material-text-field {% if recurring_items %} accounting-not-empty {% endif %}">
<label class="form-label" for="accounting-recurring-{{ expense_income }}">{{ label }}</label>
{% if recurring_items %}
<ul class="list-group mb-2 mt-2">
{% for item in recurring_items %}
{% include "accounting/option/include/detail-recurring-item.html" %}
{% endfor %}
</ul>
{% endif %}
</div>

View File

@ -1,28 +0,0 @@
{#
The Mia! Accounting Flask Project
detail-recurring-item.html: The recurring item in the option detail
Copyright (c) 2023 imacat.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
{# <ul> For SonarQube not to complain about incorrect HTML #}
<li class="list-group-item list-group-item-action">
<div class="small">{{ item.account_text }}</div>
<div>{{ item.name }}</div>
<div class="small">{{ item.description_template }}</div>
</li>
{# </ul> For SonarQube not to complain about incorrect HTML #}

View File

@ -547,8 +547,8 @@ class AccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account from accounting.models import Account
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{CASH.code}" detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update" update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account account: Account
@ -571,7 +571,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(account.created_by.username, self.assertEqual(account.created_by.username,
editor_username) editor_username)
self.assertEqual(account.updated_by.username, self.assertEqual(account.updated_by.username,
editor2_username) admin_username)
def test_l10n(self) -> None: def test_l10n(self) -> None:
"""Tests the localization. """Tests the localization.

View File

@ -471,8 +471,8 @@ class CurrencyTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Currency from accounting.models import Currency
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{USD.code}" detail_uri: str = f"{PREFIX}/{USD.code}"
update_uri: str = f"{PREFIX}/{USD.code}/update" update_uri: str = f"{PREFIX}/{USD.code}/update"
currency: Currency currency: Currency
@ -493,7 +493,7 @@ class CurrencyTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username) self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor2_username) self.assertEqual(currency.updated_by.username, admin_username)
def test_api_exists(self) -> None: def test_api_exists(self) -> None:
"""Tests the API to check if a code exists. """Tests the API to check if a code exists.

View File

@ -537,8 +537,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
@ -562,7 +562,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.assertEqual(journal_entry.created_by.username, self.assertEqual(journal_entry.created_by.username,
editor_username) editor_username)
self.assertEqual(journal_entry.updated_by.username, self.assertEqual(journal_entry.updated_by.username,
editor2_username) admin_username)
def test_delete(self) -> None: def test_delete(self) -> None:
"""Tests to delete a journal entry. """Tests to delete a journal entry.
@ -1163,8 +1163,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
@ -1188,7 +1188,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
self.assertEqual(journal_entry.created_by.username, self.assertEqual(journal_entry.created_by.username,
editor_username) editor_username)
self.assertEqual(journal_entry.updated_by.username, self.assertEqual(journal_entry.updated_by.username,
editor2_username) admin_username)
def test_delete(self) -> None: def test_delete(self) -> None:
"""Tests to delete a journal entry. """Tests to delete a journal entry.
@ -1837,8 +1837,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, editor2_username = "editor", "editor2" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, editor2_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
@ -1862,7 +1862,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertEqual(journal_entry.created_by.username, self.assertEqual(journal_entry.created_by.username,
editor_username) editor_username)
self.assertEqual(journal_entry.updated_by.username, self.assertEqual(journal_entry.updated_by.username,
editor2_username) admin_username)
def test_save_as_receipt(self) -> None: def test_save_as_receipt(self) -> None:
"""Tests to save a transfer journal entry as a cash receipt journal """Tests to save a transfer journal entry as a cash receipt journal

View File

@ -67,7 +67,7 @@ class OptionTestCase(unittest.TestCase):
self.assertEqual(result.exit_code, 0) self.assertEqual(result.exit_code, 0)
Option.query.delete() Option.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "admin")
self.data: TestData = TestData(self.app, self.client, self.csrf_token) self.data: TestData = TestData(self.app, self.client, self.csrf_token)
def test_nobody(self) -> None: def test_nobody(self) -> None:
@ -104,12 +104,12 @@ class OptionTestCase(unittest.TestCase):
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor2(self) -> None: def test_editor(self) -> None:
"""Test the permission as non-administrator. """Test the permission as editor.
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "editor2") client, csrf_token = get_client(self.app, "editor")
response: httpx.Response response: httpx.Response
response = client.get(DETAIL_URI) response = client.get(DETAIL_URI)
@ -121,7 +121,7 @@ class OptionTestCase(unittest.TestCase):
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token)) response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_editor(self) -> None: def test_admin(self) -> None:
"""Test the permission as administrator. """Test the permission as administrator.
:return: None. :return: None.
@ -343,7 +343,7 @@ class OptionTestCase(unittest.TestCase):
""" """
from accounting.models import Option from accounting.models import Option
from accounting.utils.user import get_user_pk from accounting.utils.user import get_user_pk
editor_username, editor2_username = "editor", "editor2" admin_username, editor_username = "admin", "editor"
option: Option | None option: Option | None
response: httpx.Response response: httpx.Response
@ -352,11 +352,11 @@ class OptionTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], DETAIL_URI)
with self.app.app_context(): with self.app.app_context():
editor2_pk: int = get_user_pk(editor2_username) editor_pk: int = get_user_pk(editor_username)
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
option.created_by_id = editor2_pk option.created_by_id = editor_pk
option.updated_by_id = editor2_pk option.updated_by_id = editor_pk
db.session.commit() db.session.commit()
form: dict[str, str] = self.__get_form() form: dict[str, str] = self.__get_form()
@ -371,8 +371,8 @@ class OptionTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
self.assertEqual(option.created_by.username, editor2_username) self.assertEqual(option.created_by.username, editor_username)
self.assertEqual(option.updated_by.username, editor_username) self.assertEqual(option.updated_by.username, admin_username)
def __get_form(self, csrf_token: str | None = None) -> dict[str, str]: def __get_form(self, csrf_token: str | None = None) -> dict[str, str]:
"""Returns the option form. """Returns the option form.

View File

@ -72,15 +72,15 @@ def create_app(is_testing: bool = False) -> Flask:
def can_view(self) -> bool: def can_view(self) -> bool:
return auth.current_user() is not None \ return auth.current_user() is not None \
and auth.current_user().username in ["viewer", "editor", and auth.current_user().username in ["viewer", "editor",
"editor2"] "admin"]
def can_edit(self) -> bool: def can_edit(self) -> bool:
return auth.current_user() is not None \ return auth.current_user() is not None \
and auth.current_user().username in ["editor", "editor2"] and auth.current_user().username in ["editor", "admin"]
def can_admin(self) -> bool: def can_admin(self) -> bool:
return auth.current_user() is not None \ return auth.current_user() is not None \
and auth.current_user().username == "editor" and auth.current_user().username == "admin"
@property @property
def cls(self) -> t.Type[auth.User]: def cls(self) -> t.Type[auth.User]:
@ -112,7 +112,7 @@ def init_db_command() -> None:
"""Initializes the database.""" """Initializes the database."""
db.create_all() db.create_all()
from .auth import User from .auth import User
for username in ["viewer", "editor", "editor2", "nobody"]: for username in ["viewer", "editor", "admin", "nobody"]:
if User.query.filter(User.username == username).first() is None: if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username)) db.session.add(User(username=username))
db.session.commit() db.session.commit()

View File

@ -58,8 +58,8 @@ def login() -> redirect:
:return: The redirection to the home page. :return: The redirection to the home page.
""" """
if request.form.get("username") not in ["viewer", "editor", "editor2", if request.form.get("username") not in {"viewer", "editor", "admin",
"nobody"]: "nobody"}:
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
session["user"] = request.form.get("username") session["user"] = request.form.get("username")
return redirect(url_for("home.home")) return redirect(url_for("home.home"))

View File

@ -29,7 +29,7 @@ First written: 2023/1/27
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button> <button class="btn btn-primary" type="submit" name="username" value="viewer">{{ _("Viewer") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button> <button class="btn btn-primary" type="submit" name="username" value="editor">{{ _("Editor") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="editor2">{{ _("Editor2") }}</button> <button class="btn btn-primary" type="submit" name="username" value="admin">{{ _("Administrator") }}</button>
<button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button> <button class="btn btn-primary" type="submit" name="username" value="nobody">{{ _("Nobody") }}</button>
</form> </form>

View File

@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n" "Project-Id-Version: Mia! Accounting Flask Demonstration 0.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n" "Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-02-27 10:07+0800\n" "POT-Creation-Date: 2023-03-24 08:32+0800\n"
"PO-Revision-Date: 2023-02-27 10:08+0800\n" "PO-Revision-Date: 2023-03-24 08:33+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n" "Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language: zh_Hant\n" "Language: zh_Hant\n"
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n" "Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
@ -18,27 +18,27 @@ msgstr ""
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n" "Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.11.0\n" "Generated-By: Babel 2.12.1\n"
#: tests/test_site/templates/base.html:23 #: tests/test_site/templates/base.html:23
msgid "en" msgid "en"
msgstr "zh-Hant" msgstr "zh-Hant"
#: tests/test_site/templates/base.html:43 #: tests/test_site/templates/base.html:46
#: tests/test_site/templates/home.html:24 #: tests/test_site/templates/home.html:24
msgid "Home" msgid "Home"
msgstr "首頁" msgstr "首頁"
#: tests/test_site/templates/base.html:68 #: tests/test_site/templates/base.html:71
msgid "Log Out" msgid "Log Out"
msgstr "登出" msgstr "登出"
#: tests/test_site/templates/base.html:78 #: tests/test_site/templates/base.html:81
#: tests/test_site/templates/login.html:24 #: tests/test_site/templates/login.html:24
msgid "Log In" msgid "Log In"
msgstr "登入" msgstr "登入"
#: tests/test_site/templates/base.html:119 #: tests/test_site/templates/base.html:122
msgid "Error:" msgid "Error:"
msgstr "錯誤:" msgstr "錯誤:"
@ -51,8 +51,8 @@ msgid "Editor"
msgstr "記帳者" msgstr "記帳者"
#: tests/test_site/templates/login.html:32 #: tests/test_site/templates/login.html:32
msgid "Editor2" msgid "Administrator"
msgstr "記帳者2" msgstr "管理者"
#: tests/test_site/templates/login.html:33 #: tests/test_site/templates/login.html:33
msgid "Nobody" msgid "Nobody"