Compare commits

...

13 Commits

Author SHA1 Message Date
f1fa890042 Updated the Sphinx documentation. 2023-07-29 10:04:45 +08:00
cf8e3158fd Updated the translation. 2023-07-29 10:04:45 +08:00
6c7de34d4e Revised to capitalize the account titles when initializing the base accounts instead of when displaying the account titles, so that the titles of the user-added accounts are not capitalized incorrectly. 2023-07-29 10:04:45 +08:00
0c655eb7b4 Updated the translation. 2023-07-29 10:04:34 +08:00
7c2d3d636e Updated the log in message at the home page, and removed the next URI from the log in link. The next URI is not clear text but encrypted now. There is no need to attach the next URI, as it defaults redirects to the accounting application without the next URI. 2023-07-29 10:04:34 +08:00
70c66e5ee3 Removed unused imports from the test site. 2023-07-29 10:04:34 +08:00
5b0b45b566 Removed an unused import from testlib_journal_entry.py. 2023-07-29 10:04:34 +08:00
636bd2891f Revised the code to read from the CSV data files in the __test_base_account_data method of the ConsoleCommandTestCase test case, to prevent PyCharm from complaining. 2023-07-29 10:04:34 +08:00
17acf5efd7 Changed the properties of the test cases from public to private. 2023-07-29 10:04:34 +08:00
5b984dff38 Removed the CSRF token from the get_client function in testlib.py, so that type hints and documentation can be added to the client and the CSRF token properties separately. 2023-07-29 10:04:33 +08:00
ed20ae4528 Added missing documentation to the global variables and class and object properties. 2023-07-29 10:03:47 +08:00
64b9c8c11f Removed an excess property declaration in the populate_obj method of the JournalEntryForm form. 2023-07-29 10:03:46 +08:00
9072de82d4 Added the "decode_next" utility in the "accounting.utils.next_uri" module, and applied the "encode_next" and "decode_next" utilities to the NextUriTestCase test case, so that the test case do not need to get involved into the detail of the next URI encryption. 2023-07-29 10:03:45 +08:00
61 changed files with 1522 additions and 1278 deletions

View File

@ -100,6 +100,14 @@ accounting.utils.strip\_text module
:undoc-members:
:show-inheritance:
accounting.utils.title\_case module
-----------------------------------
.. automodule:: accounting.utils.title_case
:members:
:undoc-members:
:show-inheritance:
accounting.utils.user module
----------------------------

View File

@ -168,7 +168,9 @@ class AccountReorderForm:
:param base: The base account.
"""
self.base: BaseAccount = base
"""The base account."""
self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None:
"""Saves the order of the account.

View File

@ -24,6 +24,7 @@ import sqlalchemy as sa
from accounting import data_dir
from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n
from accounting.utils.title_case import title_case
def init_base_accounts_command() -> None:
@ -34,7 +35,7 @@ def init_base_accounts_command() -> None:
with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
account_data: list[dict[str, str]] = [{"code": x["code"],
"title_l10n": x["title"]}
"title_l10n": title_case(x["title"])}
for x in data]
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],

View File

@ -65,6 +65,7 @@ class IsDebitAccount:
:param message: The error message.
"""
self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:
@ -85,6 +86,7 @@ class IsCreditAccount:
:param message: The error message.
"""
self.__message: str | LazyString = message
"""The error message."""
def __call__(self, form: FlaskForm, field: StringField) -> None:
if field.data is None:

View File

@ -151,7 +151,6 @@ class JournalEntryForm(FlaskForm):
is_new: bool = obj.id is None
if is_new:
obj.id = new_id(JournalEntry)
self.date: DateField
self.__set_date(obj, self.date.data)
obj.note = self.note.data

View File

@ -54,7 +54,9 @@ class JournalEntryReorderForm:
:param date: The date.
"""
self.date: dt.date = date
"""The date."""
self.is_modified: bool = False
"""Whether the order is modified."""
def save_order(self) -> None:
"""Saves the order of the account.

View File

@ -166,8 +166,11 @@ class DescriptionRecurring:
:param account: The account.
"""
self.name: str = name
"""The name."""
self.account: DescriptionAccount = DescriptionAccount(account, 0)
"""The account."""
self.description_template: str = description_template
"""The description template."""
@property
def account_codes(self) -> list[str]:

View File

@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
from flask_babel_js import JAVASCRIPT, c2js
translation_dir: Path = Path(__file__).parent / "translations"
"""The directory of the translation files."""
domain: Domain = Domain(translation_directories=[translation_dir],
domain="accounting")
"""The message domain."""
def gettext(string, **variables) -> str:
@ -120,6 +122,5 @@ def init_app(app: Flask, bp: Blueprint) -> None:
:param bp: The blueprint of the accounting application.
:return: None.
"""
bp.add_url_rule("/_jstrans.js", "babel_catalog",
__babel_js_catalog_view)
bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
app.jinja_env.globals["A_"] = domain.gettext

View File

@ -54,7 +54,7 @@ class BaseAccount(db.Model):
:return: The string representation of the base account.
"""
return f"{self.code} {self.title.title()}"
return f"{self.code} {self.title}"
@property
def title(self) -> str:
@ -151,7 +151,7 @@ class Account(db.Model):
:return: The string representation of this account.
"""
return f"{self.base_code}-{self.no:03d} {self.title.title()}"
return f"{self.base_code}-{self.no:03d} {self.title}"
@property
def code(self) -> str:

View File

@ -145,6 +145,7 @@ class AccountCollector:
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
Account.base_code == "3351",
Account.base_code == "3353")).all()
"""The accounts."""
account_by_id: dict[int, Account] \
= {x.id: x for x in self.__all_accounts}
self.accounts: list[ReportAccount] \
@ -154,6 +155,7 @@ class AccountCollector:
account_by_id[x.id],
self.__period))
for x in account_balances]
"""The accounts on the balance sheet."""
self.__add_accumulated()
self.__add_current_period()
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
@ -452,11 +454,11 @@ class BalanceSheet(BaseReport):
:return: The CSV rows for the section.
"""
rows: list[CSVHalfRow] \
= [CSVHalfRow(section.title.title.title(), None)]
= [CSVHalfRow(section.title.title, None)]
for subsection in section.subsections:
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
rows.append(CSVHalfRow(f" {subsection.title.title}", None))
for account in subsection.accounts:
rows.append(CSVHalfRow(f" {str(account.account).title()}",
rows.append(CSVHalfRow(f" {str(account.account)}",
account.amount))
return rows

View File

@ -407,13 +407,13 @@ class IncomeExpenses(BaseReport):
gettext("Note"))]
if self.__brought_forward is not None:
rows.append(CSVRow(self.__brought_forward.date,
str(self.__brought_forward.account).title(),
str(self.__brought_forward.account),
self.__brought_forward.description,
self.__brought_forward.income,
self.__brought_forward.expense,
self.__brought_forward.balance,
None))
rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
rows.extend([CSVRow(x.date, str(x.account), x.description,
x.income, x.expense, x.balance, x.note)
for x in self.__line_items])
if self.__total is not None:

View File

@ -106,6 +106,7 @@ class Section:
"""The subsections in the section."""
self.accumulated: AccumulatedTotal \
= AccumulatedTotal(accumulated_title)
"""The accumulated total."""
@property
def total(self) -> Decimal:
@ -225,12 +226,12 @@ class IncomeStatement(BaseReport):
for x in balances})).all()
total_titles: dict[str, str] \
= {"4": gettext("total operating revenue"),
"5": gettext("gross income"),
"6": gettext("operating income"),
"7": gettext("before tax income"),
"8": gettext("after tax income"),
"9": gettext("net income or loss for current period")}
= {"4": gettext("Total Operating Revenue"),
"5": gettext("Gross Income"),
"6": gettext("Operating Income"),
"7": gettext("Before Tax Income"),
"8": gettext("After Tax Income"),
"9": gettext("Net Income or Loss for Current Period")}
sections: dict[str, Section] \
= {x.code: Section(x, total_titles[x.code]) for x in titles}
@ -300,14 +301,14 @@ class IncomeStatement(BaseReport):
total_str: str = gettext("Total")
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
for section in self.__sections:
rows.append(CSVRow(str(section.title).title(), None))
rows.append(CSVRow(str(section.title), None))
for subsection in section.subsections:
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
rows.append(CSVRow(f" {str(subsection.title)}", None))
for account in subsection.accounts:
rows.append(CSVRow(f" {str(account.account).title()}",
rows.append(CSVRow(f" {str(account.account)}",
account.amount))
rows.append(CSVRow(f" {total_str}", subsection.total))
rows.append(CSVRow(section.accumulated.title.title(),
rows.append(CSVRow(section.accumulated.title,
section.accumulated.amount))
rows.append(CSVRow(None, None))
rows = rows[:-1]

View File

@ -160,7 +160,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
gettext("Debit"), gettext("Credit"),
gettext("Note"))]
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
str(x.account).title(), x.description,
str(x.account), x.description,
x.debit, x.credit, x.journal_entry.note)
for x in line_items])
return rows

View File

@ -224,7 +224,7 @@ class TrialBalance(BaseReport):
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
gettext("Credit"))]
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
rows.extend([CSVRow(str(x.account), x.debit, x.credit)
for x in self.__accounts])
rows.append(CSVRow(gettext("Total"), self.__total.debit,
self.__total.credit))

View File

@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
rows.extend([CSVRow(str(x), x.count) for x in accounts])
return rows

View File

@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
:return: The CSV rows.
"""
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
rows.extend([CSVRow(str(x).title(), x.count)
for x in accounts])
rows.extend([CSVRow(str(x), x.count) for x in accounts])
return rows

View File

@ -90,7 +90,7 @@ First written: 2023/1/31
{% endif %}
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title|title }}</div>
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.is_need_offset %}
<div>

View File

@ -33,7 +33,7 @@ First written: 2023/2/1
</div>
<div class="accounting-card col-sm-6">
<div class="accounting-card-title">{{ obj.title|title }}</div>
<div class="accounting-card-title">{{ obj.title }}</div>
<div class="accounting-card-code">{{ obj.code }}</div>
{% if obj.accounts %}
<div>

View File

@ -26,7 +26,7 @@ First written: 2023/3/14
<div>
<div class="small">
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }}
{{ line_item.account.title }}
</div>
{% if line_item.description is not none %}
<div>{{ line_item.description }}</div>

View File

@ -42,7 +42,7 @@ First written: 2023/2/25
<div class="small">
{{ line_item.journal_entry.date|accounting_format_date }}
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }}
{{ line_item.account.title }}
</div>
{{ line_item.description|accounting_default }}
</div>

View File

