24 Commits

Author SHA1 Message Date
a9c4fa9de0 Advanced to version 1.5.6. 2023-05-23 09:32:48 +08:00
3a676e0b5a Fixed the back URL of the creation forms, applying the accounting_or_next filter for the decoded next URI instead of getting the next URI directly. 2023-05-23 09:32:48 +08:00
9cc7b64bb3 Moved the "__as_next" utility from the test site to the "accounting.utils.next_uri" module, and applied it to the template of the unmatched offset list. 2023-05-23 09:32:48 +08:00
352867797d Advanced to version 1.5.5. 2023-05-23 09:30:33 +08:00
09a344d749 Removed excess spaces from the test_change_date test of the JournalEntryReorderTestCase test case. 2023-05-23 09:30:33 +08:00
818c357613 Revised the next URI utilities to apply URLSafeSerializer for encoding and decoding the next URI, in order to prevent tampering with the next URI. 2023-05-23 09:30:19 +08:00
822c8fc49b Renamed the "__get_next_uri" function to "__get_next" in the "accounting.utils.next_uri" module. 2023-05-23 07:10:30 +08:00
3b8a2e3bb1 Replaced the "accounting-dummy-form" name with the dummy CSRF token to work with OWASP ZAP CSRF token scans. 2023-05-22 18:32:24 +08:00
9e4927ee0b Replaced the get_errors_view with the get_messages_view in the create_test_app function in testlib.py. 2023-05-22 00:03:13 +08:00
3b030c577c Added the integrity value of the CDN stylesheet links in the base template of the test site. 2023-05-19 18:17:29 +08:00
60b33f2a3b Revised the link to the stylesheet of tempus dominus in the base template of the test site. 2023-05-19 18:17:20 +08:00
08fdf59844 Revised the indent of the flashed success messages in the base template of the test site. 2023-05-19 18:17:11 +08:00
b397515457 Removed the size restriction in the next URI utilities. Buffer overflow may happen with any parameter, not only the "next" parameter. It should be solved in uWSGI, but not the application. 2023-05-18 23:30:36 +08:00
abe90d3483 Advanced to version 1.5.4. 2023-05-18 00:06:16 +08:00
65e7dcdf6d Replaced the "/next" next URI with the NEXT_URI constant in the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-18 00:06:05 +08:00
74e414badf Removed unnecessary f-strings from the test_reorder test of the JournalEntryReorderTestCase test case. 2023-05-17 23:54:52 +08:00
69175979ff Added the form name to the dummy forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 22:56:47 +08:00
2f69e0f215 Added the form name to the search forms so that they can be excluded by OWASP ZAP scanner for Anti-CSRF tokens. 2023-05-17 21:43:21 +08:00
961385c389 Added SESSION_COOKIE_SAMESITE and SESSION_COOKIE_SECURE to create_app of the test site, to set the SameSite and Secure flags for the session cookie. 2023-05-17 19:57:38 +08:00
a691cfd2da Applied the or_next utility to the set local route of the test site. 2023-05-17 19:57:23 +08:00
482a0faa23 Added safeguard to the next URI utilities from invalid or insecure next URI. 2023-05-17 16:26:35 +08:00
0ecf7b6617 Revised the documentation of the "accounting.utils.cast" module. 2023-05-17 15:33:42 +08:00
4408bbfc82 Updated the JavaScript library versions, and added decimal.js-light to the documentation. 2023-05-06 23:59:06 +08:00
433110f486 Revised the way to query accounts with Flask-SQLAlchemy style queries in the accounts method of the CurrentAccount data model. 2023-05-04 09:35:20 +08:00
34 changed files with 544 additions and 280 deletions

View File

@ -2,6 +2,44 @@ Change Log
==========
Version 1.5.6
-------------
Released 2023/5/23
Bug fixes.
* Fixed the return URI of the creation forms to decode the next URI.
* Fixed the unmatched offset list to use the encoded next URI.
Version 1.5.5
-------------
Released 2023/5/23
Security fixes.
* Revised the next URI utilities to encode and decode the next URI
preventing tampering with the next URI.
* Added the integrity value of the CDN stylesheet links.
* Various fixes.
Version 1.5.4
-------------
Released 2023/5/18
Security fixes.
* Added safeguard to the next URI utilities, to prevent Cross-Site
Scripting (XSS) attacks.
* Applied the safe next URI utilities to the test site.
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
of the test site.
Version 1.5.3
-------------

View File

@ -50,9 +50,9 @@ The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_.
* Bootstrap_ 5.2.3 or above
* FontAwesome_ 6.2.1 or above
* `Decimal.js`_ 6.4.3 or above
* `Tempus-Dominus`_ 6.4.3 or above
* FontAwesome_ 6.4.0 or above
* `decimal.js`_ 10.4.3 or above, or `decimal.js-light`_ 2.5.1 or above.
* `Tempus-Dominus`_ 6.7.7 or above
Configuration
@ -114,6 +114,7 @@ Check your Flask application and see how it works.
.. _CDN: https://en.wikipedia.org/wiki/Content_delivery_network
.. _Bootstrap: https://getbootstrap.com
.. _FontAwesome: https://fontawesome.com
.. _Decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js: https://mikemcl.github.io/decimal.js
.. _decimal.js-light: https://mikemcl.github.io/decimal.js-light
.. _Tempus-Dominus: https://getdatepicker.com
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/

View File

