8 Commits

16 changed files with 94 additions and 29 deletions

View File

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

View File

@ -32,7 +32,7 @@ First written: 2023/1/30
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% 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"> <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"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -26,7 +26,7 @@ First written: 2023/1/26
{% block content %} {% block content %}
<div class="mb-2 accounting-toolbar"> <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"> <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"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -32,7 +32,7 @@ First written: 2023/2/6
{{ A_("New") }} {{ A_("New") }}
</a> </a>
{% endif %} {% 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"> <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"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -19,7 +19,7 @@ description-editor-modal.html: The modal of the description editor
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/28 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 }}"> <form id="accounting-description-editor-{{ description_editor.debit_credit }}" class="accounting-description-editor" name="accounting-dummy-form" data-debit-credit="{{ description_editor.debit_credit }}">
<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 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-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -19,7 +19,7 @@ journal-entry-line-item-editor-modal: The modal of the journal entry line item e
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/2/25 First written: 2023/2/25
#} #}
<form id="accounting-line-item-editor"> <form id="accounting-line-item-editor" name="accounting-dummy-form">
<div id="accounting-line-item-editor-modal" class="modal fade" tabindex="-1" aria-labelledby="accounting-line-item-editor-modal-label" aria-hidden="true"> <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-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -19,7 +19,7 @@ recurring-item-editor-modal.html: The modal of the recurring item editor
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/22 First written: 2023/3/22
#} #}
<form id="accounting-recurring-item-editor-{{ expense_income }}"> <form id="accounting-recurring-item-editor-{{ expense_income }}" name="accounting-dummy-form">
<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 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-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -19,7 +19,7 @@ search-modal.html: The search modal
Author: imacat@mail.imacat.idv.tw (imacat) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2023/3/8 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 fade" id="accounting-search-modal" tabindex="-1" aria-labelledby="accounting-search-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -118,7 +118,7 @@ First written: 2023/3/8
</button> </button>
{% endif %} {% endif %}
{% if use_search %} {% 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"> <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"> <label id="accounting-toolbar-search-label" for="accounting-toolbar-search" class="input-group-text">
<button type="submit"> <button type="submit">

View File

@ -14,8 +14,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
"""The utility to cast a SQLAlchemy column into the column type, to avoid """The utilities to cast values into desired types, to avoid IDE warnings.
warnings from the IDE.
This module should not import any other module from the application. 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 import sqlalchemy as sa
from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
from accounting.models import Account from accounting.models import Account
@ -75,7 +74,7 @@ class CurrentAccount:
""" """
accounts: list[cls] = [cls.current_assets_and_liabilities()] accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x) accounts.extend([CurrentAccount(x)
for x in db.session.query(Account) for x in Account.query
.filter(cls.sql_condition()) .filter(cls.sql_condition())
.order_by(Account.base_code, Account.no)]) .order_by(Account.base_code, Account.no)])
return accounts return accounts

View File

@ -41,11 +41,8 @@ def inherit_next(uri: str) -> str:
:param uri: The URI. :param uri: The URI.
:return: The URI with the current next URI added at the query argument. :return: The URI with the current next URI added at the query argument.
""" """
next_uri: str | None = request.form.get("next") \ next_uri: str | None = __get_next_uri()
if request.method == "POST" else request.args.get("next") return uri if next_uri is None else __set_next(uri, next_uri)
if next_uri is None:
return uri
return __set_next(uri, next_uri)
def or_next(uri: str) -> str: def or_next(uri: str) -> str:
@ -54,9 +51,22 @@ def or_next(uri: str) -> str:
:param uri: The URI. :param uri: The URI.
:return: The next URI or the supplied URI. :return: The next URI or the supplied URI.
""" """
next_uri: str | None = __get_next_uri()
return uri if next_uri is None else next_uri
def __get_next_uri() -> str | None:
"""Returns the valid next URI.
:return: The valid next URI.
"""
next_uri: str | None = request.form.get("next") \ next_uri: str | None = request.form.get("next") \
if request.method == "POST" else request.args.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 or not next_uri.startswith("/"):
return None
if len(next_uri) > 512:
return next_uri[:512]
return next_uri
def __set_next(uri: str, next_uri: str) -> str: def __set_next(uri: str, next_uri: str) -> str:

View File

@ -52,6 +52,8 @@ def create_app(is_testing: bool = False) -> Flask:
db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite" db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
app.config.from_mapping({ app.config.from_mapping({
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)), "SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": True,
"SQLALCHEMY_DATABASE_URI": db_uri, "SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en", "BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文", "ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",

View File

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

View File

@ -26,13 +26,13 @@ First written: 2023/1/27
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="{{ "imacat" }}" /> <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/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" type="text/css" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/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" href="https://cdn.jsdelivr.net/npm/@eonasdan/tempus-dominus@6.7.7/dist/css/tempus-dominus.min.css" crossorigin="anonymous">
{% block styles %}{% endblock %} {% block styles %}{% endblock %}
<script src="{{ url_for("babel_catalog") }}"></script> <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/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/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 %} {% block scripts %}{% endblock %}
<link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}"> <link rel="shortcut icon" href="{{ url_for("static", filename="favicon.svg") }}">
<title>{% block title %}{% endblock %}</title> <title>{% block title %}{% endblock %}</title>

View File

@ -101,6 +101,60 @@ class NextUriTestCase(unittest.TestCase):
"name": "viewer"}) "name": "viewer"})
self.assertEqual(response.status_code, 200) 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),
request.args.get("inherit-expected"))
self.assertEqual(or_next(self.TARGET),
request.args.get("or-expected"))
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"
expected1 = self.TARGET
expected2 = self.TARGET
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}"
f"&inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}")
self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next"
f"?inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}",
data={"csrf_token": csrf_token,
"next": next_uri})
self.assertEqual(response.status_code, 200)
# An extremely-long URI to trigger the error
next_uri = "/" + "x" * 1024
expected2 = next_uri[:512]
expected1 = f"{self.TARGET}?next={quote_plus(expected2)}"
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}"
f"&inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}")
self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next"
f"?inherit-expected={quote_plus(expected1)}"
f"&or-expected={quote_plus(expected2)}",
data={"csrf_token": csrf_token,
"next": next_uri})
self.assertEqual(response.status_code, 200)
class QueryKeywordParserTestCase(unittest.TestCase): class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser.""" """The test case for the query keyword parser."""