@ -20,21 +20,21 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<div class="accounting-report-table-row accounting-balance-sheet-section">
<div>{{ section.title.title|title }}</div>
<div>{{ section.title.title }}</div>
</div>
<div class="accounting-report-table-body">
{% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
{{ subsection.title.title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
{{ account.account.title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>

View File

@ -20,7 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<div>{{ line_item.date|accounting_format_date }}</div>
<div>{{ line_item.account.title|title }}</div>
<div>{{ line_item.account.title }}</div>
<div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
{{ line_item.date|accounting_format_date }}
{% endif %}
{% if line_item.account %}
{{ line_item.account.title|title }}
{{ line_item.account.title }}
{% endif %}
</div>
{% endif %}

View File

@ -93,7 +93,7 @@ First written: 2023/3/8
{% for account in report.account_options %}
<li>
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
{{ account.title|title }}
{{ account.title }}
</a>
</li>
{% endfor %}

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}

View File

@ -66,21 +66,21 @@ First written: 2023/3/7
<div class="accounting-report-table-row accounting-income-statement-section">
<div>
<span class="d-none d-md-inline">{{ section.title.code }}</span>
{{ section.title.title|title }}
{{ section.title.title }}
</div>
</div>
{% for subsection in section.subsections %}
<div class="accounting-report-table-row accounting-income-statement-subsection">
<div>
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
{{ subsection.title.title|title }}
{{ subsection.title.title }}
</div>
</div>
{% for account in subsection.accounts %}
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
{{ account.account.title }}
</div>
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
</a>
@ -91,7 +91,7 @@ First written: 2023/3/7
</div>
{% endfor %}
<div class="accounting-report-table-row accounting-income-statement-total">
<div>{{ section.accumulated.title|title }}</div>
<div>{{ section.accumulated.title }}</div>
<div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
</div>
{% endfor %}

View File

@ -65,7 +65,7 @@ First written: 2023/3/4
<div>{{ line_item.currency.name }}</div>
<div>
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }}
{{ line_item.account.title }}
</div>
<div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
@ -82,7 +82,7 @@ First written: 2023/3/4
<div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small">
{{ line_item.journal_entry.date|accounting_format_date }}
{{ line_item.account.title|title }}
{{ line_item.account.title }}
{% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %}

View File

@ -26,7 +26,7 @@ First written: 2023/3/5
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}

View File

@ -62,7 +62,7 @@ First written: 2023/3/8
<div>{{ line_item.currency.name }}</div>
<div>
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
{{ line_item.account.title|title }}
{{ line_item.account.title }}
</div>
<div>{{ line_item.description|accounting_default }}</div>
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
@ -79,7 +79,7 @@ First written: 2023/3/8
<div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
<div class="text-muted small">
{{ line_item.journal_entry.date|accounting_format_date }}
{{ line_item.account.title|title }}
{{ line_item.account.title }}
{% if line_item.currency.code != accounting_default_currency_code() %}
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
{% endif %}

View File

@ -68,7 +68,7 @@ First written: 2023/3/5
<a class="accounting-report-table-row" href="{{ account.url }}">
<div>
<span class="d-none d-md-inline">{{ account.account.code }}</span>
{{ account.account.title|title }}
{{ account.account.title }}
</div>
<div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
<div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>

View File

@ -26,7 +26,7 @@ First written: 2023/4/8
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unapplied Items") }}{% else %}{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unapplied Items") }}{% else %}{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
@ -46,9 +46,9 @@ First written: 2023/4/8
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">
{% if report.currency.code == accounting_default_currency_code() %}
{{ A_("Accounts with Unapplied Items") }}
{{ A_("Accounts With Unapplied Items") }}
{% else %}
{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
{% endif %}
</h2>
</div>
@ -64,7 +64,7 @@ First written: 2023/4/8
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", currency=report.currency, account=account, period=report.period) }}">
<div>
<span class="d-none d-md-inline">{{ account.code }}</span>
{{ account.title|title }}
{{ account.title }}
</div>
<div class="accounting-amount">{{ account.count }}</div>
</a>

View File

@ -26,7 +26,7 @@ First written: 2023/4/7
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}

View File

@ -26,7 +26,7 @@ First written: 2023/4/17
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unmatched Offsets") }}{% else %}{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unmatched Offsets") }}{% else %}{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}
@ -46,9 +46,9 @@ First written: 2023/4/17
<div class="d-none d-sm-flex justify-content-center mb-3">
<h2 class="text-center">
{% if report.currency.code == accounting_default_currency_code() %}
{{ A_("Accounts with Unmatched Offsets") }}
{{ A_("Accounts With Unmatched Offsets") }}
{% else %}
{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
{% endif %}
</h2>
</div>
@ -64,7 +64,7 @@ First written: 2023/4/17
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unmatched", currency=report.currency, account=account, period=report.period) }}">
<div>
<span class="d-none d-md-inline">{{ account.code }}</span>
{{ account.title|title }}
{{ account.title }}
</div>
<div class="accounting-amount">{{ account.count }}</div>
</a>

View File

@ -26,7 +26,7 @@ First written: 2023/4/17
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
{% block content %}

View File

@ -8,8 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: mia-accounting 1.4.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-18 09:32+0800\n"
"PO-Revision-Date: 2023-04-18 09:32+0800\n"
"POT-Creation-Date: 2023-07-29 08:55+0800\n"
"PO-Revision-Date: 2023-07-29 08:56+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"
@ -21,7 +21,7 @@ msgstr ""
#: src/accounting/forms.py:33
#: src/accounting/static/js/journal-entry-form.js:1080
#: src/accounting/static/js/journal-entry-line-item-editor.js:411
#: src/accounting/static/js/journal-entry-line-item-editor.js:415
#: src/accounting/static/js/option-form.js:537
#: src/accounting/static/js/option-form.js:803
msgid "Please select the account."
@ -35,22 +35,22 @@ msgstr "沒有這個貨幣。"
msgid "The account does not exist."
msgstr "沒有這個科目。"
#: src/accounting/models.py:581
#: src/accounting/models.py:578
#, python-format
msgid "Cash Disbursement Journal Entry#%(id)s"
msgstr "現金支出傳票#%(id)s"
#: src/accounting/models.py:584
#: src/accounting/models.py:581
#, python-format
msgid "Cash Receipt Journal Entry#%(id)s"
msgstr "現金收入傳票#%(id)s"
#: src/accounting/models.py:585
#: src/accounting/models.py:582
#, python-format
msgid "Transfer Journal Entry#%(id)s"
msgstr "轉帳傳票#%(id)s"
#: src/accounting/models.py:714
#: src/accounting/models.py:706
#, python-format
msgid "%(date)s %(description)s %(amount)s"
msgstr "%(date)s %(description)s %(amount)s"
@ -101,7 +101,7 @@ msgid "Please fill in the title"
msgstr "請填上標題。"
#: src/accounting/account/queries.py:50
#: src/accounting/report/reports/search.py:101
#: src/accounting/report/reports/search.py:100
#: src/accounting/templates/accounting/account/detail.html:97
#: src/accounting/templates/accounting/account/list.html:62
msgid "Needs Offset"
@ -205,24 +205,24 @@ msgstr "傳票不可刪除。"
msgid "The journal entry is deleted successfully."
msgstr "傳票刪掉了"
#: src/accounting/journal_entry/forms/currency.py:39
#: src/accounting/journal_entry/forms/currency.py:38
msgid "Please select the currency."
msgstr "請選擇貨幣。"
#: src/accounting/journal_entry/forms/currency.py:62
#: src/accounting/journal_entry/forms/currency.py:61
msgid "The currency must be the same as the original line item."
msgstr "貨幣需和原始分錄相同。"
#: src/accounting/journal_entry/forms/currency.py:89
#: src/accounting/journal_entry/forms/currency.py:88
msgid "The currency must not be changed when there is offset."
msgstr "抵銷過不可變更貨幣。"
#: src/accounting/journal_entry/forms/currency.py:98
#: src/accounting/journal_entry/forms/currency.py:97
#: src/accounting/static/js/journal-entry-form.js:773
msgid "Please add some line items."
msgstr "請加上分錄。"
#: src/accounting/journal_entry/forms/currency.py:111
#: src/accounting/journal_entry/forms/currency.py:110
#: src/accounting/static/js/journal-entry-form.js:522
msgid "The totals of the debit and credit amounts do not match."
msgstr "借方貸方合計不符。 "
@ -251,62 +251,62 @@ msgstr "請加上貨幣。"
msgid "Line items with offset cannot be deleted."
msgstr "無法刪除抵銷過的分錄。"
#: src/accounting/journal_entry/forms/line_item.py:49
#: src/accounting/journal_entry/forms/line_item.py:48
msgid "The original line item does not exist."
msgstr "沒有這筆原始分錄。"
#: src/accounting/journal_entry/forms/line_item.py:70
#: src/accounting/journal_entry/forms/line_item.py:69
msgid "The original line item is on the same debit or credit."
msgstr "原始分錄在借貸同一邊。"
#: src/accounting/journal_entry/forms/line_item.py:85
#: src/accounting/journal_entry/forms/line_item.py:84
msgid "The original line item does not need offset."
msgstr "這筆原始分錄不需抵銷。"
#: src/accounting/journal_entry/forms/line_item.py:101
#: src/accounting/journal_entry/forms/line_item.py:100
msgid "The original line item cannot be an offset item."
msgstr "原始分錄不可以是抵銷分錄。"
#: src/accounting/journal_entry/forms/line_item.py:119
#: src/accounting/journal_entry/forms/line_item.py:118
msgid "The account must be the same as the original line item."
msgstr "科目需和原始分錄相同。"
#: src/accounting/journal_entry/forms/line_item.py:135
#: src/accounting/journal_entry/forms/line_item.py:134
msgid "The account must not be changed when there is offset."
msgstr "抵銷過不可變更科目。"
#: src/accounting/journal_entry/forms/line_item.py:151
#: src/accounting/journal_entry/forms/line_item.py:150
msgid "A payable line item cannot start from debit."
msgstr "不可由借方新建應付款。"
#: src/accounting/journal_entry/forms/line_item.py:167
#: src/accounting/journal_entry/forms/line_item.py:166
msgid "A receivable line item cannot start from credit."
msgstr "不可由貸方新建應收款。"
#: src/accounting/journal_entry/forms/line_item.py:178
#: src/accounting/static/js/journal-entry-line-item-editor.js:436
#: src/accounting/journal_entry/forms/line_item.py:177
#: src/accounting/static/js/journal-entry-line-item-editor.js:440
msgid "Please fill in a positive amount."
msgstr "金額請填正數。"
#: src/accounting/journal_entry/forms/line_item.py:220
#: src/accounting/static/js/journal-entry-line-item-editor.js:442
#: src/accounting/journal_entry/forms/line_item.py:219
#: src/accounting/static/js/journal-entry-line-item-editor.js:446
#, python-format
msgid ""
"The amount must not exceed the net balance %(balance)s of the original "
"line item."
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
#: src/accounting/journal_entry/forms/line_item.py:241
#: src/accounting/static/js/journal-entry-line-item-editor.js:450
#: src/accounting/journal_entry/forms/line_item.py:239
#: src/accounting/static/js/journal-entry-line-item-editor.js:454
#, python-format
msgid "The amount must not be less than the offset total %(total)s."
msgstr "金額不可低於抵銷總額 %(total)s 。"
#: src/accounting/journal_entry/forms/line_item.py:426
#: src/accounting/journal_entry/forms/line_item.py:424
msgid "This account is not for debit line items."
msgstr "科目不是借方科目。"
#: src/accounting/journal_entry/forms/line_item.py:478
#: src/accounting/journal_entry/forms/line_item.py:476
msgid "This account is not for credit line items."
msgstr "科目不是貸方科目。"
@ -417,15 +417,15 @@ msgstr "去年"
msgid "All"
msgstr "全部"
#: src/accounting/report/reports/balance_sheet.py:423
#: src/accounting/report/reports/balance_sheet.py:427
#: src/accounting/report/reports/balance_sheet.py:439
#: src/accounting/report/reports/balance_sheet.py:425
#: src/accounting/report/reports/balance_sheet.py:429
#: src/accounting/report/reports/balance_sheet.py:441
#: src/accounting/report/reports/income_expenses.py:189
#: src/accounting/report/reports/income_expenses.py:423
#: src/accounting/report/reports/income_statement.py:300
#: src/accounting/report/reports/ledger.py:171
#: src/accounting/report/reports/ledger.py:380
#: src/accounting/report/reports/balance_sheet.py:443
#: src/accounting/report/reports/income_expenses.py:187
#: src/accounting/report/reports/income_expenses.py:420
#: src/accounting/report/reports/income_statement.py:301
#: src/accounting/report/reports/ledger.py:168
#: src/accounting/report/reports/ledger.py:376
#: src/accounting/report/reports/trial_balance.py:229
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:38
@ -445,14 +445,14 @@ msgstr "全部"
msgid "Total"
msgstr "合計"
#: src/accounting/report/reports/income_expenses.py:136
#: src/accounting/report/reports/ledger.py:132
#: src/accounting/report/reports/income_expenses.py:134
#: src/accounting/report/reports/ledger.py:129
msgid "Brought forward"
msgstr "前期轉入"
#: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/income_expenses.py:404
#: src/accounting/report/reports/journal.py:158
#: src/accounting/report/reports/ledger.py:366
#: src/accounting/report/reports/ledger.py:362
#: src/accounting/report/reports/unapplied.py:148
#: src/accounting/report/reports/unmatched.py:158
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
@ -466,13 +466,13 @@ msgstr "前期轉入"
msgid "Date"
msgstr "日期"
#: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/income_expenses.py:404
#: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/report/reports/unapplied_accounts.py:122
#: src/accounting/report/reports/unmatched_accounts.py:122
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:58
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:40
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
#: src/accounting/templates/accounting/report/income-expenses.html:56
#: src/accounting/templates/accounting/report/journal.html:55
@ -481,13 +481,13 @@ msgstr "日期"
msgid "Account"
msgstr "科目"
#: src/accounting/report/reports/income_expenses.py:408
#: src/accounting/report/reports/income_expenses.py:405
#: src/accounting/report/reports/journal.py:159
#: src/accounting/report/reports/ledger.py:366
#: src/accounting/report/reports/ledger.py:362
#: src/accounting/report/reports/unapplied.py:149
#: src/accounting/report/reports/unmatched.py:159
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:29
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:50
#: src/accounting/templates/accounting/report/income-expenses.html:57
#: src/accounting/templates/accounting/report/journal.html:56
#: src/accounting/templates/accounting/report/ledger.html:56
@ -497,18 +497,18 @@ msgstr "科目"
msgid "Description"
msgstr "摘要"
#: src/accounting/report/reports/income_expenses.py:408
#: src/accounting/report/reports/income_expenses.py:405
#: src/accounting/templates/accounting/report/income-expenses.html:58
msgid "Income"
msgstr "收入"
#: src/accounting/report/reports/income_expenses.py:409
#: src/accounting/report/reports/income_expenses.py:406
#: src/accounting/templates/accounting/report/income-expenses.html:59
msgid "Expense"
msgstr "支出"
#: src/accounting/report/reports/income_expenses.py:409
#: src/accounting/report/reports/ledger.py:368
#: src/accounting/report/reports/income_expenses.py:406
#: src/accounting/report/reports/ledger.py:364
#: src/accounting/report/reports/unmatched.py:160
#: src/accounting/templates/accounting/report/income-expenses.html:60
#: src/accounting/templates/accounting/report/ledger.html:60
@ -516,41 +516,41 @@ msgstr "支出"
msgid "Balance"
msgstr "餘額"
#: src/accounting/report/reports/income_expenses.py:410
#: src/accounting/report/reports/income_expenses.py:407
#: src/accounting/report/reports/journal.py:161
#: src/accounting/report/reports/ledger.py:368
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
#: src/accounting/report/reports/ledger.py:364
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:179
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
msgid "Note"
msgstr "備註"
#: src/accounting/report/reports/income_statement.py:228
msgid "total operating revenue"
#: src/accounting/report/reports/income_statement.py:229
msgid "Total Operating Revenue"
msgstr "營業收入總額"
#: src/accounting/report/reports/income_statement.py:229
msgid "gross income"
#: src/accounting/report/reports/income_statement.py:230
msgid "Gross Income"
msgstr "營業毛利"
#: src/accounting/report/reports/income_statement.py:230
msgid "operating income"
#: src/accounting/report/reports/income_statement.py:231
msgid "Operating Income"
msgstr "營業淨利"
#: src/accounting/report/reports/income_statement.py:231
msgid "before tax income"
#: src/accounting/report/reports/income_statement.py:232
msgid "Before Tax Income"
msgstr "稅前淨利"
#: src/accounting/report/reports/income_statement.py:232
msgid "after tax income"
#: src/accounting/report/reports/income_statement.py:233
msgid "After Tax Income"
msgstr "稅後淨利"
#: src/accounting/report/reports/income_statement.py:233
msgid "net income or loss for current period"
#: src/accounting/report/reports/income_statement.py:234
msgid "Net Income or Loss for Current Period"
msgstr "本期損益"
#: src/accounting/report/reports/income_statement.py:301
#: src/accounting/report/reports/income_statement.py:302
#: src/accounting/report/reports/unapplied.py:149
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:66
#: src/accounting/templates/accounting/report/income-statement.html:61
#: src/accounting/templates/accounting/report/unapplied.html:54
msgid "Amount"
@ -567,7 +567,7 @@ msgid "Currency"
msgstr "貨幣"
#: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/ledger.py:363
#: src/accounting/report/reports/trial_balance.py:225
#: src/accounting/report/reports/unmatched.py:159
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
@ -581,7 +581,7 @@ msgid "Debit"
msgstr "借方"
#: src/accounting/report/reports/journal.py:160
#: src/accounting/report/reports/ledger.py:367
#: src/accounting/report/reports/ledger.py:363
#: src/accounting/report/reports/trial_balance.py:226
#: src/accounting/report/reports/unmatched.py:160
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
@ -614,16 +614,16 @@ msgstr "淨額"
msgid "Count"
msgstr "數量"
#: src/accounting/report/utils/offset_matcher.py:163
#: src/accounting/report/utils/offset_matcher.py:161
msgid "There is no unmatched offset."
msgstr "沒有遺漏的抵銷分錄"
#: src/accounting/report/utils/offset_matcher.py:167
#: src/accounting/report/utils/offset_matcher.py:165
#, python-format
msgid "%(total)s unmatched offsets without original items."
msgstr "%(total)s 筆遺漏的抵銷分錄無法自動抵銷。"
#: src/accounting/report/utils/offset_matcher.py:172
#: src/accounting/report/utils/offset_matcher.py:170
#, python-format
msgid ""
"%(matches)s unmatched offsets out of %(total)s can match with their "
@ -752,7 +752,7 @@ msgid "December"
msgstr "十二月"
#: src/accounting/static/js/journal-entry-form.js:1085
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
#: src/accounting/static/js/journal-entry-line-item-editor.js:434
msgid "Please fill in the amount."
msgstr "請填上金額。"
@ -833,12 +833,12 @@ msgstr "確認刪除科目"
#: src/accounting/templates/accounting/account/include/form.html:91
#: src/accounting/templates/accounting/currency/detail.html:73
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:27
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:30
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:31
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:29
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:29
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
#: src/accounting/templates/accounting/report/include/search-modal.html:28
#: src/accounting/templates/accounting/report/unmatched.html:58
@ -853,11 +853,11 @@ msgstr "你確定要刪掉這個科目嗎?"
#: src/accounting/templates/accounting/account/include/form.html:112
#: src/accounting/templates/accounting/currency/detail.html:79
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:49
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:194
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:195
#: src/accounting/templates/accounting/journal-entry/include/detail.html:84
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:70
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
#: src/accounting/templates/accounting/report/include/search-modal.html:37
#: src/accounting/templates/accounting/report/unmatched.html:74
msgid "Cancel"
@ -942,12 +942,12 @@ msgstr "%(base)s下的科目"
#: src/accounting/templates/accounting/account/include/form.html:75
#: src/accounting/templates/accounting/account/order.html:62
#: src/accounting/templates/accounting/currency/include/form.html:57
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:195
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:196
#: src/accounting/templates/accounting/journal-entry/include/form.html:80
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:72
#: src/accounting/templates/accounting/journal-entry/order.html:61
#: src/accounting/templates/accounting/option/form.html:80
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:67
msgid "Save"
msgstr "儲存"
@ -1008,7 +1008,7 @@ msgid "Code"
msgstr "代碼"
#: src/accounting/templates/accounting/currency/include/form.html:50
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:33
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:34
msgid "Name"
msgstr "名稱"
@ -1077,53 +1077,53 @@ msgstr "選擇科目"
msgid "More…"
msgstr "更多…"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:36
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:37
msgid "Offset..."
msgstr "抵銷…"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:44
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:45
msgid "General"
msgstr "一般"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:49
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:50
msgid "Travel"
msgstr "差旅"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:54
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:55
msgid "Bus"
msgstr "公車"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:59
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:60
msgid "Recurring"
msgstr "常用"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:64
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:65
msgid "Annotation"
msgstr "註記"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:73
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:90
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:125
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:74
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:91
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:126
msgid "Tag"
msgstr "標籤"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:105
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:146
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:106
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:147
#: src/accounting/templates/accounting/report/include/period-chooser.html:129
msgid "From"
msgstr "從"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:114
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:151
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:115
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:152
#: src/accounting/templates/accounting/report/include/period-chooser.html:135
msgid "To"
msgstr "至"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:130
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:131
msgid "Route"
msgstr "路線"
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:173
msgid "The Number of Items"
msgstr "數量"
@ -1155,11 +1155,11 @@ msgstr "確認刪除傳票"
msgid "Do you really want to delete this journal entry?"
msgstr "你確定要刪掉這張傳票嗎?"
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:27
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
msgid "Line Item Content"
msgstr "分錄內容"
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:34
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:35
msgid "Original Line Item"
msgstr "原始分錄"
@ -1215,43 +1215,43 @@ msgstr "常用支出"
msgid "Recurring Income"
msgstr "常用收入"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:47
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:48
msgid "Description Template"
msgstr "摘要範本"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:52
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:53
msgid "Available template variables:"
msgstr "範本變數說明:"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:54
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
msgid "This month, as a number."
msgstr "這個月的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
msgid "This month, in its name."
msgstr "這個月的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
msgid "Last month, as a number."
msgstr "上個月的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
msgid "Last month, in its name."
msgstr "上個月的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
msgid "The previous bimonthly period, as numbers."
msgstr "前個雙月期的數字。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:60
msgid "The previous bimonthly period, as their names."
msgstr "前個雙月期的名稱。"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
msgid "Example:"
msgstr "範例:"
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
msgid "Water bill for {last_bimonthly_name}"
msgstr "水費{last_bimonthly_number}月"
@ -1318,13 +1318,13 @@ msgstr "%(period)s%(currency)s試算表"
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29
#: src/accounting/templates/accounting/report/unapplied-accounts.html:49
msgid "Accounts with Unapplied Items"
msgid "Accounts With Unapplied Items"
msgstr "含未抵銷項目的科目"
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29
#: src/accounting/templates/accounting/report/unapplied-accounts.html:51
#, python-format
msgid "Accounts with Unapplied Items in %(currency)s"
msgid "Accounts With Unapplied Items in %(currency)s"
msgstr "%(currency)s含未抵銷項目的科目"
#: src/accounting/templates/accounting/report/unapplied.html:29
@ -1339,13 +1339,13 @@ msgstr "%(currency)s%(account)s未抵銷項目"
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
#: src/accounting/templates/accounting/report/unmatched-accounts.html:49
msgid "Accounts with Unmatched Offsets"
msgid "Accounts With Unmatched Offsets"
msgstr "含遺漏抵銷項目的科目"
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
#: src/accounting/templates/accounting/report/unmatched-accounts.html:51
#, python-format
msgid "Accounts with Unmatched Offsets in %(currency)s"
msgid "Accounts With Unmatched Offsets in %(currency)s"
msgstr "%(currency)s含遺漏抵銷項目的科目"
#: src/accounting/templates/accounting/report/unmatched.html:29
@ -1415,12 +1415,12 @@ msgstr "下載"
msgid "current assets and liabilities"
msgstr "流動資產與負債"
#: src/accounting/utils/pagination.py:206
#: src/accounting/utils/pagination.py:207
msgctxt "Pagination|"
msgid "Previous"
msgstr "上一頁"
#: src/accounting/utils/pagination.py:255
#: src/accounting/utils/pagination.py:256
msgctxt "Pagination|"
msgid "Next"
msgstr "下一頁"

View File

@ -75,8 +75,7 @@ def __get_next() -> str | None:
if next_uri is None:
return None
try:
return URLSafeSerializer(current_app.config["SECRET_KEY"])\
.loads(next_uri, "next")
return decode_next(next_uri)
except BadData:
return None
@ -107,6 +106,16 @@ def encode_next(uri: str) -> str:
.dumps(uri, "next")
def decode_next(uri: str) -> str:
"""Decodes the encoded next URI.
:param uri: The encoded next URI.
:return: The next URI.
"""
return URLSafeSerializer(current_app.config["SECRET_KEY"])\
.loads(uri, "next")
def init_app(bp: Blueprint) -> None:
"""Initializes the application.

View File

@ -39,8 +39,11 @@ class RecurringItem:
:param description_template: The description template.
"""
self.name: str = name
"""The name."""
self.account_code: str = account_code
"""The account code."""
self.description_template: str = description_template
"""The description template."""
@property
def account_text(self) -> str:
@ -61,8 +64,10 @@ class Recurring:
"""
self.expenses: list[RecurringItem] \
= [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]]
"""The recurring expenses."""
self.incomes: list[RecurringItem] \
= [RecurringItem(x[0], x[1], x[2]) for x in data["income"]]
"""The recurring incomes."""
@property
def codes(self) -> set[str]:

View File

@ -63,6 +63,7 @@ DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
T = TypeVar("T")
"""The pagination item type."""
class Pagination(Generic[T]):

View File

@ -0,0 +1,59 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/7/29
# 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 title case capitalization for the base account titles.
This follows the APA style title case capitalization. See
https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case .
This module should not import any other module from the application.
"""
import re
CONJUNCTIONS: set[str] = {"and", "as", "but", "for", "if", "nor", "or", "so",
"yet"}
"""Short conjunctions."""
ARTICLES: set[str] = {"a", "an", "the"}
"""Articles."""
PREPOSITIONS: set[str] = {"as", "at", "by", "for", "in", "of", "on", "per",
"to", "up", "via"}
"""Short prepositions."""
MINOR_WORDS: set[str] \
= CONJUNCTIONS.copy().union(ARTICLES).union(PREPOSITIONS)
"""Minor words that should be in lowercase."""
# Excludes "by" as in "1223 by-products"
MINOR_WORDS.remove("by")
def title_case(s: str) -> str:
"""Capitalize a title string for the base account titles. Do not use it
in other places. This excludes "by" as in "1223 by-products".
:param s: The title string.
:return: The capitalized title string.
"""
return re.sub(r"\w+", __cap_word, s)
def __cap_word(m: re.Match) -> str:
"""Capitalize a matched title word.
:param m: The matched title word.
:return: The capitalized title word.
"""
if m.group(0).lower() in MINOR_WORDS:
return m.group(0)
return m.group(0).title()

View File

@ -27,6 +27,7 @@ from flask import g, Response
from flask_sqlalchemy.model import Model
T = TypeVar("T", bound=Model)
"""The user data model data type."""
class UserUtilityInterface(Generic[T], ABC):

View File

@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
from opencc import OpenCC
root_dir: Path = Path(__file__).parent.parent
"""The project root directory."""
translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
"""The directory of the translation files."""
domain: str = "messages"
"""The message domain."""
@click.group()

View File

@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
from opencc import OpenCC
root_dir: Path = Path(__file__).parent.parent
"""The project root directory."""
translation_dir: Path = root_dir / "src" / "accounting" / "translations"
"""The directory of the translation files."""
domain: str = "accounting"
"""The message domain."""
@click.group()

View File

@ -25,8 +25,8 @@ from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
add_journal_entry
from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
set_locale, add_journal_entry
class AccountData:
@ -72,30 +72,35 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context():
with self.__app.app_context():
from accounting.models import Account, AccountL10n
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "editor")
self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": CASH.base_code,
"title": CASH.title})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code,
"title": CASH.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{CASH.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": BANK.base_code,
"title": BANK.title})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"base_code": BANK.base_code,
"title": BANK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{BANK.code}")
@ -106,7 +111,8 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self.app, "nobody")
client: httpx.Client = get_client(self.__app, "nobody")
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get(PREFIX)
@ -140,12 +146,12 @@ class AccountTestCase(unittest.TestCase):
response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 403)
with self.app.app_context():
with self.__app.app_context():
cash_id: int = Account.find_by_code(CASH.code).id
response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token,
"next": self.encoded_next_uri,
"next": self.__encoded_next_uri,
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403)
@ -155,7 +161,8 @@ class AccountTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account
client, csrf_token = get_client(self.app, "viewer")
client: httpx.Client = get_client(self.__app, "viewer")
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get(PREFIX)
@ -189,12 +196,12 @@ class AccountTestCase(unittest.TestCase):
response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
with self.__app.app_context():
cash_id: int = Account.find_by_code(CASH.code).id
response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token,
"next": self.encoded_next_uri,
"next": self.__encoded_next_uri,
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403)
@ -206,48 +213,48 @@ class AccountTestCase(unittest.TestCase):
from accounting.models import Account
response: httpx.Response
response = self.client.get(PREFIX)
response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{CASH.code}")
response = self.__client.get(f"{PREFIX}/{CASH.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
response = self.__client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{STOCK.code}")
response = self.client.get(f"{PREFIX}/{CASH.code}/edit")
response = self.__client.get(f"{PREFIX}/{CASH.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{CASH.code}/update",
data={"csrf_token": self.csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
response = self.__client.post(f"{PREFIX}/{CASH.code}/update",
data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.csrf_token})
response = self.__client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
response = self.client.get(f"{PREFIX}/bases/{CASH.base_code}")
response = self.__client.get(f"{PREFIX}/bases/{CASH.base_code}")
self.assertEqual(response.status_code, 200)
with self.app.app_context():
with self.__app.app_context():
cash_id: int = Account.find_by_code(CASH.code).id
response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri,
f"{cash_id}-no": "5"})
response = self.__client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri,
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -262,96 +269,97 @@ class AccountTestCase(unittest.TestCase):
detail_uri: str = f"{PREFIX}/{STOCK.code}"
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, BANK.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"base_code": STOCK.base_code,
"title": STOCK.title})
response = self.__client.post(store_uri,
data={"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"base_code": STOCK.base_code,
"title": STOCK.title})
response = self.__client.post(store_uri,
data={"csrf_token":
f"{self.__csrf_token}-2",
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 400)
# Empty base account code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": STOCK.title})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": " ",
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Non-existing base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": STOCK.title})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": "9999",
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Unavailable base account
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": STOCK.title})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": "1",
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": STOCK.base_code,
"title": " "})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# A nominal account that needs offset
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": STOCK.title,
"is_need_offset": "yes"})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": "6172",
"title": STOCK.title,
"is_need_offset": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {STOCK.base_code} ",
"title": f" {STOCK.title} "})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": f" {STOCK.base_code} ",
"title": f" {STOCK.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Success under the same base
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{STOCK.base_code}-002")
# Success under the same base, with order in a mess.
with self.app.app_context():
with self.__app.app_context():
stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002")
stock_2.no = 66
db.session.commit()
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{STOCK.base_code}-003")
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, BANK.code, STOCK.code,
f"{STOCK.base_code}-002",
@ -374,71 +382,71 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {CASH.base_code} ",
"title": f" {CASH.title}-1 "})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": f" {CASH.base_code} ",
"title": f" {CASH.title}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
account: Account = Account.find_by_code(CASH.code)
self.assertEqual(account.base_code, CASH.base_code)
self.assertEqual(account.title_l10n, f"{CASH.title}-1")
# Empty base account code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": " ",
"title": STOCK.title})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": " ",
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "9999",
"title": STOCK.title})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": "9999",
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Unavailable base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "1",
"title": STOCK.title})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": "1",
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": STOCK.base_code,
"title": " "})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code,
"title": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# A nominal account that needs offset
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": "6172",
"title": STOCK.title,
"is_need_offset": "yes"})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": "6172",
"title": STOCK.title,
"is_need_offset": "yes"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change the base account
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": STOCK.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
response = self.__client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
@ -452,14 +460,14 @@ class AccountTestCase(unittest.TestCase):
account: Account
response: httpx.Response
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": f" {CASH.base_code} ",
"title": f" {CASH.title} "})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": f" {CASH.base_code} ",
"title": f" {CASH.title} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
account.created_at \
@ -467,14 +475,14 @@ class AccountTestCase(unittest.TestCase):
account.updated_at = account.created_at
db.session.commit()
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": CASH.base_code,
"title": STOCK.title})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code,
"title": STOCK.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account)
self.assertLess(account.created_at,
@ -487,13 +495,14 @@ class AccountTestCase(unittest.TestCase):
"""
from accounting.models import Account
editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username)
client: httpx.Client = get_client(self.__app, admin_username)
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}/{CASH.code}"
update_uri: str = f"{PREFIX}/{CASH.code}/update"
account: Account
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertEqual(account.created_by.username, editor_username)
self.assertEqual(account.updated_by.username, editor_username)
@ -505,7 +514,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertEqual(account.created_by.username,
editor_username)
@ -523,51 +532,51 @@ class AccountTestCase(unittest.TestCase):
account: Account
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual(account.l10n, [])
set_locale(self.app, self.client, self.csrf_token, "zh_Hant")
set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant"})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(self.app, self.client, self.csrf_token, "en")
set_locale(self.__app, self.__client, self.__csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, f"{CASH.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(self.app, self.client, self.csrf_token, "zh_Hant")
set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant-2"})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"base_code": CASH.base_code,
"title": f"{CASH.title}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(CASH.code)
self.assertEqual(account.title_l10n, f"{CASH.title}-2")
self.assertEqual({(x.locale, x.title) for x in account.l10n},
@ -584,53 +593,53 @@ class AccountTestCase(unittest.TestCase):
list_uri: str = PREFIX
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": PETTY.base_code,
"title": PETTY.title})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"base_code": PETTY.base_code,
"title": PETTY.title})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri,
add_journal_entry(self.__client,
form={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri,
"date": dt.date.today().isoformat(),
"currency-1-code": "USD",
"currency-1-credit-1-account_code": BANK.code,
"currency-1-credit-1-amount": "20"})
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, PETTY.code, BANK.code})
# Cannot delete the cash account
response = self.client.post(f"{PREFIX}/{CASH.code}/delete",
data={"csrf_token": self.csrf_token})
response = self.__client.post(f"{PREFIX}/{CASH.code}/delete",
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
# Cannot delete the account that is in use
response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.csrf_token})
response = self.__client.post(f"{PREFIX}/{BANK.code}/delete",
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}")
# Success
response = self.client.get(detail_uri)
response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
response = self.__client.post(delete_uri,
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Account.query.all()},
{CASH.code, BANK.code})
response = self.client.get(detail_uri)
response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
response = self.__client.post(delete_uri,
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 404)
def test_change_base_code(self) -> None:
@ -642,15 +651,15 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response
for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}")
with self.app.app_context():
with self.__app.app_context():
account_1: Account = Account.find_by_code("1111-001")
id_1: int = account_1.id
account_2: Account = Account.find_by_code("1111-002")
@ -670,14 +679,14 @@ class AccountTestCase(unittest.TestCase):
account_5.no = 6
db.session.commit()
response = self.client.post(f"{PREFIX}/1111-005/update",
data={"csrf_token": self.csrf_token,
"base_code": "1112",
"title": "Title"})
response = self.__client.post(f"{PREFIX}/1111-005/update",
data={"csrf_token": self.__csrf_token,
"base_code": "1112",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
with self.app.app_context():
with self.__app.app_context():
self.assertEqual(db.session.get(Account, id_1).no, 1)
self.assertEqual(db.session.get(Account, id_2).no, 3)
self.assertEqual(db.session.get(Account, id_3).no, 2)
@ -693,34 +702,34 @@ class AccountTestCase(unittest.TestCase):
response: httpx.Response
for i in range(2, 6):
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Title"})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"base_code": "1111",
"title": "Title"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/1111-00{i}")
# Normal reorder
with self.app.app_context():
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(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri,
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"})
response = self.__client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri,
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"], NEXT_URI)
with self.app.app_context():
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")
@ -728,7 +737,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
# Malformed orders
with self.app.app_context():
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
@ -736,16 +745,16 @@ class AccountTestCase(unittest.TestCase):
db.session.get(Account, id_5).no = 9
db.session.commit()
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri,
f"{id_2}-no": "3a",
f"{id_3}-no": "5",
f"{id_4}-no": "2"})
response = self.__client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri,
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"], NEXT_URI)
with self.app.app_context():
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")

View File

@ -39,14 +39,15 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
client: httpx.Client = get_client(self.__app, "nobody")
response: httpx.Response
response = client.get(LIST_URI)
@ -60,7 +61,7 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
client: httpx.Client = get_client(self.__app, "viewer")
response: httpx.Response
response = client.get(LIST_URI)
@ -74,7 +75,7 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "editor")
client: httpx.Client = get_client(self.__app, "editor")
response: httpx.Response
response = client.get(LIST_URI)

View File

@ -18,6 +18,7 @@
"""
import csv
import re
import unittest
from typing import Any
@ -27,6 +28,7 @@ from flask import Flask
from flask.testing import FlaskCliRunner
from sqlalchemy.sql.ddl import DropTable
from accounting.utils.title_case import MINOR_WORDS
from test_site import db
from testlib import create_test_app
@ -40,9 +42,10 @@ class ConsoleCommandTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context():
with self.__app.app_context():
# Drop every accounting table, to see if accounting-init recreates
# them correctly.
tables: list[sa.Table] \
@ -61,8 +64,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
:return: None.
"""
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
runner: FlaskCliRunner = self.__app.test_cli_runner()
with self.__app.app_context():
result: Result = runner.invoke(
args=["accounting-init-db", "-u", "editor"])
self.assertEqual(result.exit_code, 0,
@ -80,20 +83,23 @@ class ConsoleCommandTestCase(unittest.TestCase):
from accounting.models import BaseAccount
with open(data_dir / "base_accounts.csv") as fp:
data: dict[dict[str, Any]] \
= {x["code"]: {"code": x["code"],
"title": x["title"],
"l10n": {y[5:]: x[y]
for y in x if y.startswith("l10n-")}}
for x in csv.DictReader(fp)}
rows: list[dict[str, str]] = list(csv.DictReader(fp))
data: dict[dict[str, Any]] \
= {x["code"]: {"code": x["code"],
"title": x["title"],
"l10n": {y[5:]: x[y]
for y in x if y.startswith("l10n-")}}
for x in rows}
with self.app.app_context():
with self.__app.app_context():
accounts: list[BaseAccount] = BaseAccount.query.all()
self.assertEqual(len(accounts), len(data))
for account in accounts:
self.assertIn(account.code, data)
self.assertEqual(account.title_l10n, data[account.code]["title"])
self.assertEqual(account.title_l10n.lower(),
data[account.code]["title"].lower())
self.__test_title_case(account.title_l10n)
l10n: dict[str, str] = {x.locale: x.title for x in account.l10n}
self.assertEqual(len(l10n), len(data[account.code]["l10n"]))
for locale in l10n:
@ -101,6 +107,21 @@ class ConsoleCommandTestCase(unittest.TestCase):
self.assertEqual(l10n[locale],
data[account.code]["l10n"][locale])
def __test_title_case(self, s: str) -> None:
"""Tests the case of a base account title.
:param s: The base account title.
:return: None.
"""
self.assertTrue(s[0].isupper(), s)
for word in re.findall(r"\w+", s):
if len(word) >= 4:
self.assertTrue(word.istitle(), s)
elif word in MINOR_WORDS:
self.assertTrue(word.islower(), s)
else:
self.assertTrue(word.istitle(), s)
def __test_account_data(self) -> None:
"""Tests the account data.
@ -108,7 +129,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
"""
from accounting.models import BaseAccount, Account, AccountL10n
with self.app.app_context():
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()
@ -142,7 +163,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
for y in x if y.startswith("l10n-")}}
for x in csv.DictReader(fp)}
with self.app.app_context():
with self.__app.app_context():
currencies: list[Currency] = Currency.query.all()
self.assertEqual(len(currencies), len(data))

View File

@ -25,8 +25,8 @@ from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
add_journal_entry
from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
set_locale, add_journal_entry
class CurrencyData:
@ -65,28 +65,32 @@ class CurrencyTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context():
with self.__app.app_context():
from accounting.models import Currency, CurrencyL10n
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
self.client, self.csrf_token = get_client(self.app, "editor")
self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": USD.code,
"name": USD.name})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"code": USD.code,
"name": USD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": EUR.code,
"name": EUR.name})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"code": EUR.code,
"name": EUR.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
@ -95,7 +99,8 @@ class CurrencyTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
client: httpx.Client = get_client(self.__app, "nobody")
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get(PREFIX)
@ -131,7 +136,8 @@ class CurrencyTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
client: httpx.Client = get_client(self.__app, "viewer")
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get(PREFIX)
@ -169,34 +175,34 @@ class CurrencyTestCase(unittest.TestCase):
"""
response: httpx.Response
response = self.client.get(PREFIX)
response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/{USD.code}")
response = self.__client.get(f"{PREFIX}/{USD.code}")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/create")
response = self.__client.get(f"{PREFIX}/create")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": TWD.code,
"name": TWD.name})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{TWD.code}")
response = self.client.get(f"{PREFIX}/{USD.code}/edit")
response = self.__client.get(f"{PREFIX}/{USD.code}/edit")
self.assertEqual(response.status_code, 200)
response = self.client.post(f"{PREFIX}/{USD.code}/update",
data={"csrf_token": self.csrf_token,
"code": JPY.code,
"name": JPY.name})
response = self.__client.post(f"{PREFIX}/{USD.code}/update",
data={"csrf_token": self.__csrf_token,
"code": JPY.code,
"name": JPY.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{JPY.code}")
response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.csrf_token})
response = self.__client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], PREFIX)
@ -211,72 +217,73 @@ class CurrencyTestCase(unittest.TestCase):
detail_uri: str = f"{PREFIX}/{TWD.code}"
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code})
# Missing CSRF token
response = self.client.post(store_uri,
data={"code": TWD.code,
"name": TWD.name})
response = self.__client.post(store_uri,
data={"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 400)
# CSRF token mismatch
response = self.client.post(store_uri,
data={"csrf_token": f"{self.csrf_token}-2",
"code": TWD.code,
"name": TWD.name})
response = self.__client.post(store_uri,
data={"csrf_token":
f"{self.__csrf_token}-2",
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 400)
# Empty code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": TWD.name})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"code": " ",
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": TWD.name})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"code": " create ",
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Bad code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": " zzc ",
"name": TWD.name})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"code": " zzc ",
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Empty name
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": TWD.code,
"name": " "})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"code": TWD.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Success, with spaces to be stripped
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": f" {TWD.code} ",
"name": f" {TWD.name} "})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"code": f" {TWD.code} ",
"name": f" {TWD.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
# Duplicated code
response = self.client.post(store_uri,
data={"csrf_token": self.csrf_token,
"code": TWD.code,
"name": TWD.name})
response = self.__client.post(store_uri,
data={"csrf_token": self.__csrf_token,
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code, TWD.code})
@ -297,70 +304,70 @@ class CurrencyTestCase(unittest.TestCase):
response: httpx.Response
# Success, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {USD.code} ",
"name": f" {USD.name}-1 "})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": f" {USD.code} ",
"name": f" {USD.name}-1 "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
currency: Currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.code, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-1")
# Empty code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " ",
"name": TWD.name})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": " ",
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Blocked code, with spaces to be stripped
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": " create ",
"name": TWD.name})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": " create ",
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Bad code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": "abc/def",
"name": TWD.name})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": "abc/def",
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty name
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": TWD.code,
"name": " "})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": TWD.code,
"name": " "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Duplicated code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": EUR.code,
"name": TWD.name})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": EUR.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Change code
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": TWD.code,
"name": TWD.name})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": TWD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_c_uri)
response = self.client.get(detail_uri)
response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.get(detail_c_uri)
response = self.__client.get(detail_c_uri)
self.assertEqual(response.status_code, 200)
def test_update_not_modified(self) -> None:
@ -374,14 +381,14 @@ class CurrencyTestCase(unittest.TestCase):
currency: Currency | None
response: httpx.Response
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": f" {USD.code} ",
"name": f" {USD.name} "})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": f" {USD.code} ",
"name": f" {USD.name} "})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency)
currency.created_at \
@ -389,14 +396,14 @@ class CurrencyTestCase(unittest.TestCase):
currency.updated_at = currency.created_at
db.session.commit()
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": USD.code,
"name": TWD.name})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": USD.code,
"name": TWD.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency)
self.assertLess(currency.created_at,
@ -409,13 +416,14 @@ class CurrencyTestCase(unittest.TestCase):
"""
from accounting.models import Currency
editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username)
client: httpx.Client = get_client(self.__app, admin_username)
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}/{USD.code}"
update_uri: str = f"{PREFIX}/{USD.code}/update"
currency: Currency
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, editor_username)
@ -427,7 +435,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.created_by.username, editor_username)
self.assertEqual(currency.updated_by.username, admin_username)
@ -439,14 +447,14 @@ class CurrencyTestCase(unittest.TestCase):
"""
response: httpx.Response
response = self.client.get(
response = self.__client.get(
f"/accounting/api/currencies/exists-code?q={USD.code}")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(set(data.keys()), {"exists"})
self.assertTrue(data["exists"])
response = self.client.get(
response = self.__client.get(
f"/accounting/api/currencies/exists-code?q={USD.code}-1")
self.assertEqual(response.status_code, 200)
data = response.json()
@ -464,51 +472,51 @@ class CurrencyTestCase(unittest.TestCase):
currency: Currency
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual(currency.l10n, [])
set_locale(self.app, self.client, self.csrf_token, "zh_Hant")
set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": USD.code,
"name": f"{USD.name}-zh_Hant"})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": USD.code,
"name": f"{USD.name}-zh_Hant"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.app, self.client, self.csrf_token, "en")
set_locale(self.__app, self.__client, self.__csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": USD.code,
"name": f"{USD.name}-2"})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": USD.code,
"name": f"{USD.name}-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.app, self.client, self.csrf_token, "zh_Hant")
set_locale(self.__app, self.__client, self.__csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
"code": USD.code,
"name": f"{USD.name}-zh_Hant-2"})
response = self.__client.post(update_uri,
data={"csrf_token": self.__csrf_token,
"code": USD.code,
"name": f"{USD.name}-zh_Hant-2"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
currency = db.session.get(Currency, USD.code)
self.assertEqual(currency.name_l10n, f"{USD.name}-2")
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
@ -522,56 +530,56 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{JPY.code}"
delete_uri: str = f"{PREFIX}/{JPY.code}/delete"
with self.app.app_context():
with self.__app.app_context():
encoded_next_uri: str = encode_next(NEXT_URI)
list_uri: str = PREFIX
response: httpx.Response
response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token,
"code": JPY.code,
"name": JPY.name})
response = self.__client.post(f"{PREFIX}/store",
data={"csrf_token": self.__csrf_token,
"code": JPY.code,
"name": JPY.name})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
add_journal_entry(self.__client,
form={"csrf_token": self.__csrf_token,
"next": encoded_next_uri,
"date": dt.date.today().isoformat(),
"currency-1-code": EUR.code,
"currency-1-credit-1-account_code": "1111-001",
"currency-1-credit-1-amount": "20"})
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code, JPY.code})
# Cannot delete the default currency
response = self.client.post(f"{PREFIX}/{USD.code}/delete",
data={"csrf_token": self.csrf_token})
response = self.__client.post(f"{PREFIX}/{USD.code}/delete",
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
# Cannot delete the account that is in use
response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.csrf_token})
response = self.__client.post(f"{PREFIX}/{EUR.code}/delete",
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
# Success
response = self.client.get(detail_uri)
response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
response = self.__client.post(delete_uri,
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], list_uri)
with self.app.app_context():
with self.__app.app_context():
self.assertEqual({x.code for x in Currency.query.all()},
{USD.code, EUR.code})
response = self.client.get(detail_uri)
response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token})
response = self.__client.post(delete_uri,
data={"csrf_token": self.__csrf_token})
self.assertEqual(response.status_code, 404)