@ -24,7 +24,7 @@ from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import UserUtilityInterface
VERSION: str = "1.5.3"
VERSION: str = "1.5.6"
"""The package version."""
db: SQLAlchemy = SQLAlchemy()
"""The database instance."""

View File

@ -23,6 +23,6 @@ First written: 2023/2/1
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %}
{% block back_url %}{{ url_for("accounting.account.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}

View File

@ -32,7 +32,7 @@ First written: 2023/1/30
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">

View File

@ -26,7 +26,7 @@ First written: 2023/1/26
{% block content %}
<div class="mb-2 accounting-toolbar">
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.base-account.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">

View File

@ -23,6 +23,6 @@ First written: 2023/2/6
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
{% block back_url %}{{ url_for("accounting.currency.list")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}

View File

@ -32,7 +32,7 @@ First written: 2023/2/6
{{ A_("New") }}
</a>
{% endif %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting.currency.list") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28
#}
<form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" data-debit-credit="{{ description_editor.debit_credit }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-description-editor-{{ description_editor.debit_credit }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-description-editor-{{ description_editor.debit_credit }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25
#}
<form id="accounting-line-item-editor">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}

View File

@ -20,6 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22
#}
<form id="accounting-recurring-item-editor-{{ expense_income }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div id="accounting-recurring-item-editor-{{ expense_income }}-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-recurring-item-editor-{{ expense_income }}-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -19,7 +19,7 @@ search-modal.html: The search modal
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8
#}
<form action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-search-modal-label">
<form action="{{ url_for("accounting-report.search") }}" name="accounting-search-form" method="get" role="search" aria-labelledby="accounting-search-modal-label">
<div class="modal fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -118,7 +118,7 @@ First written: 2023/3/8
</button>
{% endif %}
{% if use_search %}
<form class="btn btn-primary d-flex input-group" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<form class="btn btn-primary d-flex input-group" name="accounting-search-form" action="{{ url_for("accounting-report.search") }}" method="get" role="search" aria-labelledby="accounting-toolbar-search-label">
<input id="accounting-toolbar-search" class="form-control form-control-sm" type="search" name="q" value="{{ request.args.q }}" placeholder=" " required="required">
<label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit">

View File

@ -49,7 +49,7 @@ First written: 2023/4/17
<form action="{{ url_for("accounting-report.match-offsets", currency=report.currency, account=report.account) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
<input type="hidden" name="next" value="{{ accounting_as_next() }}">
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -14,8 +14,7 @@
# 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 utility to cast a SQLAlchemy column into the column type, to avoid
warnings from the IDE.
"""The utilities to cast values into desired types, to avoid IDE warnings.
This module should not import any other module from the application.

View File

@ -21,7 +21,6 @@ from typing import Self
import sqlalchemy as sa
from accounting import db
from accounting.locale import gettext
from accounting.models import Account
@ -75,7 +74,7 @@ class CurrentAccount:
"""
accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x)
for x in db.session.query(Account)
for x in Account.query
.filter(cls.sql_condition())
.order_by(Account.base_code, Account.no)])
return accounts

View File

@ -22,7 +22,17 @@ This module should not import any other module from the application.
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
urlunparse
from flask import request, Blueprint
from flask import request, Blueprint, current_app
from itsdangerous import URLSafeSerializer, BadData
def __as_next() -> str:
"""Encodes the current request URI as value for the next URI.
:return: The current request URI as value for the next URI.
"""
return encode_next(
request.full_path if request.query_string else request.path)
def append_next(uri: str) -> str:
@ -41,11 +51,8 @@ def inherit_next(uri: str) -> str:
:param uri: The URI.
:return: The URI with the current next URI added at the query argument.
"""
next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.get("next")
if next_uri is None:
return uri
return __set_next(uri, next_uri)
next_uri: str | None = __get_next()
return uri if next_uri is None else __set_next(uri, next_uri)
def or_next(uri: str) -> str:
@ -54,9 +61,24 @@ def or_next(uri: str) -> str:
:param uri: The URI.
:return: The next URI or the supplied URI.
"""
next_uri: str | None = __get_next()
return uri if next_uri is None else next_uri
def __get_next() -> str | None:
"""Returns the valid next URI.
:return: The valid next URI.
"""
next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.get("next")
return uri if next_uri is None else next_uri
if next_uri is None:
return None
try:
return URLSafeSerializer(current_app.config["SECRET_KEY"])\
.loads(next_uri, "next")
except BadData:
return None
def __set_next(uri: str, next_uri: str) -> str:
@ -69,18 +91,29 @@ def __set_next(uri: str, next_uri: str) -> str:
uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "next"]
params.append(("next", next_uri))
params.append(("next", encode_next(next_uri)))
parts: list[str] = list(uri_p)
parts[4] = urlencode(params)
return urlunparse(parts)
def encode_next(uri: str) -> str:
"""Encodes the next URI.
:param uri: The next URI.
:return: The encoded next URI.
"""
return URLSafeSerializer(current_app.config["SECRET_KEY"])\
.dumps(uri, "next")
def init_app(bp: Blueprint) -> None:
"""Initializes the application.
:param bp: The blueprint of the accounting application.
:return: None.
"""
bp.add_app_template_global(__as_next, "accounting_as_next")
bp.add_app_template_filter(append_next, "accounting_append_next")
bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
bp.add_app_template_filter(or_next, "accounting_or_next")

View File

@ -23,6 +23,7 @@ import unittest
import httpx
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
@ -78,6 +79,7 @@ class AccountTestCase(unittest.TestCase):
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
response: httpx.Response
@ -143,7 +145,7 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token,
"next": NEXT_URI,
"next": self.encoded_next_uri,
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403)
@ -192,7 +194,7 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token,
"next": NEXT_URI,
"next": self.encoded_next_uri,
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403)
@ -244,7 +246,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"next": self.encoded_next_uri,
f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -526,7 +528,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual(account.l10n, [])
set_locale(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,
@ -541,7 +543,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(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,
@ -556,7 +558,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")})
set_locale(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,
@ -591,7 +593,7 @@ class AccountTestCase(unittest.TestCase):
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"next": self.encoded_next_uri,
"date": dt.date.today().isoformat(),
"currency-1-code": "USD",
"currency-1-credit-1-account_code": BANK.code,
@ -709,7 +711,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"next": self.encoded_next_uri,
f"{id_1}-no": "4",
f"{id_2}-no": "1",
f"{id_3}-no": "5",
@ -736,7 +738,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"next": self.encoded_next_uri,
f"{id_2}-no": "3a",
f"{id_3}-no": "5",
f"{id_4}-no": "2"})

View File

@ -23,6 +23,7 @@ import unittest
import httpx
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
@ -468,7 +469,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual(currency.l10n, [])
set_locale(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,
@ -483,7 +484,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(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,
@ -498,7 +499,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(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,
@ -521,6 +522,8 @@ 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():
encoded_next_uri: str = encode_next(NEXT_URI)
list_uri: str = PREFIX
response: httpx.Response
@ -533,7 +536,7 @@ class CurrencyTestCase(unittest.TestCase):
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": dt.date.today().isoformat(),
"currency-1-code": EUR.code,
"currency-1-credit-1-account_code": "1111-001",

View File

@ -22,6 +22,7 @@ import unittest
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
@ -41,6 +42,7 @@ class DescriptionEditorTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
@ -51,7 +53,7 @@ class DescriptionEditorTestCase(unittest.TestCase):
"""
from accounting.journal_entry.utils.description_editor import \
DescriptionEditor
for form in get_form_data(self.csrf_token):
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()
@ -143,22 +145,24 @@ class DescriptionEditorTestCase(unittest.TestCase):
Accounts.PREPAID)
def get_form_data(csrf_token: str) -> list[dict[str, str]]:
def get_form_data(csrf_token: str, encoded_next_uri: str) \
-> list[dict[str, str]]:
"""Returns the form data for multiple journal entry forms.
:param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:return: A list of the form data.
"""
journal_entry_date: str = dt.date.today().isoformat()
return [{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-description": " Salary ",
"currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
@ -180,7 +184,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-2-description": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
@ -196,7 +200,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-1-description": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL,
@ -212,14 +216,14 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-1-description": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-description": " Airplane—Lake City↔Hill Town",
"currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
@ -247,7 +251,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-3-description": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL,
@ -293,7 +297,7 @@ def get_form_data(csrf_token: str) -> list[dict[str, str]]:
"currency-0-credit-6-description": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry_date,
"currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PETTY_CASH,

View File

@ -24,6 +24,7 @@ from decimal import Decimal
import httpx
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, \
add_journal_entry, match_journal_entry_detail
@ -53,6 +54,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
@ -153,7 +155,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
data=update_form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_id}?next=%2F_next")
f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
response = self.client.post(f"{PREFIX}/{journal_entry_id}/delete",
data={"csrf_token": self.csrf_token})
@ -166,7 +169,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import JournalEntry, JournalEntryCurrency
create_uri: str = f"{PREFIX}/create/receipt?next=%2F_next"
create_uri: str = (f"{PREFIX}/create/receipt?"
f"next={self.encoded_next_uri}")
store_uri: str = f"{PREFIX}/store/receipt"
response: httpx.Response
form: dict[str, str]
@ -322,8 +326,10 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{journal_entry_id}/edit?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
edit_uri: str = (f"{PREFIX}/{journal_entry_id}/edit?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id)
@ -485,7 +491,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry
response: httpx.Response
@ -524,7 +531,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
= add_journal_entry(self.client, self.__get_add_form())
editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry
response: httpx.Response
@ -557,7 +565,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem
journal_entry_id_1: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id_1}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id_1}?"
f"next={self.encoded_next_uri}")
delete_uri: str = f"{PREFIX}/{journal_entry_id_1}/delete"
response: httpx.Response
@ -575,7 +584,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
add_journal_entry(
self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"next": self.encoded_next_uri,
"date": dt.date.today().isoformat(),
"currency-1-code": line_item.currency_code,
"currency-1-debit-1-original_line_item_id": line_item.id,
@ -585,17 +594,18 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
# Cannot delete the journal entry that is in use
response = self.client.post(f"{PREFIX}/{journal_entry_id_2}/delete",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_id_2}?next=%2F_next")
f"{PREFIX}/{journal_entry_id_2}?"
f"next={self.encoded_next_uri}")
# Success
response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -603,7 +613,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 404)
def __get_add_form(self) -> dict[str, str]:
@ -611,7 +621,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
:return: The form data to add a new journal entry.
"""
form: dict[str, str] = get_add_form(self.csrf_token)
form: dict[str, str] = get_add_form(self.csrf_token,
self.encoded_next_uri)
form = {x: form[x] for x in form if "-debit-" not in x}
return form
@ -625,7 +636,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
not changed.
"""
form: dict[str, str] = get_unchanged_update_form(
journal_entry_id, self.app, self.csrf_token)
journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri)
form = {x: form[x] for x in form if "-debit-" not in x}
return form
@ -638,7 +649,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
changed.
"""
form: dict[str, str] = get_update_form(
journal_entry_id, self.app, self.csrf_token, False)
journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri,
False)
form = {x: form[x] for x in form if "-debit-" not in x}
return form
@ -658,6 +670,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
@ -758,7 +771,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
data=update_form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_id}?next=%2F_next")
f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
response = self.client.post(f"{PREFIX}/{journal_entry_id}/delete",
data={"csrf_token": self.csrf_token})
@ -771,7 +785,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import JournalEntry, JournalEntryCurrency
create_uri: str = f"{PREFIX}/create/disbursement?next=%2F_next"
create_uri: str = (f"{PREFIX}/create/disbursement?"
f"next={self.encoded_next_uri}")
store_uri: str = f"{PREFIX}/store/disbursement"
response: httpx.Response
form: dict[str, str]
@ -930,8 +945,10 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{journal_entry_id}/edit?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
edit_uri: str = (f"{PREFIX}/{journal_entry_id}/edit?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id)
@ -1097,7 +1114,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry
response: httpx.Response
@ -1136,7 +1154,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
= add_journal_entry(self.client, self.__get_add_form())
editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry
response: httpx.Response
@ -1168,7 +1187,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
"""
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
delete_uri: str = f"{PREFIX}/{journal_entry_id}/delete"
response: httpx.Response
@ -1176,7 +1196,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -1184,7 +1204,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 404)
def __get_add_form(self) -> dict[str, str]:
@ -1192,7 +1212,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
:return: The form data to add a new journal entry.
"""
form: dict[str, str] = get_add_form(self.csrf_token)
form: dict[str, str] = get_add_form(self.csrf_token,
self.encoded_next_uri)
form = {x: form[x] for x in form if "-credit-" not in x}
return form
@ -1206,7 +1227,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
not changed.
"""
form: dict[str, str] = get_unchanged_update_form(
journal_entry_id, self.app, self.csrf_token)
journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri)
form = {x: form[x] for x in form if "-credit-" not in x}
return form
@ -1219,7 +1240,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
changed.
"""
form: dict[str, str] = get_update_form(
journal_entry_id, self.app, self.csrf_token, True)
journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri,
True)
form = {x: form[x] for x in form if "-credit-" not in x}
return form
@ -1240,6 +1262,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
@ -1340,7 +1363,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
data=update_form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_id}?next=%2F_next")
f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
response = self.client.post(f"{PREFIX}/{journal_entry_id}/delete",
data={"csrf_token": self.csrf_token})
@ -1353,7 +1377,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import JournalEntry, JournalEntryCurrency
create_uri: str = f"{PREFIX}/create/transfer?next=%2F_next"
create_uri: str = (f"{PREFIX}/create/transfer?"
f"next={self.encoded_next_uri}")
store_uri: str = f"{PREFIX}/store/transfer"
response: httpx.Response
form: dict[str, str]
@ -1548,8 +1573,10 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
edit_uri: str = f"{PREFIX}/{journal_entry_id}/edit?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
edit_uri: str = (f"{PREFIX}/{journal_entry_id}/edit?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id)
@ -1758,7 +1785,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry
response: httpx.Response
@ -1797,7 +1825,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
= add_journal_entry(self.client, self.__get_add_form())
editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry
response: httpx.Response
@ -1831,7 +1860,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update?as=receipt"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id)
form_0 = {x: form_0[x] for x in form_0 if "-debit-" not in x}
@ -1932,7 +1962,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update?as=disbursement"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id)
form_0 = {x: form_0[x] for x in form_0 if "-credit-" not in x}
@ -2035,7 +2066,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
"""
journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next"
detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
delete_uri: str = f"{PREFIX}/{journal_entry_id}/delete"
response: httpx.Response
@ -2043,7 +2075,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -2051,7 +2083,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 404)
def __get_add_form(self) -> dict[str, str]:
@ -2059,7 +2091,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
:return: The form data to add a new journal entry.
"""
return get_add_form(self.csrf_token)
return get_add_form(self.csrf_token, self.encoded_next_uri)
def __get_unchanged_update_form(self, journal_entry_id: int) \
-> dict[str, str]:
@ -2071,7 +2103,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
not changed.
"""
return get_unchanged_update_form(
journal_entry_id, self.app, self.csrf_token)
journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri)
def __get_update_form(self, journal_entry_id: int) -> dict[str, str]:
"""Returns the form data to update a journal entry, where the data are
@ -2081,8 +2113,9 @@ class TransferJournalEntryTestCase(unittest.TestCase):
:return: The form data to update the journal entry, where the data are
changed.
"""
return get_update_form(journal_entry_id,
self.app, self.csrf_token, None)
return get_update_form(
journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri,
None)
class JournalEntryReorderTestCase(unittest.TestCase):
@ -2100,6 +2133,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
@ -2147,7 +2181,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/{id_2}/update", data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{id_2}?next=%2F_next")
f"{PREFIX}/{id_2}?next={self.encoded_next_uri}")
with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 1)
@ -2181,14 +2215,14 @@ class JournalEntryReorderTestCase(unittest.TestCase):
response = self.client.post(
f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token,
"next": "/next",
"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"], f"/next")
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 4)
@ -2209,12 +2243,12 @@ class JournalEntryReorderTestCase(unittest.TestCase):
response = self.client.post(
f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token,
"next": "/next",
"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"], f"/next")
self.assertEqual(response.headers["Location"], NEXT_URI)
with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 3)
@ -2228,7 +2262,8 @@ class JournalEntryReorderTestCase(unittest.TestCase):
:return: The form data to add a new cash receipt journal entry.
"""
form: dict[str, str] = get_add_form(self.csrf_token)
form: dict[str, str] = get_add_form(self.csrf_token,
self.encoded_next_uri)
form = {x: form[x] for x in form if "-debit-" not in x}
return form
@ -2237,7 +2272,8 @@ class JournalEntryReorderTestCase(unittest.TestCase):
:return: The form data to add a new cash disbursement journal entry.
"""
form: dict[str, str] = get_add_form(self.csrf_token)
form: dict[str, str] = get_add_form(self.csrf_token,
self.encoded_next_uri)
form = {x: form[x] for x in form if "-credit-" not in x}
return form
@ -2251,7 +2287,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
where the data are not changed.
"""
form: dict[str, str] = get_unchanged_update_form(
journal_entry_id, self.app, self.csrf_token)
journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri)
form = {x: form[x] for x in form if "-credit-" not in x}
return form
@ -2260,4 +2296,4 @@ class JournalEntryReorderTestCase(unittest.TestCase):
:return: The form data to add a new journal entry.
"""
return get_add_form(self.csrf_token)
return get_add_form(self.csrf_token, self.encoded_next_uri)

View File

@ -25,6 +25,7 @@ from decimal import Decimal
import httpx
from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db
from test_site.lib import JournalEntryLineItemData, JournalEntryCurrencyData, \
JournalEntryData, BaseTestData
@ -50,6 +51,7 @@ class OffsetTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
self.data: OffsetTestData = OffsetTestData(self.app, "editor")
@ -61,7 +63,8 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account, JournalEntry
create_uri: str = f"{PREFIX}/create/receipt?next=%2F_next"
create_uri: str = (f"{PREFIX}/create/receipt?"
f"next={self.encoded_next_uri}")
store_uri: str = f"{PREFIX}/store/receipt"
form: dict[str, str]
old_amount: Decimal
@ -85,14 +88,16 @@ class OffsetTestCase(unittest.TestCase):
original_line_item=self.data.l_r_or3d)])])
# Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -108,7 +113,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
response = self.client.post(
store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI))
data=journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
@ -117,7 +123,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
@ -126,21 +133,24 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount
+ Decimal("0.01"))
@ -149,7 +159,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-3-amount"] \
= str(journal_entry_data.currencies[0].credit[2].amount
+ Decimal("0.01"))
@ -160,14 +171,16 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
journal_entry_id: int \
@ -184,7 +197,8 @@ class OffsetTestCase(unittest.TestCase):
"""
from accounting.models import Account
journal_entry_data: JournalEntryData = self.data.j_r_of2
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
@ -196,14 +210,16 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -220,7 +236,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
response = self.client.post(
update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI))
data=journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
@ -229,7 +246,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account
@ -238,21 +256,24 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01"))
@ -264,7 +285,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01"))
@ -278,18 +300,21 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_data.id}?next=%2F_next")
f"{PREFIX}/{journal_entry_data.id}?"
f"next={self.encoded_next_uri}")
def test_edit_receivable_original_line_item(self) -> None:
"""Tests to edit the receivable original line item.
@ -298,7 +323,8 @@ class OffsetTestCase(unittest.TestCase):
"""
from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.j_r_or1
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
@ -310,21 +336,24 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4")
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01"))
@ -336,7 +365,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01"))
@ -350,25 +380,29 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
del form["currency-1-debit-1-id"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_data.id}?next=%2F_next")
f"{PREFIX}/{journal_entry_data.id}?"
f"next={self.encoded_next_uri}")
# The original line item is always before the offset item, even when
# they happen in the same day.
@ -388,7 +422,8 @@ class OffsetTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Account, JournalEntry
create_uri: str = f"{PREFIX}/create/disbursement?next=%2F_next"
create_uri: str = (f"{PREFIX}/create/disbursement?"
f"next={self.encoded_next_uri}")
store_uri: str = f"{PREFIX}/store/disbursement"
form: dict[str, str]
response: httpx.Response
@ -411,14 +446,16 @@ class OffsetTestCase(unittest.TestCase):
[])])
# Non-existing original line item ID
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -434,7 +471,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
response = self.client.post(
store_uri,
data=journal_entry_data.new_form(self.csrf_token, NEXT_URI))
data=journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context():
@ -443,7 +481,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
@ -452,21 +491,24 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not the same account
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01"))
@ -475,7 +517,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01"))
@ -486,14 +529,16 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.new_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.new_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302)
journal_entry_id: int \
@ -510,7 +555,8 @@ class OffsetTestCase(unittest.TestCase):
"""
from accounting.models import Account, JournalEntry
journal_entry_data: JournalEntryData = self.data.j_p_of2
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
@ -522,14 +568,16 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original line item ID
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -546,7 +594,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
response = self.client.post(
update_uri,
data=journal_entry_data.update_form(self.csrf_token, NEXT_URI))
data=journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context():
@ -555,7 +604,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit()
# The original line item is also an offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account
@ -564,21 +614,24 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01"))
@ -590,7 +643,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01"))
@ -604,14 +658,16 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
journal_entry_id: int \
@ -628,7 +684,8 @@ class OffsetTestCase(unittest.TestCase):
"""
from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.j_p_or1
edit_uri: str = f"{PREFIX}/{journal_entry_data.id}/edit?next=%2F_next"
edit_uri: str = (f"{PREFIX}/{journal_entry_data.id}/edit?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str]
response: httpx.Response
@ -640,21 +697,24 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9")
# Not the same currency
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-code"] = "EUR"
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-credit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01"))
@ -666,7 +726,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01"))
@ -680,25 +741,29 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items
old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days
# Not deleting matched original line items
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
del form["currency-1-credit-1-id"]
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri)
# Success
form = journal_entry_data.update_form(self.csrf_token, NEXT_URI)
form = journal_entry_data.update_form(self.csrf_token,
self.encoded_next_uri)
response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
f"{PREFIX}/{journal_entry_data.id}?next=%2F_next")
f"{PREFIX}/{journal_entry_data.id}?"
f"next={self.encoded_next_uri}")
# The original line item is always before the offset item, even when
# they happen in the same day

View File

@ -23,17 +23,12 @@ import unittest
import httpx
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
PREFIX: str = "/accounting/options"
"""The URL prefix for the option management."""
DETAIL_URI: str = f"{PREFIX}?next=%2F_next"
"""THE URI for the option detail."""
EDIT_URI: str = f"{PREFIX}/edit?next=%2F_next"
"""THE URI for the form to edit the options."""
UPDATE_URI: str = f"{PREFIX}/update"
"""THE URI to update the options."""
class OptionTestCase(unittest.TestCase):
@ -50,6 +45,7 @@ class OptionTestCase(unittest.TestCase):
with self.app.app_context():
from accounting.models import Option
Option.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "admin")
@ -59,15 +55,18 @@ class OptionTestCase(unittest.TestCase):
: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}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response
response = client.get(DETAIL_URI)
response = client.get(detail_uri)
self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI)
response = client.get(edit_uri)
self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
response = client.post(update_uri, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
@ -76,15 +75,18 @@ 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}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response
response = client.get(DETAIL_URI)
response = client.get(detail_uri)
self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI)
response = client.get(edit_uri)
self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
response = client.post(update_uri, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
@ -93,15 +95,18 @@ 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}"
update_uri: str = f"{PREFIX}/update"
response: httpx.Response
response = client.get(DETAIL_URI)
response = client.get(detail_uri)
self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI)
response = client.get(edit_uri)
self.assertEqual(response.status_code, 403)
response = client.post(UPDATE_URI, data=self.__get_form(csrf_token))
response = client.post(update_uri, data=self.__get_form(csrf_token))
self.assertEqual(response.status_code, 403)
def test_admin(self) -> None:
@ -109,17 +114,20 @@ 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}"
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)
self.assertEqual(response.headers["Location"], detail_uri)
def test_set(self) -> None:
"""Test to set the options.
@ -127,59 +135,62 @@ 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}"
update_uri: str = f"{PREFIX}/update"
form: dict[str, str]
response: httpx.Response
# 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)
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)
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)
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)
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)
self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item name empty
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)
self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item account empty
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)
self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item non-expense account
form = self.__get_form()
@ -187,9 +198,9 @@ 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)
self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item non-income account
form = self.__get_form()
@ -197,9 +208,9 @@ 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)
self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item payable expense
form = self.__get_form()
@ -207,9 +218,9 @@ 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)
self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item receivable income
form = self.__get_form()
@ -217,17 +228,17 @@ 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)
self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item description template empty
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)
self.assertEqual(response.headers["Location"], edit_uri)
# Success, with malformed order
with self.app.app_context():
@ -236,9 +247,9 @@ class OptionTestCase(unittest.TestCase):
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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
self.assertEqual(options.default_currency_code, "EUR")
@ -261,9 +272,9 @@ 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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
self.assertEqual(len(options.recurring.expenses), 0)
@ -275,13 +286,15 @@ class OptionTestCase(unittest.TestCase):
:return: None.
"""
from accounting.models import Option
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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
option = db.session.get(Option, "recurring")
@ -295,9 +308,9 @@ 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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
option = db.session.get(Option, "recurring")
@ -311,9 +324,9 @@ 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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
option = db.session.get(Option, "recurring")
@ -328,12 +341,14 @@ 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}"
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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
editor_pk: int = get_user_pk(editor_username)
@ -348,9 +363,9 @@ 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)
self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context():
option = db.session.get(Option, "recurring")
@ -367,7 +382,7 @@ class OptionTestCase(unittest.TestCase):
if csrf_token is None:
csrf_token = self.csrf_token
return {"csrf_token": csrf_token,
"next": 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

@ -23,13 +23,15 @@ from typing import Type
from click.testing import Result
from flask import Flask, Blueprint, render_template, redirect, Response, \
url_for
url_for, request
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()
@ -52,6 +54,8 @@ def create_app(is_testing: bool = False) -> Flask:
db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
app.config.from_mapping({
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": True,
"SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",

View File

@ -140,36 +140,38 @@ class JournalEntryData:
for line_item in currency.credit:
line_item.journal_entry = self
def new_form(self, csrf_token: str, next_uri: str) -> dict[str, str]:
def new_form(self, csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the journal entry as a creation form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:param encoded_next_uri: The encoded next URI.
:return: The journal entry as a creation form.
"""
return self.__form(csrf_token, next_uri, is_update=False)
return self.__form(csrf_token, encoded_next_uri, is_update=False)
def update_form(self, csrf_token: str, next_uri: str) -> dict[str, str]:
def update_form(self, csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the journal entry as an update form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:param encoded_next_uri: The encoded next URI.
:return: The journal entry as an update form.
"""
return self.__form(csrf_token, next_uri, is_update=True)
return self.__form(csrf_token, encoded_next_uri, is_update=True)
def __form(self, csrf_token: str, next_uri: str, is_update: bool = False) \
-> dict[str, str]:
def __form(self, csrf_token: str, encoded_next_uri: str,
is_update: bool = False) -> dict[str, str]:
"""Returns the journal entry as a form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:param encoded_next_uri: The encoded next URI.
:param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form.
"""
date: dt.date = dt.date.today() - dt.timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": next_uri,
"next": encoded_next_uri,
"date": date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))

View File

@ -19,10 +19,12 @@
"""
from babel import Locale
from flask import request, session, current_app, Blueprint, Response, \
redirect, url_for, Flask
redirect, Flask
from flask_babel import Babel
from werkzeug.datastructures import LanguageAccept
from accounting.utils.next_uri import or_next
bp: Blueprint = Blueprint("locale", __name__, url_prefix="/")
@ -68,9 +70,7 @@ def set_locale() -> Response:
all_linguas: dict[str, str] = get_all_linguas()
if "locale" in request.form and request.form["locale"] in all_linguas:
session["locale"] = request.form["locale"]
if "next" in request.form:
return redirect(request.form["next"])
return redirect(url_for("home.home"))
return redirect(or_next("/"))
def get_all_linguas() -> dict[str, str]:

View File

@ -25,14 +25,14 @@ First written: 2023/1/27
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="{{ "imacat" }}" />
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.2.1/css/all.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css" integrity="sha384-iw3OoTErCYJJB9mCa8LNS2hbsQ7M3C0EpIsO/H5+EGAkPGc6rk+V8i04oW/K5xq0" crossorigin="anonymous">
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/css/tempus-dominus.min.css" integrity="sha384-l66rSL7gUubrdJxFRbXUo/tO7eNPAcCiZXFs/Xl147146xNqQ1qt4oPW6jlVezsS" crossorigin="anonymous">
{% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/decimal.js-light@2.5.1/decimal.min.js" integrity="sha384-QdsxGEq4Y0erX8WUIsZJDtfoSSyBF6dmNCnzRNYCa2AOM/xzNsyhHu0RbdFBAm+l" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.4.3/dist/js/tempus-dominus.min.js" integrity="sha384-2MkID2vkc9sxBCqs2us3mB8fV+c0o7uPtOvAPjaC8gKv9Bk21UHT0r2Q7Kv70+zO" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/js/tempus-dominus.min.js" integrity="sha384-MxHp+/TqTjbku1jSTIe1e/4l6CZTLhACLDbWyxYaFRgD3AM4oh99AY8bxsGhIoRc" crossorigin="anonymous"></script>
{% block scripts %}{% endblock %}
<link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}">
<title>{% block title %}{% endblock %}</title>
@ -96,7 +96,7 @@ First written: 2023/1/27
</span>
<form action="{{ url_for("locale.set-locale") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
<input type="hidden" name="next" value="{{ accounting_as_next() }}">
<ul class="dropdown-menu dropdown-menu-end">
{% for locale_code, locale_name in get_all_linguas().items() %}
<li>

View File

@ -22,6 +22,7 @@ import unittest
import httpx
from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db
from test_site.lib import JournalEntryCurrencyData, JournalEntryData, \
BaseTestData
@ -46,6 +47,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete()
JournalEntryLineItem.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor")
@ -60,7 +62,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None:
@ -74,7 +76,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 403)
def test_editor(self) -> None:
@ -87,7 +89,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -100,7 +102,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/{Accounts.PAYABLE}",
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -150,7 +152,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -200,7 +202,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -278,7 +280,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri = f"{PREFIX}/{Accounts.RECEIVABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)
@ -344,7 +346,7 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
match_uri = f"{PREFIX}/{Accounts.PAYABLE}"
response = self.client.post(match_uri,
data={"csrf_token": self.csrf_token,
"next": NEXT_URI})
"next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI)

View File

@ -22,11 +22,12 @@ 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.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
from testlib import TEST_SERVER, create_test_app, get_csrf_token, NEXT_URI
class NextUriTestCase(unittest.TestCase):
@ -40,6 +41,8 @@ class NextUriTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.serializer: URLSafeSerializer \
= URLSafeSerializer(self.app.config["SECRET_KEY"])
def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI.
@ -51,12 +54,12 @@ class NextUriTestCase(unittest.TestCase):
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(current_uri)}")
f"{self.TARGET}?next={self.__encode(current_uri)}")
next_uri: str = request.form["next"] if request.method == "POST" \
else request.args["next"]
self.assertEqual(inherit_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(next_uri)}")
self.assertEqual(or_next(self.TARGET), next_uri)
f"{self.TARGET}?next={next_uri}")
self.assertEqual(or_next(self.TARGET), self.__decode(next_uri))
return ""
self.app.add_url_rule("/test-next", view_func=test_next_uri_view,
@ -66,10 +69,11 @@ class NextUriTestCase(unittest.TestCase):
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get("/test-next?next=/next&q=abc&page-no=4")
encoded_uri: str = self.__encode(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,
"next": "/next",
"next": encoded_uri,
"name": "viewer"})
self.assertEqual(response.status_code, 200)
@ -80,10 +84,6 @@ class NextUriTestCase(unittest.TestCase):
"""
def test_no_next_uri_view() -> str:
"""The test view without the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(current_uri)}")
self.assertEqual(inherit_next(self.TARGET), self.TARGET)
self.assertEqual(or_next(self.TARGET), self.TARGET)
return ""
@ -101,6 +101,53 @@ class NextUriTestCase(unittest.TestCase):
"name": "viewer"})
self.assertEqual(response.status_code, 200)
def test_invalid(self) -> None:
"""Tests the next URI utilities without an invalid next URI.
:return: None.
"""
def test_invalid_next_uri_view() -> str:
"""The test view without the next URI."""
self.assertEqual(inherit_next(self.TARGET), self.TARGET)
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)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
next_uri: str
expected1: str
expected2: str
response: httpx.Response
# A foreign URI
next_uri = "https://example.com"
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}")
self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next",
data={"csrf_token": csrf_token,
"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."""

View File

@ -25,6 +25,7 @@ from typing import Literal
import httpx
from flask import Flask, render_template_string
from accounting.utils.next_uri import encode_next
from test_site import create_app
TEST_SERVER: str = "https://testserver"
@ -71,9 +72,9 @@ def create_test_app() -> Flask:
"""The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}")
@app.get("/.errors")
def get_errors_view() -> str:
"""The test view to return the CSRF token."""
@app.get("/.messages")
def get_messages_view() -> str:
"""The test view to return the flashed messages."""
return render_template_string("{{get_flashed_messages()|tojson}}")
return app
@ -98,30 +99,35 @@ def get_client(app: Flask, username: str) -> tuple[httpx.Client, str]:
client: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
with app.app_context():
encoded_next_uri: str = encode_next(NEXT_URI)
response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token,
"next": "/",
"next": encoded_next_uri,
"username": username})
assert response.status_code == 302
assert response.headers["Location"] == "/"
assert response.headers["Location"] == NEXT_URI
return client, csrf_token
def set_locale(client: httpx.Client, csrf_token: str,
def set_locale(app: Flask, client: httpx.Client, csrf_token: str,
locale: Literal["en", "zh_Hant", "zh_Hans"]) -> None:
"""Sets the current locale.
:param app: The Flask application.
:param client: The test client.
:param csrf_token: The CSRF token.
:param locale: The locale.
:return: None.
"""
with app.app_context():
encoded_next_uri: str = encode_next(NEXT_URI)
response: httpx.Response = client.post("/locale",
data={"csrf_token": csrf_token,
"locale": locale,
"next": "/next"})
"next": encoded_next_uri})
assert response.status_code == 302
assert response.headers["Location"] == "/next"
assert response.headers["Location"] == NEXT_URI
def add_journal_entry(client: httpx.Client, form: dict[str, str]) -> int:
@ -152,6 +158,6 @@ def match_journal_entry_detail(location: str) -> int:
:raise AssertionError: When the location is not the journal entry detail.
"""
m: re.Match = re.match(
r"^/accounting/journal-entries/(\d+)\?next=%2F_next", location)
r"^/accounting/journal-entries/(\d+)\?next=", location)
assert m is not None
return int(m.group(1))

View File

@ -33,14 +33,15 @@ EMPTY_NOTE: str = " \n\n "
"""The empty note content."""
def get_add_form(csrf_token: str) -> dict[str, str]:
def get_add_form(csrf_token: str, encoded_next_uri: str) -> dict[str, str]:
"""Returns the form data to add a new journal entry.
:param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:return: The form data to add a new journal entry.
"""
return {"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": dt.date.today().isoformat(),
"currency-0-code": "USD",
"currency-0-debit-0-no": "16",
@ -102,13 +103,15 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
def get_unchanged_update_form(journal_entry_id: int, app: Flask,
csrf_token: str) -> dict[str, str]:
csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the form data to update a journal entry, where the data are not
changed.
:param journal_entry_id: The journal entry ID.
:param app: The Flask application.
:param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:return: The form data to update the journal entry, where the data are not
changed.
"""
@ -121,7 +124,7 @@ def get_unchanged_update_form(journal_entry_id: int, app: Flask,
form: dict[str, str] \
= {"csrf_token": csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": journal_entry.date,
"note": " \n \n\n " if journal_entry.note is None
else f"\n \n\n \n \n{journal_entry.note} \n\n "}
@ -182,20 +185,22 @@ def __get_new_index(indices_used: set[int]) -> int:
def get_update_form(journal_entry_id: int, app: Flask,
csrf_token: str, is_debit: bool | None) -> dict[str, str]:
csrf_token: str, encoded_next_uri: str,
is_debit: bool | None) -> dict[str, str]:
"""Returns the form data to update a journal entry, where the data are
changed.
:param journal_entry_id: The journal entry ID.
:param app: The Flask application.
:param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:param is_debit: True for a cash disbursement journal entry, False for a
cash receipt journal entry, or None for a transfer journal entry.
:return: The form data to update the journal entry, where the data are
changed.
"""
form: dict[str, str] = get_unchanged_update_form(
journal_entry_id, app, csrf_token)
journal_entry_id, app, csrf_token, encoded_next_uri)
# Mess up the line items in a currency
currency_prefix: str = __get_currency_prefix(form, "USD")