View File

@ -20,11 +20,12 @@
import datetime as dt
import unittest
import httpx
from flask import Flask
from accounting.utils.next_uri import encode_next
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
add_journal_entry
get_csrf_token, add_journal_entry
class DescriptionEditorTestCase(unittest.TestCase):
@ -36,15 +37,20 @@ class DescriptionEditorTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context():
with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "editor")
self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_description_editor(self) -> None:
"""Test the description editor.
@ -53,9 +59,9 @@ class DescriptionEditorTestCase(unittest.TestCase):
"""
from accounting.journal_entry.utils.description_editor import \
DescriptionEditor
for form in get_form_data(self.csrf_token, self.encoded_next_uri):
add_journal_entry(self.client, form)
with self.app.app_context():
for form in get_form_data(self.__csrf_token, self.__encoded_next_uri):
add_journal_entry(self.__client, form)
with self.__app.app_context():
editor: DescriptionEditor = DescriptionEditor()
# Debit-General

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,8 @@ from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
get_csrf_token
PREFIX: str = "/accounting/options"
"""The URL prefix for the option management."""
@ -40,23 +41,29 @@ class OptionTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context():
with self.__app.app_context():
from accounting.models import Option
Option.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "admin")
self.__client: httpx.Client = get_client(self.__app, "admin")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
detail_uri: str = f"{PREFIX}?next={self.encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.encoded_next_uri}"
client: httpx.Client = get_client(self.__app, "nobody")
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response
@ -74,9 +81,10 @@ class OptionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
detail_uri: str = f"{PREFIX}?next={self.encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.encoded_next_uri}"
client: httpx.Client = get_client(self.__app, "viewer")
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response
@ -94,9 +102,10 @@ class OptionTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "editor")
detail_uri: str = f"{PREFIX}?next={self.encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.encoded_next_uri}"
client: httpx.Client = get_client(self.__app, "editor")
csrf_token: str = get_csrf_token(client)
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response
@ -114,18 +123,18 @@ class OptionTestCase(unittest.TestCase):
:return: None.
"""
detail_uri: str = f"{PREFIX}?next={self.encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.encoded_next_uri}"
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response
response = self.client.get(detail_uri)
response = self.__client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.get(edit_uri)
response = self.__client.get(edit_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(update_uri, data=self.__get_form())
response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
@ -135,8 +144,8 @@ class OptionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.utils.options import options
detail_uri: str = f"{PREFIX}?next={self.encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.encoded_next_uri}"
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
edit_uri: str = f"{PREFIX}/edit?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
form: dict[str, str]
response: httpx.Response
@ -144,35 +153,35 @@ class OptionTestCase(unittest.TestCase):
# Empty currency code
form = self.__get_form()
form["default_currency_code"] = " "
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing currency code
form = self.__get_form()
form["default_currency_code"] = "ZZZ"
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Empty current account
form = self.__get_form()
form["default_ie_account_code"] = " "
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing current account
form = self.__get_form()
form["default_ie_account_code"] = "9999-999"
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not a current account
form = self.__get_form()
form["default_ie_account_code"] = Accounts.MEAL
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -180,7 +189,7 @@ class OptionTestCase(unittest.TestCase):
form = self.__get_form()
key = [x for x in form if x.endswith("-name")][0]
form[key] = " "
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -188,7 +197,7 @@ class OptionTestCase(unittest.TestCase):
form = self.__get_form()
key = [x for x in form if x.endswith("-account_code")][0]
form[key] = " "
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -198,7 +207,7 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.SERVICE
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -208,7 +217,7 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-income-")
and x.endswith("-account_code")][0]
form[key] = Accounts.UTILITIES
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -218,7 +227,7 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.PAYABLE
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -228,7 +237,7 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-income-")
and x.endswith("-account_code")][0]
form[key] = Accounts.RECEIVABLE
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
@ -236,22 +245,22 @@ class OptionTestCase(unittest.TestCase):
form = self.__get_form()
key = [x for x in form if x.endswith("-description_template")][0]
form[key] = " "
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success, with malformed order
with self.app.app_context():
with self.__app.app_context():
self.assertEqual(options.default_currency_code, "USD")
self.assertEqual(options.default_ie_account_code, "1111-001")
self.assertEqual(len(options.recurring.expenses), 0)
self.assertEqual(len(options.recurring.incomes), 0)
response = self.client.post(update_uri, data=self.__get_form())
response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
self.assertEqual(options.default_currency_code, "EUR")
self.assertEqual(options.default_ie_account_code, "0000-000")
self.assertEqual(len(options.recurring.expenses), 4)
@ -272,11 +281,11 @@ class OptionTestCase(unittest.TestCase):
# Success, with no recurring data
form = self.__get_form()
form = {x: form[x] for x in form if not x.startswith("recurring-")}
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
self.assertEqual(len(options.recurring.expenses), 0)
self.assertEqual(len(options.recurring.incomes), 0)
@ -286,17 +295,17 @@ class OptionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Option
detail_uri: str = f"{PREFIX}?next={self.encoded_next_uri}"
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
form: dict[str, str]
option: Option | None
resource: httpx.Response
response = self.client.post(update_uri, data=self.__get_form())
response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
timestamp: dt.datetime \
@ -308,11 +317,11 @@ class OptionTestCase(unittest.TestCase):
# The recurring setting was not modified
form = self.__get_form()
form["default_currency_code"] = "JPY"
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
self.assertEqual(option.created_at, timestamp)
@ -324,11 +333,11 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
self.assertLess(option.created_at, option.updated_at)
@ -341,16 +350,16 @@ class OptionTestCase(unittest.TestCase):
from accounting.models import Option
from accounting.utils.user import get_user_pk
admin_username, editor_username = "admin", "editor"
detail_uri: str = f"{PREFIX}?next={self.encoded_next_uri}"
detail_uri: str = f"{PREFIX}?next={self.__encoded_next_uri}"
update_uri: str = f"{PREFIX}/update"
option: Option | None
response: httpx.Response
response = self.client.post(update_uri, data=self.__get_form())
response = self.__client.post(update_uri, data=self.__get_form())
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
editor_pk: int = get_user_pk(editor_username)
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
@ -363,11 +372,11 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL
response = self.client.post(update_uri, data=form)
response = self.__client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
with self.__app.app_context():
option = db.session.get(Option, "recurring")
self.assertIsNotNone(option)
self.assertEqual(option.created_by.username, editor_username)
@ -380,9 +389,9 @@ class OptionTestCase(unittest.TestCase):
:return: The option form.
"""
if csrf_token is None:
csrf_token = self.csrf_token
csrf_token = self.__csrf_token
return {"csrf_token": csrf_token,
"next": self.encoded_next_uri,
"next": self.__encoded_next_uri,
"default_currency_code": "EUR",
"default_ie_account_code": "0000-000",
"recurring-expense-1-name": "Water bill",

View File

@ -24,7 +24,7 @@ import httpx
from flask import Flask
from test_site.lib import BaseTestData
from testlib import create_test_app, get_client, Accounts
from testlib import create_test_app, get_client, get_csrf_token, Accounts
PREFIX: str = "/accounting"
"""The URL prefix for the reports."""
@ -41,22 +41,26 @@ class ReportTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context():
with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.client, self.csrf_token = get_client(self.app, "editor")
self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
ReportTestData(self.app, "editor").populate()
client: httpx.Client = get_client(self.__app, "nobody")
ReportTestData(self.__app, "editor").populate()
response: httpx.Response
response = client.get(PREFIX)
@ -146,8 +150,8 @@ class ReportTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
ReportTestData(self.app, "editor").populate()
client: httpx.Client = get_client(self.__app, "viewer")
ReportTestData(self.__app, "editor").populate()
response: httpx.Response
response = client.get(PREFIX)
@ -248,101 +252,101 @@ class ReportTestCase(unittest.TestCase):
:return: None.
"""
ReportTestData(self.app, "editor").populate()
ReportTestData(self.__app, "editor").populate()
response: httpx.Response
response = self.client.get(PREFIX)
response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}?as=csv")
response = self.__client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/journal")
response = self.__client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/journal?as=csv")
response = self.__client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/ledger")
response = self.__client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/ledger?as=csv")
response = self.__client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-expenses")
response = self.__client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
response = self.__client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/trial-balance")
response = self.__client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
response = self.__client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-statement")
response = self.__client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-statement?as=csv")
response = self.__client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/balance-sheet")
response = self.__client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
response = self.__client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied")
response = self.__client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unapplied?as=csv")
response = self.__client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unmatched")
response = self.__client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unmatched?as=csv")
response = self.__client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=Salary")
response = self.__client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
response = self.__client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=薪水")
response = self.__client.get(f"{PREFIX}/search?q=薪水")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=薪水&as=csv")
response = self.__client.get(f"{PREFIX}/search?q=薪水&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
@ -353,91 +357,91 @@ class ReportTestCase(unittest.TestCase):
"""
response: httpx.Response
response = self.client.get(PREFIX)
response = self.__client.get(PREFIX)
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}?as=csv")
response = self.__client.get(f"{PREFIX}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/journal")
response = self.__client.get(f"{PREFIX}/journal")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/journal?as=csv")
response = self.__client.get(f"{PREFIX}/journal?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/ledger")
response = self.__client.get(f"{PREFIX}/ledger")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/ledger?as=csv")
response = self.__client.get(f"{PREFIX}/ledger?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-expenses")
response = self.__client.get(f"{PREFIX}/income-expenses")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-expenses?as=csv")
response = self.__client.get(f"{PREFIX}/income-expenses?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/trial-balance")
response = self.__client.get(f"{PREFIX}/trial-balance")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/trial-balance?as=csv")
response = self.__client.get(f"{PREFIX}/trial-balance?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/income-statement")
response = self.__client.get(f"{PREFIX}/income-statement")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/income-statement?as=csv")
response = self.__client.get(f"{PREFIX}/income-statement?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/balance-sheet")
response = self.__client.get(f"{PREFIX}/balance-sheet")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/balance-sheet?as=csv")
response = self.__client.get(f"{PREFIX}/balance-sheet?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unapplied")
response = self.__client.get(f"{PREFIX}/unapplied")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unapplied?as=csv")
response = self.__client.get(f"{PREFIX}/unapplied?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unapplied/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/unmatched")
response = self.__client.get(f"{PREFIX}/unmatched")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/unmatched?as=csv")
response = self.__client.get(f"{PREFIX}/unmatched?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}")
self.assertEqual(response.status_code, 200)
response = self.client.get(
response = self.__client.get(
f"{PREFIX}/unmatched/USD/{Accounts.PAYABLE}?as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)
response = self.client.get(f"{PREFIX}/search?q=Salary")
response = self.__client.get(f"{PREFIX}/search?q=Salary")
self.assertEqual(response.status_code, 200)
response = self.client.get(f"{PREFIX}/search?q=Salary&as=csv")
response = self.__client.get(f"{PREFIX}/search?q=Salary&as=csv")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers["Content-Type"], CSV_MIME)

View File

@ -23,15 +23,13 @@ from typing import Type
from click.testing import Result
from flask import Flask, Blueprint, render_template, redirect, Response, \
url_for, request
url_for
from flask.testing import FlaskCliRunner
from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy import Column
from accounting.utils.next_uri import encode_next
bp: Blueprint = Blueprint("home", __name__)
"""The global blueprint."""
babel_js: BabelJS = BabelJS()

View File

@ -57,13 +57,20 @@ class JournalEntryLineItemData:
:param original_line_item: The original journal entry line item.
"""
self.journal_entry: JournalEntryData | None = None
"""The journal entry data."""
self.id: int = -1
"""The journal entry line item ID."""
self.no: int = -1
"""The line item number under the journal entry and debit or credit."""
self.original_line_item: JournalEntryLineItemData | None \
= original_line_item
"""The original journal entry line item."""
self.account: str = account
"""The account code."""
self.description: str | None = description
"""The description."""
self.amount: Decimal = Decimal(amount)
"""The amount."""
def form(self, prefix: str, debit_credit: str, index: int,
is_update: bool) -> dict[str, str]:
@ -101,8 +108,11 @@ class JournalEntryCurrencyData:
:param credit: The credit line items.
"""
self.code: str = currency
"""The currency code."""
self.debit: list[JournalEntryLineItemData] = debit
"""The debit line items."""
self.credit: list[JournalEntryLineItemData] = credit
"""The credit line items."""
def form(self, index: int, is_update: bool) -> dict[str, str]:
"""Returns the currency as form data.
@ -131,9 +141,13 @@ class JournalEntryData:
:param currencies: The journal entry currency data.
"""
self.id: int = -1
"""The journal entry ID."""
self.days: int = days
"""The number of days before today."""
self.currencies: list[JournalEntryCurrencyData] = currencies
"""The journal entry currency data."""
self.note: str | None = None
"""The note."""
for currency in self.currencies:
for line_item in currency.debit:
line_item.journal_entry = self
@ -190,13 +204,17 @@ class BaseTestData(ABC):
:param username: The username.
"""
self._app: Flask = app
"""The Flask application."""
with self._app.app_context():
current_user: User | None = User.query\
.filter(User.username == username).first()
assert current_user is not None
self.__current_user_id: int = current_user.id
"""The current user ID."""
self.__journal_entries: list[dict[str, Any]] = []
"""The data of the journal entries."""
self.__line_items: list[dict[str, Any]] = []
"""The data of the journal entry line items."""
self._init_data()
@abstractmethod

View File

@ -26,6 +26,7 @@ from werkzeug.datastructures import LanguageAccept
from accounting.utils.next_uri import or_next
bp: Blueprint = Blueprint("locale", __name__, url_prefix="/")
"""The blueprint for the localization."""
def get_locale():

View File

@ -29,6 +29,7 @@ from .lib import Accounts, JournalEntryLineItemData, JournalEntryData, \
JournalEntryCurrencyData, BaseTestData
bp: Blueprint = Blueprint("reset", __name__, url_prefix="/")
"""The blueprint for the data reset."""
@bp.get("reset", endpoint="reset-page")

View File

@ -25,7 +25,7 @@ First written: 2023/1/27
{% block content %}
<p>{{ _("This is the live demonstration of the Mia! Accounting project. Please <a href=\"/login?next=%%2Faccounting\">log in</a> to continue.") }}</p>
<p>{{ _("This is the live demonstration of the Mia! Accounting project. Please <a href=\"%(url)s\">log in</a> to continue.", url=url_for("auth.login")) }}</p>
<p>{{ _("You may also want to check the <a href=\"https://mia-accounting.readthedocs.io\">full documentation</a> and the <a href=\"https://github.com/imacat/mia-accounting\">Github repository</a>.") }}</p>

View File

@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: mia-accounting-test-site 1.0.0\n"
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
"POT-Creation-Date: 2023-04-12 17:59+0800\n"
"PO-Revision-Date: 2023-04-12 18:00+0800\n"
"POT-Creation-Date: 2023-06-10 10:42+0800\n"
"PO-Revision-Date: 2023-06-10 10:43+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"
@ -24,7 +24,7 @@ msgstr ""
msgid "The sample data are emptied and reset successfully."
msgstr "範例資料已清空重設。"
#: tests/test_site/reset.py:68
#: tests/test_site/reset.py:69
msgid "The database is emptied successfully."
msgstr "資料庫已清空。"
@ -61,8 +61,8 @@ msgstr "Mia! Accounting 示範站"
#, python-format
msgid ""
"This is the live demonstration of the Mia! Accounting project. Please <a"
" href=\"/login?next=%%2Faccounting\">log in</a> to continue."
msgstr "這是 Mia! Accounting 專案的示範站。請先<a href=\"/login?next=%%2Faccounting\">登入</a>。"
" href=\"%(url)s\">log in</a> to continue."
msgstr "這是 Mia! Accounting 專案的示範站。請先<a href=\"%(url)s\">登入</a>。"
#: tests/test_site/templates/home.html:30
msgid ""

View File

@ -26,7 +26,8 @@ from accounting.utils.next_uri import encode_next
from test_site import db
from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \
BaseTestData
from testlib import NEXT_URI, create_test_app, get_client, Accounts
from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
Accounts
PREFIX: str = "/accounting/match-offsets/USD"
"""The URL prefix for the unmatched offset management."""
@ -41,28 +42,34 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.__app: Flask = create_test_app()
"""The Flask application."""
with self.app.app_context():
with self.__app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.__encoded_next_uri: str = encode_next(NEXT_URI)
"""The encoded next URI."""
self.client, self.csrf_token = get_client(self.app, "editor")
self.__client: httpx.Client = get_client(self.__app, "editor")
"""The user client."""
self.__csrf_token: str = get_csrf_token(self.__client)
"""The CSRF token."""
def test_nobody(self) -> None:
"""Test the permission as nobody.
:return: None.
"""
client, csrf_token = get_client(self.app, "nobody")
DifferentTestData(self.app, "nobody").populate()
client: httpx.Client = get_client(self.__app, "nobody")
csrf_token: str = get_csrf_token(client)
DifferentTestData(self.__app, "nobody").populate()
response: httpx.Response
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token,
"next": self.encoded_next_uri})
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
@ -70,13 +77,14 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None.
"""
client, csrf_token = get_client(self.app, "viewer")
DifferentTestData(self.app, "viewer").populate()
client: httpx.Client = get_client(self.__app, "viewer")
csrf_token: str = get_csrf_token(client)
DifferentTestData(self.__app, "viewer").populate()
response: httpx.Response
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token,
"next": self.encoded_next_uri})
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
@ -84,12 +92,12 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
:return: None.
"""
DifferentTestData(self.app, "editor").populate()
DifferentTestData(self.__app, "editor").populate()
response: httpx.Response
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri})
response = self.__client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -100,9 +108,9 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
"""
response: httpx.Response
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri})
response = self.__client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -114,7 +122,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.report.utils.offset_matcher import OffsetMatcher
from accounting.template_globals import default_currency_code
data: DifferentTestData = DifferentTestData(self.app, "editor")
data: DifferentTestData = DifferentTestData(self.__app, "editor")
data.populate()
account: Account | None
line_item: JournalEntryLineItem | None
@ -122,13 +130,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri: str
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
currency: Currency | None \
= db.session.get(Currency, default_currency_code())
assert currency is not None
# The receivables
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -150,13 +158,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNone(line_item.original_line_item_id)
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri})
response = self.__client.post(match_uri,
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -178,7 +186,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -200,13 +208,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertIsNone(line_item.original_line_item_id)
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri})
response = self.__client.post(match_uri,
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -235,7 +243,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
from accounting.models import Currency, Account, JournalEntryLineItem
from accounting.report.utils.offset_matcher import OffsetMatcher
from accounting.template_globals import default_currency_code
data: SameTestData = SameTestData(self.app, "editor")
data: SameTestData = SameTestData(self.__app, "editor")
data.populate()
account: Account | None
line_item: JournalEntryLineItem | None
@ -243,13 +251,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri: str
response: httpx.Response
with self.app.app_context():
with self.__app.app_context():
currency: Currency | None \
= db.session.get(Currency, default_currency_code())
assert currency is not None
# The receivables
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -278,13 +286,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_r_or2d.id)
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri})
response = self.__client.post(match_uri,
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.RECEIVABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -315,7 +323,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_r_or4d.id)
# The payables
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -344,13 +352,13 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
self.assertEqual(line_item.original_line_item_id, data.l_p_or2c.id)
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": self.encoded_next_uri})
response = self.__client.post(match_uri,
data={"csrf_token": self.__csrf_token,
"next": self.__encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
with self.__app.app_context():
account = Account.find_by_code(Accounts.PAYABLE)
assert account is not None
matcher = OffsetMatcher(currency, account)
@ -410,18 +418,22 @@ class DifferentTestData(BaseTestData):
50, [JournalEntryCurrencyData(
"USD", [self.l_r_or1d, self.l_r_or4d],
[self.l_r_or1c, self.l_r_or4c])])
"""The receivable original journal entry #1."""
self.j_r_or2: JournalEntryData = JournalEntryData(
30, [JournalEntryCurrencyData(
"USD", [self.l_r_or2d, self.l_r_or3d],
[self.l_r_or2c, self.l_r_or3c])])
"""The receivable original journal entry #2"""
self.j_p_or1: JournalEntryData = JournalEntryData(
40, [JournalEntryCurrencyData(
"USD", [self.l_p_or1d, self.l_p_or4d],
[self.l_p_or1c, self.l_p_or4c])])
"""The payable original journal entry #1."""
self.j_p_or2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData(
"USD", [self.l_p_or2d, self.l_p_or3d],
[self.l_p_or2c, self.l_p_or3c])])
"""The payable original journal entry #2."""
self._add_journal_entry(self.j_r_or1)
self._add_journal_entry(self.j_r_or2)
@ -456,23 +468,29 @@ class DifferentTestData(BaseTestData):
self.j_r_of1: JournalEntryData = JournalEntryData(
25, [JournalEntryCurrencyData(
"USD", [self.l_r_of1d], [self.l_r_of1c])])
"""The offset journal entry to the receivable #1."""
self.j_r_of2: JournalEntryData = JournalEntryData(
20, [JournalEntryCurrencyData(
"USD", [self.l_r_of2d, self.l_r_of3d, self.l_r_of4d],
[self.l_r_of2c, self.l_r_of3c, self.l_r_of4c])])
"""The offset journal entry to the receivable #2."""
self.j_r_of3: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData(
"USD", [self.l_r_of5d], [self.l_r_of5c])])
"""The offset journal entry to the receivable #3."""
self.j_p_of1: JournalEntryData = JournalEntryData(
15, [JournalEntryCurrencyData(
"USD", [self.l_p_of1d], [self.l_p_of1c])])
"""The offset journal entry to the payable #1."""
self.j_p_of2: JournalEntryData = JournalEntryData(
10, [JournalEntryCurrencyData(
"USD", [self.l_p_of2d, self.l_p_of3d, self.l_p_of4d],
[self.l_p_of2c, self.l_p_of3c, self.l_p_of4c])])
"""The offset journal entry to the payable #2."""
self.j_p_of3: JournalEntryData = JournalEntryData(
5, [JournalEntryCurrencyData(
"USD", [self.l_p_of5d], [self.l_p_of5c])])
"""The offset journal entry to the payable #3."""
self._add_journal_entry(self.j_r_of1)
self._add_journal_entry(self.j_r_of2)

View File

@ -22,9 +22,9 @@ from urllib.parse import quote_plus
import httpx
from flask import Flask, request
from itsdangerous import URLSafeSerializer
from accounting.utils.next_uri import append_next, inherit_next, or_next
from accounting.utils.next_uri import append_next, inherit_next, or_next, \
encode_next, decode_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords
from testlib import TEST_SERVER, create_test_app, get_csrf_token, NEXT_URI
@ -40,9 +40,8 @@ class NextUriTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.serializer: URLSafeSerializer \
= URLSafeSerializer(self.app.config["SECRET_KEY"])
self.__app: Flask = create_test_app()
"""The Flask application."""
def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI.
@ -53,23 +52,29 @@ class NextUriTestCase(unittest.TestCase):
"""The test view with the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
with self.__app.app_context():
encoded_current: str = encode_next(current_uri)
self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={self.__encode(current_uri)}")
next_uri: str = request.form["next"] if request.method == "POST" \
else request.args["next"]
f"{self.TARGET}?next={encoded_current}")
encoded_next_uri: str = request.form["next"] \
if request.method == "POST" else request.args["next"]
self.assertEqual(inherit_next(self.TARGET),
f"{self.TARGET}?next={next_uri}")
self.assertEqual(or_next(self.TARGET), self.__decode(next_uri))
f"{self.TARGET}?next={encoded_next_uri}")
with self.__app.app_context():
next_uri: str = decode_next(encoded_next_uri)
self.assertEqual(or_next(self.TARGET), next_uri)
return ""
self.app.add_url_rule("/test-next", view_func=test_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
self.__app.add_url_rule("/test-next", view_func=test_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.__app,
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
response: httpx.Response
encoded_uri: str = self.__encode(NEXT_URI)
with self.__app.app_context():
encoded_uri: str = encode_next(NEXT_URI)
response = client.get(f"/test-next?next={encoded_uri}&q=abc&page-no=4")
self.assertEqual(response.status_code, 200)
response = client.post("/test-next", data={"csrf_token": csrf_token,
@ -88,9 +93,11 @@ class NextUriTestCase(unittest.TestCase):
self.assertEqual(or_next(self.TARGET), self.TARGET)
return ""
self.app.add_url_rule("/test-no-next", view_func=test_no_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
self.__app.add_url_rule("/test-no-next",
view_func=test_no_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.__app,
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
response: httpx.Response
@ -112,10 +119,11 @@ class NextUriTestCase(unittest.TestCase):
self.assertEqual(or_next(self.TARGET), self.TARGET)
return ""
self.app.add_url_rule("/test-invalid-next",
view_func=test_invalid_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
self.__app.add_url_rule("/test-invalid-next",
view_func=test_invalid_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.__app,
base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
next_uri: str
@ -132,22 +140,6 @@ class NextUriTestCase(unittest.TestCase):
"next": next_uri})
self.assertEqual(response.status_code, 200)
def __encode(self, uri: str) -> str:
"""Encodes the next URI.
:param uri: The next URI.
:return: The encoded next URI.
"""
return self.serializer.dumps(uri, "next")
def __decode(self, uri: str) -> str:
"""Decodes the next URI.
:param uri: The encoded next URI.
:return: The next URI.
"""
return self.serializer.loads(uri, "next")
class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser."""
@ -190,7 +182,7 @@ class PaginationTestCase(unittest.TestCase):
"""The test case for pagination."""
class Params:
"""The testing parameters."""
"""The testing pagination parameters."""
def __init__(self, items: list[int], is_reversed: bool | None,
result: list[int], is_paged: bool):
@ -202,9 +194,13 @@ class PaginationTestCase(unittest.TestCase):
:param is_paged: Whether we need pagination.
"""
self.items: list[int] = items
"""All the items in the list."""
self.is_reversed: bool | None = is_reversed
"""Whether the default page is the last page."""
self.result: list[int] = result
"""The expected items on the page."""
self.is_paged: bool = is_paged
"""Whether we need pagination."""
def setUp(self) -> None:
"""Sets up the test.
@ -212,24 +208,29 @@ class PaginationTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.params = self.Params([], None, [], True)
self.__app: Flask = create_test_app()
"""The Flask application."""
self.__params: PaginationTestCase.Params \
= self.Params([], None, [], True)
"""The testing pagination parameters."""
@self.app.get("/test-pagination")
@self.__app.get("/test-pagination")
def test_pagination_view() -> str:
"""The test view with the pagination."""
pagination: Pagination
if self.params.is_reversed is not None:
if self.__params.is_reversed is not None:
pagination = Pagination[int](
self.params.items, is_reversed=self.params.is_reversed)
self.__params.items, is_reversed=self.__params.is_reversed)
else:
pagination = Pagination[int](self.params.items)
self.assertEqual(pagination.is_paged, self.params.is_paged)
self.assertEqual(pagination.list, self.params.result)
pagination = Pagination[int](self.__params.items)
self.assertEqual(pagination.is_paged, self.__params.is_paged)
self.assertEqual(pagination.list, self.__params.result)
return ""
self.client = httpx.Client(app=self.app, base_url=TEST_SERVER)
self.client.headers["Referer"] = TEST_SERVER
self.__client: httpx.Client = httpx.Client(app=self.__app,
base_url=TEST_SERVER)
"""The user client."""
self.__client.headers["Referer"] = TEST_SERVER
def __test_success(self, query: str, items: range,
result: range, is_paged: bool = True,
@ -246,9 +247,9 @@ class PaginationTestCase(unittest.TestCase):
target: str = "/test-pagination"
if query != "":
target = f"{target}?{query}"
self.params = self.Params(list(items), is_reversed,
list(result), is_paged)
response: httpx.Response = self.client.get(target)
self.__params = self.Params(list(items), is_reversed,
list(result), is_paged)
response: httpx.Response = self.__client.get(target)
self.assertEqual(response.status_code, 200)
def __test_malformed(self, query: str, items: range, redirect_to: str,
@ -262,8 +263,8 @@ class PaginationTestCase(unittest.TestCase):
:return: None.
"""
target: str = "/test-pagination"
self.params = self.Params(list(items), is_reversed, [], True)
response: httpx.Response = self.client.get(f"{target}?{query}")
self.__params = self.Params(list(items), is_reversed, [], True)
response: httpx.Response = self.__client.get(f"{target}?{query}")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{target}?{redirect_to}")

View File

@ -89,12 +89,12 @@ def get_csrf_token(client: httpx.Client) -> str:
return client.get("/.csrf-token").text
def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
def get_client(app: Flask, username: str) -> httpx.Client:
"""Returns a user client.
:param app: The Flask application.
:param username: The username.
:return: A tuple of the client and the CSRF token.
:return: The user client.
"""
client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
@ -107,7 +107,7 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
"username": username})
assert response.status_code == 302
assert response.headers["Location"] == NEXT_URI
return client, csrf_token
return client
def set_locale(app: Flask, client: httpx.Client, csrf_token: str,

View File

@ -25,7 +25,7 @@ from secrets import randbelow
from flask import Flask
from test_site import db
from testlib import NEXT_URI, Accounts
from testlib import Accounts
NON_EMPTY_NOTE: str = " This is \n\na test."
"""The stripped content of an non-empty note."""