82 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
0b1dd4f4fc Advanced to version 1.5.3. 2023-04-30 15:07:46 +08:00
46bd27e126 Revised the saveOriginalLineItem method of the JavaScript JournalEntryLineItemEditor class not to override the existing amount when the existing amount is less than the net balance. This make it easier when updating the existing journal entries. 2023-04-30 15:03:59 +08:00
b718d19450 Resolved an issue where, in cases where there was no existing localized title and the default title was submitted, the submitted account title or currency name would be erroneously saved as the localized title. 2023-04-30 15:03:58 +08:00
2969e83afe Advanced to version 1.5.2. 2023-04-30 06:43:18 +08:00
a732656746 Revised the coding style in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:43 +08:00
1daed940b6 Corrected the definition of the "is_offset" property in the "__get_line_items" method of the OffsetMatcher class. 2023-04-30 06:38:01 +08:00
f29cb00aec Advanced to version 1.5.1. 2023-04-30 05:53:37 +08:00
693f07a49c Removed the "timestamp" and
"user_pk" type aliases for the columns in the data models.  They do not work with the current version of Flask-SQLAlchemy when creating Sphinx documentation.
2023-04-30 05:51:31 +08:00
8c899776f2 Corrected the filename in the csv method of the AccountsWithUnmatchedOffsets report class. 2023-04-30 05:35:13 +08:00
f9aa226bf9 Removed an unnecessary f-string from the csv method of the AccountsWithUnappliedOriginalLineItems report class. 2023-04-30 05:34:34 +08:00
c9bb4197be Fixed the error calling the old "setEnableDescriptionAccount" method in the saveOriginalLineItem method of the JavaScript JournalEntryLineItemEditor class. 2023-04-30 05:27:09 +08:00
9ae8d587d8 Removed the unused "random_pk" annotated type alias. 2023-04-29 04:16:11 +08:00
158058dcfb Updated the documentation of the created_at, created_by, updated_at, updated_by, and visited_at columns of the data models, for consistency. 2023-04-28 21:53:11 +08:00
0bc9947234 Revised the documentation of the new_id function. 2023-04-26 20:36:09 +08:00
8c58a9083a Added type hint subscription for the cls parameter of the new_id function. 2023-04-26 18:31:13 +08:00
f45663754c Fixed the documentation of the "accounting.utils.random_id" module. 2023-04-26 18:30:18 +08:00
cda9e4e3c6 Replaced importing the "typing" module as "t" with importing the individual names in the "typing" module. Since Python 3.9 introduced type hinting generics in standard collections, we do not have as many names to import now. This is also to be consistent with the practices of most major and standard packages and examples. 2023-04-26 18:22:45 +08:00
ee5b447c23 Renamed the "journal_entry_date" variable to "date" in the "__form" method of the JournalEntryData class in the lib module of the test site. 2023-04-26 13:42:47 +08:00
25bfcf4aa4 Fixed the documentation of the balance pseudo property of the JournalEntryLineItem data model. 2023-04-26 13:40:48 +08:00
5956d2cd4c Renamed the "match" parameter to "value" in the setter of the "match" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:48 +08:00
833285d924 Renamed the "is_offset" parameter to "value" in the setter of the "is_offset" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:43 +08:00
dee4f5e83f Renamed the "balance" parameter to "value" in the setter of the "balance" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:37 +08:00
f0d1cae32d Renamed the "net_balance" parameter to "value" in the setter of the "net_balance" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:25 +08:00
5dc71697b3 Renamed the "credit" parameter to "value" in the setter of the "credit" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:20 +08:00
1bb1e03c08 Renamed the "debit" parameter to "value" in the setter of the "debit" pseudo property of the JournalEntryLineItem data model, for consistency. 2023-04-26 13:40:09 +08:00
914ff92e0f Renamed the "count" parameter to "value" in the setter of the "count" pseudo property of the Account data model, for consistency. 2023-04-26 13:39:56 +08:00
8a1cf463b1 Renamed the "journal_entry_date" parameter to "date" in the constructor of the CSVRow class in the "accounting.report.reports.ledger" module. 2023-04-26 13:34:14 +08:00
d4cf224d6b Renamed the "journal_entry_date" parameter to "date" in the constructor of the CSVRow class in the "accounting.report.reports.income_expenses" module. 2023-04-26 13:33:50 +08:00
8d412ec00a Renamed the "journal_entry_date" parameter to "date" in the show_journal_entry_order route. 2023-04-26 13:32:42 +08:00
2986c518ce Renamed the "journal_entry_date" parameter to "date" in the sort_journal_entries route. 2023-04-26 13:32:30 +08:00
f1351243a6 Renamed the "journal_entry_date" parameter to "date" in the constructor of the JournalEntryReorderForm form. 2023-04-26 13:29:55 +08:00
969e8c76a6 Renamed the "journal_entry_date" parameter to "date" in the "sort_journal_entries_in" function in the "accounting.journal_entry.forms.reorder" module. 2023-04-26 13:29:14 +08:00
10f5e75752 Renamed the "journal_entry_date" variable to "date" in the "test_reorder" test of the JournalEntryReorderTestCase test case. 2023-04-26 13:28:07 +08:00
169b3c292a Renamed the "journal_entry_date" variable to "date" in the "__get_journal_entry_condition" method of the LineItemCollector class in the "accounting.report.reports.search" module. 2023-04-26 13:26:38 +08:00
3eb3aef2f2 Renamed the "j_date" parameter to "date" in the "__next_j_no" method of the BaseTestData class in the lib module of the test site. 2023-04-26 13:24:19 +08:00
6c455a615c Renamed the "j_date" variable to "date" in the "_add_journal_entry" method of the BaseTestData class in the lib module of the test site. 2023-04-26 13:23:53 +08:00
4f3339bf68 Renamed the "j_date" variable to "date" in the "__add_usd_recurring" method of the SampleData class in the reset module of the test site. 2023-04-26 13:23:23 +08:00
b5aa7e923f Renamed the "j_date" variable to "date" in the "_init_data" method of the ReportTestData class in test_report.py. 2023-04-26 13:22:46 +08:00
359c335662 Revised the way to import from the datetime package, to avoid name conflict with the common "date" and "time" names. 2023-04-26 13:17:31 +08:00
c11ae23885 Removed an unused import from the "accounting.utils.cast" module. 2023-04-26 13:15:18 +08:00
e083b11394 Removed the random_pk type alias, because autoincrement=False does not seem to work with it. 2023-04-24 23:29:58 +08:00
167990fc4c Renamed the random_id type alias to random_pk. 2023-04-24 20:22:33 +08:00
d5c1be3d80 Rewrote the data model declaration of the test site with the mapped type hint and the mapped columns in SQLAlchemy 2.0. 2023-04-24 14:02:56 +08:00
f6567794e0 Added the documentation to the authentication blueprint of the test site. 2023-04-24 14:00:32 +08:00
ded85d88f7 Added the timestamp, user_pk, and random_id type aliases to simplify the column definition of the data models. 2023-04-24 03:37:33 +08:00
6d780e9296 Revised the title of the change log. 2023-04-23 22:19:01 +08:00
86637267d3 Advanced to version 1.5.0. 2023-04-23 20:13:34 +08:00
71e97721aa Removed the documentation link from the documentation in intro.rst. It does not make sense for a circular link to itself. 2023-04-23 20:13:33 +08:00
5815608288 Added the VERSION constant to the accounting module for the package version, and revised pyproject.toml and conf.py to read the version from it. 2023-04-23 20:13:10 +08:00
5f75d93c6a Simplified README.rst. 2023-04-23 18:42:54 +08:00
118c4b458e Added the change log. 2023-04-23 18:42:42 +08:00
3f7e4c0dda Rewrote the data model declaration with the mapped type hint and the mapped columns in SQLAlchemy 2.0. Added "SQLAlchemy >= 2" to the dependencies. 2023-04-23 13:21:54 +08:00
eed4c923f6 Removed the "be" cast function to cast data type for the binary expressions. It is to be replaced by the mapped type hints. 2023-04-23 13:21:48 +08:00
09dd5ae541 Revised the long line in the JournalEntryConverter converter. 2023-04-23 09:52:21 +08:00
172a12b134 Fixed the type hint of the "currency_options" function. 2023-04-23 09:44:25 +08:00
f3c558f48a Advanced to version 1.4.1. 2023-04-22 18:22:47 +08:00
988757d30e Revised the JavaScript journal entry line item editor to only override the description with the description of the original line item when there is no existing description. 2023-04-20 00:28:28 +08:00
50cea90d1b Revised the JavaScript journal entry line item editor to allow editing the description for offsets and partially-offset original items. 2023-04-20 00:26:58 +08:00
86 changed files with 1428 additions and 983 deletions

View File

@ -44,126 +44,18 @@ You may also download from the `PyPI project page`_ or the
`release page`_ on the `Git repository`_. `release page`_ on the `Git repository`_.
Prerequisites
=============
You need a running Flask application with database user login.
The primary key of the user data model must be integer. You also
need at least one user.
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
Configuration
=============
You need to pass the Flask *app* and an implementation of
`UserUtilityInterface`_ to the `init_app`_ function.
``UserUtilityInterface`` contains everything *Mia! Accounting* needs.
The following is an example configuration for *Mia! Accounting*.
::
from flask import Response, redirect
from .auth import current_user()
from .modules import User
def create_app(test_config=None) -> Flask:
app: Flask = Flask(__name__)
... (Configuration of SQLAlchemy, CSRF, Babel_JS, ... etc) ...
import accounting
class UserUtils(accounting.UserUtilityInterface[User]):
def can_view(self) -> bool:
return True
def can_edit(self) -> bool:
return "editor" in current_user().roles
def can_admin(self) -> bool:
return current_user().is_admin
def unauthorized(self) -> Response:
return redirect("/login")
@property
def cls(self) -> t.Type[User]:
return User
@property
def pk_column(self) -> Column:
return User.id
@property
def current_user(self) -> User | None:
return current_user()
def get_by_username(self, username: str) -> User | None:
return User.query.filter(User.username == username).first()
def get_pk(self, user: User) -> int:
return user.id
accounting.init_app(app, UserUtils())
... (Any other configuration) ...
return app
Database Initialization
=======================
After the configuration, run the ``accounting-init-db`` console
command to initialize the accounting database. You need to specify
the username of a user as the data creator.
::
% flask --app myapp accounting-init-db -u username
Navigation Menu
===============
Include the navigation menu in the `Bootstrap navigation bar`_ in your
base template:
::
<nav class="navbar navbar-expand-lg bg-body-tertiary bg-dark navbar-dark">
<div class="container-fluid">
...
<div id="collapsible-navbar" class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
...
{% include "accounting/include/nav.html" %}
...
</ul>
...
</div>
</div>
</nav>
Check your Flask application and see how it works.
Documentation Documentation
============= =============
Refer to the `documentation on Read the Docs`_. Refer to the `documentation on Read the Docs`_.
Change Log
==========
Refer to the `change log`_.
Copyright Copyright
========= =========
@ -198,12 +90,5 @@ Authors
.. _PyPI project page: https://pypi.org/project/mia-accounting .. _PyPI project page: https://pypi.org/project/mia-accounting
.. _release page: https://github.com/imacat/mia-accounting/releases .. _release page: https://github.com/imacat/mia-accounting/releases
.. _Git repository: https://github.com/imacat/mia-accounting .. _Git repository: https://github.com/imacat/mia-accounting
.. _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
.. _Tempus-Dominus: https://getdatepicker.com
.. _UserUtilityInterface: https://mia-accounting.readthedocs.io/en/latest/accounting.utils.html#accounting.utils.user.UserUtilityInterface
.. _init_app: https://mia-accounting.readthedocs.io/en/latest/accounting.html#accounting.init_app
.. _Bootstrap navigation bar: https://getbootstrap.com/docs/5.3/components/navbar/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io .. _documentation on Read the Docs: https://mia-accounting.readthedocs.io
.. _change log: https://mia-accounting.readthedocs.io/en/latest/changelog.html

377
docs/source/changelog.rst Normal file
View File

@ -0,0 +1,377 @@
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
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
* Revised the original line item editor not to override the existing
amount when the existing amount is less or equal to the net
balance.
Version 1.5.2
-------------
Released 2023/4/30
* Fixed the error of the net balance in the unmatched offset list.
Version 1.5.1
-------------
Released 2023/4/30
* Fixed the error calling the old ``setEnableDescriptionAccount``
method in the ``saveOriginalLineItem`` method of the JavaScript
``JournalEntryLineItemEditor`` class.
Version 1.5.0
-------------
Released 2023/4/23
* Updated to require ``SQLAlchemy >= 2``.
* Added the change log.
* Added the ``VERSION`` constant to the ``accounting`` module for
the package version, and revised ``pyproject.toml`` and ``conf.py``
to read the version from it.
Version 1.4.1
-------------
Released 2023/4/22
* Updated to allow editing the description of the journal entry line
item with offsets or are offsetting to original line items.
* Updated not to override the existing description of a journal entry
line item after choosing the original line item to offset to.
Version 1.4.0
-------------
Released 2023/4/18
* Rewrote the unapplied original line items and unmatched offsets.
* The unapplied original line items and unmatched offsets are both
in the report submodule. They can be filtered with currency and
period now.
* Show the unapplied original line items and unmatched offsets
together, and added the accumulated balance in the unmatched
offset list, for ease of reference.
* Removed the account code from the journal entry detail and journal
entry form for mobile devices.
* Made the account options in the reports to be scrollable.
Version 1.3.3
-------------
Released 2023/4/13
Changed the sample data generation in the test site live demonstration
from pre-recorded data to real-time generation, to avoid the problem
with the start of months and weeks changed with the date of the
import.
Version 1.3.2
-------------
Released 2023/4/12
Added the sample data generation and database reset on the test site
for live demonstration.
Version 1.3.1
-------------
Released 2023/4/11
* Fixed the permission of the navigation menu of the unmatched offsets.
* Revised the test site to be more accessible as the live demonstration.
Version 1.3.0
-------------
Released 2023/4/11
Added the ``accounting-init-db`` console command to replace all the
other console commands to initialize the accounting database. The
test site does not work with previous versions (<1.3.0).
Version 1.2.1
-------------
Released 2023/4/9
Fixed the search result to allow full ``year/month/day``
specification.
Version 1.2.0
-------------
Released 2023/4/9
* Simplified the URL of the default reports.
* Fixed the crash with malformed Chinese translation.
* Fixed the crash when downloading CSV data with non-US-ASCII
filenames.
Version 1.1.0
-------------
Released 2023/4/9
* Added the unapplied original line item list, to track unpaid
payables, unreceived receivables, assets, prepaids, refundable
deposits, etc.
* Added the offset matcher to match unapplied original line items
with unmatched offsets.
Version 1.0.1
-------------
Released 2023/4/6
Documentation fixes.
Version 1.0.0
-------------
Released 2023/4/6
The first formal release in Flask.
Added the documentation.
Version 0.11.1 (Pre-release)
----------------------------
Released 2023/4/5
Removed the zero balances from the trial balance, the income
statement, and the balance sheet.
Version 0.11.0 (Pre-release)
----------------------------
Released 2023/4/5
* Renamed the project from ``mia-accounting-flask`` to
``mia-accounting``.
* Updated the URL of the reports, as the default views of the
accounting application.
* Updated ``README``.
* Various fixes.
Version 0.10.0 (Pre-release)
----------------------------
Released 2023/4/3
* Added the unauthorized method to the ``UserUtilityInterface``
interface to allow fine control to how to handle the case when the
user has not logged in.
* Revised the JavaScript description editor to respect the account
that the user has confirmed or specifically selected.
* Various fixes.
Version 0.9.1 (Pre-release)
---------------------------
Released 2023/3/24
* A distinguishable look in the option detail than the option form.
* A better look in the new journal entry forms when there is no line
item yet.
* Fixed the search in the original entry selector in the journal
entry form to always do a partial match, to fix the problem that
there is no match when typing is not finished yet.
* Fixed the search in the original entry selector to search the net
balance correctly.
* Replaced the ``editor`` and ``editor2`` accounts with the ``admin``
and ``editor`` accounts.
* Various fixes.
Version 0.9.0 (Pre-release)
---------------------------
Released 2023/3/23
Moved the settings from the ``.env`` file to the option table in the
database that can be set and updated on the web interface. Added the
settings page to show and update the settings.
Version 0.8.0 (Pre-release)
---------------------------
Released 2023/3/22
* Added the recurring transactions to the description editor.
* Added prevention to delete database objects that are essential or
referenced by others with foreign keys.
* Various fixes on the visual layout.
Version 0.7.0 (Pre-release)
---------------------------
Released 2023/3/21
* Renamed "transaction" to "journal entry", and "journal entry" to
"journal entry line item".
* Renamed ``summary`` to ``description``.
* Updated tempus-dominus from version 6.2.10 to 6.4.3.
* Fixed titles and capitalization.
* Fixed to search case-insensitively.
* Added favicon to the test site.
* Fixed the navigation menu when there is no matching endpoint.
* Various fixes.
Version 0.6.0 (Pre-release)
---------------------------
Released 2023/3/18
* Added offset tracking to the journal entries in the payable and
receivable accounts.
* Renamed the ``is_offset_needed`` column to ``is_need_offset`` in
the ``Account`` data model.
Version 0.5.0 (Pre-release)
---------------------------
Released 2023/3/10
Added the accounting reports.
Version 0.4.0 (Pre-release)
---------------------------
Released 2023/3/1
Added the transaction summary helper.
Version 0.3.1 (Pre-release)
---------------------------
Released 2023/2/28
* Fixed the error that cannot select any account when adding new
transactions.
* Fixed the database error when adding new transactions.
* Added the button to convert a cash income or cash expense
transaction to a transfer transaction.
Version 0.3.0 (Pre-release)
---------------------------
Released 2023/2/27
Added the transaction management.
Version 0.2.0 (Pre-release)
---------------------------
Released 2023/2/7
* Added the currency management.
* Changed the ``can_edit`` permission to at least require the user to
log in first.
* Changed the type hint of the ``current_user`` pseudo property of
the ``AbstractUserUtils`` class to return ``None`` when the user
has not logged in.
Version 0.1.1 (Pre-release)
---------------------------
Released 2023/2/3
Finalized the account management, with tests and reordering.
Version 0.1.0 (Pre-release)
---------------------------
Released 2023/2/3
Added the account management, and updated the API to initialize the
accounting application.
Version 0.0.0 (Pre-release)
---------------------------
Released 2023/2/3
Initial release with main account list, localization, pagination,
query, permission, Sphinx documentation, and a test case based on a
test demonstration site.

View File

@ -6,6 +6,7 @@ import os
import sys import sys
sys.path.insert(0, os.path.abspath('../../src/')) sys.path.insert(0, os.path.abspath('../../src/'))
import accounting
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
@ -13,7 +14,7 @@ sys.path.insert(0, os.path.abspath('../../src/'))
project = 'Mia! Accounting' project = 'Mia! Accounting'
copyright = '2023, imacat' copyright = '2023, imacat'
author = 'imacat' author = 'imacat'
release = '1.4.0' release = accounting.VERSION
# -- General configuration --------------------------------------------------- # -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

View File

@ -14,6 +14,7 @@ Welcome to Mia! Accounting's documentation!
accounting accounting
examples examples
history history
changelog

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
@ -103,12 +103,6 @@ base template:
Check your Flask application and see how it works. Check your Flask application and see how it works.
Documentation
-------------
Refer to the `documentation on Read the Docs`_.
.. _Flask: https://flask.palletsprojects.com .. _Flask: https://flask.palletsprojects.com
.. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping .. _double-entry bookkeeping: https://en.wikipedia.org/wiki/Double-entry_bookkeeping
.. _live demonstration: https://accounting.imacat.idv.tw .. _live demonstration: https://accounting.imacat.idv.tw
@ -120,7 +114,7 @@ Refer to the `documentation on Read the Docs`_.
.. _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/
.. _documentation on Read the Docs: https://mia-accounting.readthedocs.io

View File

@ -17,7 +17,7 @@
[project] [project]
name = "mia-accounting" name = "mia-accounting"
version = "1.4.0" dynamic = ["version"]
description = "A Flask accounting module." description = "A Flask accounting module."
readme = "README.rst" readme = "README.rst"
requires-python = ">=3.11" requires-python = ">=3.11"
@ -34,6 +34,7 @@ classifiers = [
] ]
dependencies = [ dependencies = [
"flask", "flask",
"SQLAlchemy >= 2",
"Flask-SQLAlchemy", "Flask-SQLAlchemy",
"Flask-WTF", "Flask-WTF",
"Flask-Babel >= 3", "Flask-Babel >= 3",
@ -49,6 +50,7 @@ test = [
[project.urls] [project.urls]
"Documentation" = "https://mia-accounting.readthedocs.io" "Documentation" = "https://mia-accounting.readthedocs.io"
"Change Log" = "https://mia-accounting.readthedocs.io/en/latest/changelog.html"
"Repository" = "https://github.com/imacat/mia-accounting" "Repository" = "https://github.com/imacat/mia-accounting"
"Bug Tracker" = "https://github.com/imacat/mia-accounting/issues" "Bug Tracker" = "https://github.com/imacat/mia-accounting/issues"
"Demonstration" = "https://accounting.imacat.idv.tw" "Demonstration" = "https://accounting.imacat.idv.tw"
@ -57,6 +59,9 @@ test = [
requires = ["setuptools>=42"] requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.setuptools.dynamic]
version = {attr = "accounting.VERSION"}
[tool.setuptools.exclude-package-data] [tool.setuptools.exclude-package-data]
"*" = [ "*" = [
"babel.cfg", "babel.cfg",

View File

@ -24,6 +24,8 @@ from flask_sqlalchemy import SQLAlchemy
from accounting.utils.user import UserUtilityInterface from accounting.utils.user import UserUtilityInterface
VERSION: str = "1.5.6"
"""The package version."""
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
"""The database instance.""" """The database instance."""
data_dir: Path = Path(__file__).parent / "data" data_dir: Path = Path(__file__).parent / "data"

View File

@ -17,15 +17,15 @@
"""The console commands for the account management. """The console commands for the account management.
""" """
import typing as t
from secrets import randbelow from secrets import randbelow
from typing import Any
import click import click
import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk from accounting.utils.user import get_user_pk
import sqlalchemy as sa
AccountData = tuple[int, str, int, str, str, str, bool] AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number, """The format of the account data, as a list of (ID, base account code, number,
@ -63,8 +63,8 @@ def init_accounts_command(username: str) -> None:
existing_id.add(new_id) existing_id.add(new_id)
return new_id return new_id
data: list[dict[str, t.Any]] = [] data: list[dict[str, Any]] = []
l10n_data: list[dict[str, t.Any]] = [] l10n_data: list[dict[str, Any]] = []
for base in bases_to_add: for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
account_id: int = get_new_id() account_id: int = get_new_id()

View File

@ -18,7 +18,7 @@
""" """
import csv import csv
import typing as t from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
@ -39,11 +39,11 @@ def init_currencies_command(username: str) -> None:
return return
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
currency_data: list[dict[str, t.Any]] = [{"code": x["code"], currency_data: list[dict[str, Any]] = [{"code": x["code"],
"name_l10n": x["name"], "name_l10n": x["name"],
"created_by_id": creator_pk, "created_by_id": creator_pk,
"updated_by_id": creator_pk} "updated_by_id": creator_pk}
for x in to_add] for x in to_add]
locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")] locales: list[str] = [x[5:] for x in to_add[0] if x.startswith("l10n-")]
l10n_data: list[dict[str, str]] = [{"currency_code": x["code"], l10n_data: list[dict[str, str]] = [{"currency_code": x["code"],
"locale": y, "locale": y,

View File

@ -17,7 +17,7 @@
"""The path converters for the journal entry management. """The path converters for the journal entry management.
""" """
from datetime import date import datetime as dt
from flask import abort from flask import abort
from werkzeug.routing import BaseConverter from werkzeug.routing import BaseConverter
@ -37,7 +37,8 @@ class JournalEntryConverter(BaseConverter):
:param value: The journal entry ID. :param value: The journal entry ID.
:return: The corresponding journal entry. :return: The corresponding journal entry.
""" """
journal_entry: JournalEntry | None = db.session.get(JournalEntry, value) journal_entry: JournalEntry | None \
= db.session.get(JournalEntry, value)
if journal_entry is None: if journal_entry is None:
abort(404) abort(404)
return journal_entry return journal_entry
@ -81,18 +82,18 @@ class DateConverter(BaseConverter):
"""The date converter to convert the ISO date from and to the """The date converter to convert the ISO date from and to the
corresponding date in the routes.""" corresponding date in the routes."""
def to_python(self, value: str) -> date: def to_python(self, value: str) -> dt.date:
"""Converts an ISO date to a date. """Converts an ISO date to a date.
:param value: The ISO date. :param value: The ISO date.
:return: The corresponding date. :return: The corresponding date.
""" """
try: try:
return date.fromisoformat(value) return dt.date.fromisoformat(value)
except ValueError: except ValueError:
abort(404) abort(404)
def to_url(self, value: date) -> str: def to_url(self, value: dt.date) -> str:
"""Converts a date to its ISO date. """Converts a date to its ISO date.
:param value: The date. :param value: The date.

View File

@ -30,7 +30,6 @@ from accounting import db
from accounting.forms import CurrencyExists from accounting.forms import CurrencyExists
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import JournalEntryLineItem from accounting.models import JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias from accounting.utils.offset_alias import offset_alias
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm from .line_item import LineItemForm, CreditLineItemForm, DebitLineItemForm
@ -75,8 +74,8 @@ class KeepCurrencyWhenHavingOffset:
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
original_line_items: list[JournalEntryLineItem]\ original_line_items: list[JournalEntryLineItem]\
= JournalEntryLineItem.query\ = JournalEntryLineItem.query\
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(JournalEntryLineItem.id .filter(JournalEntryLineItem.id
.in_({x.id.data for x in form.line_items .in_({x.id.data for x in form.line_items

View File

@ -18,8 +18,8 @@
""" """
import datetime as dt import datetime as dt
import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type
import sqlalchemy as sa import sqlalchemy as sa
from flask_babel import LazyString from flask_babel import LazyString
@ -29,13 +29,13 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
from wtforms.validators import DataRequired, ValidationError from wtforms.validators import DataRequired, ValidationError
from accounting import db from accounting import db
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import JournalEntry, Account, JournalEntryLineItem, \ from accounting.models import JournalEntry, Account, JournalEntryLineItem, \
JournalEntryCurrency JournalEntryCurrency
from accounting.journal_entry.utils.account_option import AccountOption
from accounting.journal_entry.utils.original_line_items import \
get_selectable_original_line_items
from accounting.journal_entry.utils.description_editor import DescriptionEditor
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_multiline_text from accounting.utils.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
@ -123,7 +123,7 @@ class JournalEntryForm(FlaskForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.is_modified: bool = False self.is_modified: bool = False
"""Whether the journal entry is modified during populate_obj().""" """Whether the journal entry is modified during populate_obj()."""
self.collector: t.Type[LineItemCollector] = LineItemCollector self.collector: Type[LineItemCollector] = LineItemCollector
"""The line item collector. The default is the base abstract """The line item collector. The default is the base abstract
collector only to provide the correct type. The subclass forms should collector only to provide the correct type. The subclass forms should
provide their own collectors.""" provide their own collectors."""
@ -155,7 +155,7 @@ class JournalEntryForm(FlaskForm):
self.__set_date(obj, self.date.data) self.__set_date(obj, self.date.data)
obj.note = self.note.data obj.note = self.note.data
collector_cls: t.Type[LineItemCollector] = self.collector collector_cls: Type[LineItemCollector] = self.collector
collector: collector_cls = collector_cls(self, obj) collector: collector_cls = collector_cls(self, obj)
collector.collect() collector.collect()
@ -309,11 +309,11 @@ class JournalEntryForm(FlaskForm):
return db.session.scalar(select) return db.session.scalar(select)
T = t.TypeVar("T", bound=JournalEntryForm) T = TypeVar("T", bound=JournalEntryForm)
"""A journal entry form variant.""" """A journal entry form variant."""
class LineItemCollector(t.Generic[T], ABC): class LineItemCollector(Generic[T], ABC):
"""The line item collector.""" """The line item collector."""
def __init__(self, form: T, obj: JournalEntry): def __init__(self, form: T, obj: JournalEntry):

View File

@ -17,7 +17,7 @@
"""The line item sub-forms for the journal entry management. """The line item sub-forms for the journal entry management.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -33,7 +33,6 @@ from accounting.forms import ACCOUNT_REQUIRED, AccountExists, IsDebitAccount, \
from accounting.locale import lazy_gettext from accounting.locale import lazy_gettext
from accounting.models import Account, JournalEntry, JournalEntryLineItem from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.template_filters import format_amount from accounting.template_filters import format_amount
from accounting.utils.cast import be
from accounting.utils.random_id import new_id from accounting.utils.random_id import new_id
from accounting.utils.strip_text import strip_text from accounting.utils.strip_text import strip_text
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
@ -198,13 +197,13 @@ class NotExceedingOriginalLineItemNetBalance:
existing_line_item_id \ existing_line_item_id \
= {x.id for x in form.journal_entry_form.obj.line_items} = {x.id for x in form.journal_entry_form.obj.line_items}
offset_total_func: sa.Function = sa.func.sum(sa.case( offset_total_func: sa.Function = sa.func.sum(sa.case(
(be(JournalEntryLineItem.is_debit == is_debit), (JournalEntryLineItem.is_debit == is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
offset_total_but_form: Decimal | None = db.session.scalar( offset_total_but_form: Decimal | None = db.session.scalar(
sa.select(offset_total_func) sa.select(offset_total_func)
.filter(be(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id
== original_line_item.id), == original_line_item.id,
JournalEntryLineItem.id.not_in(existing_line_item_id))) JournalEntryLineItem.id.not_in(existing_line_item_id)))
if offset_total_but_form is None: if offset_total_but_form is None:
offset_total_but_form = Decimal("0") offset_total_but_form = Decimal("0")
@ -232,8 +231,7 @@ class NotLessThanOffsetTotal:
(JournalEntryLineItem.is_debit != is_debit, (JournalEntryLineItem.is_debit != is_debit,
JournalEntryLineItem.amount), JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)))\ else_=-JournalEntryLineItem.amount)))\
.filter(be(JournalEntryLineItem.original_line_item_id .filter(JournalEntryLineItem.original_line_item_id == form.id.data)
== form.id.data))
offset_total: Decimal | None = db.session.scalar(select_offset_total) offset_total: Decimal | None = db.session.scalar(select_offset_total)
if offset_total is not None and field.data < offset_total: if offset_total is not None and field.data < offset_total:
raise ValidationError(lazy_gettext( raise ValidationError(lazy_gettext(
@ -309,7 +307,7 @@ class LineItemForm(FlaskForm):
return getattr(self, "____original_line_item") return getattr(self, "____original_line_item")
@property @property
def original_line_item_date(self) -> date | None: def original_line_item_date(self) -> dt.date | None:
"""Returns the text representation of the original line item. """Returns the text representation of the original line item.
:return: The text representation of the original line item. :return: The text representation of the original line item.

View File

@ -17,7 +17,7 @@
"""The reorder forms for the journal entry management. """The reorder forms for the journal entry management.
""" """
from datetime import date import datetime as dt
import sqlalchemy as sa import sqlalchemy as sa
from flask import request from flask import request
@ -26,17 +26,15 @@ from accounting import db
from accounting.models import JournalEntry from accounting.models import JournalEntry
def sort_journal_entries_in(journal_entry_date: date, def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
exclude: int | None = None) -> None:
"""Sorts the journal entries under a date after changing the date or """Sorts the journal entries under a date after changing the date or
deleting a journal entry. deleting a journal entry.
:param journal_entry_date: The date of the journal entry. :param date: The date of the journal entry.
:param exclude: The journal entry ID to exclude. :param exclude: The journal entry ID to exclude.
:return: None. :return: None.
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
= [JournalEntry.date == journal_entry_date]
if exclude is not None: if exclude is not None:
conditions.append(JournalEntry.id != exclude) conditions.append(JournalEntry.id != exclude)
journal_entries: list[JournalEntry] = JournalEntry.query\ journal_entries: list[JournalEntry] = JournalEntry.query\
@ -50,12 +48,12 @@ def sort_journal_entries_in(journal_entry_date: date,
class JournalEntryReorderForm: class JournalEntryReorderForm:
"""The form to reorder the journal entries.""" """The form to reorder the journal entries."""
def __init__(self, journal_entry_date: date): def __init__(self, date: dt.date):
"""Constructs the form to reorder the journal entries in a day. """Constructs the form to reorder the journal entries in a day.
:param journal_entry_date: The date. :param date: The date.
""" """
self.date: date = journal_entry_date self.date: dt.date = date
self.is_modified: bool = False self.is_modified: bool = False
def save_order(self) -> None: def save_order(self) -> None:

View File

@ -18,7 +18,7 @@
""" """
import re import re
import typing as t from typing import Literal
import sqlalchemy as sa import sqlalchemy as sa
@ -124,12 +124,12 @@ class DescriptionTag:
class DescriptionType: class DescriptionType:
"""A description type""" """A description type"""
def __init__(self, type_id: t.Literal["general", "travel", "bus"]): def __init__(self, type_id: Literal["general", "travel", "bus"]):
"""Constructs a description type. """Constructs a description type.
:param type_id: The type ID, either "general", "travel", or "bus". :param type_id: The type ID, either "general", "travel", or "bus".
""" """
self.id: t.Literal["general", "travel", "bus"] = type_id self.id: Literal["general", "travel", "bus"] = type_id
"""The type ID.""" """The type ID."""
self.__tag_dict: dict[str, DescriptionTag] = {} self.__tag_dict: dict[str, DescriptionTag] = {}
"""A dictionary from the tag name to their corresponding tag.""" """A dictionary from the tag name to their corresponding tag."""
@ -181,12 +181,12 @@ class DescriptionRecurring:
class DescriptionDebitCredit: class DescriptionDebitCredit:
"""The description on debit or credit.""" """The description on debit or credit."""
def __init__(self, debit_credit: t.Literal["debit", "credit"]): def __init__(self, debit_credit: Literal["debit", "credit"]):
"""Constructs the description on debit or credit. """Constructs the description on debit or credit.
:param debit_credit: Either "debit" or "credit". :param debit_credit: Either "debit" or "credit".
""" """
self.debit_credit: t.Literal["debit", "credit"] = debit_credit self.debit_credit: Literal["debit", "credit"] = debit_credit
"""Either debit or credit.""" """Either debit or credit."""
self.general: DescriptionType = DescriptionType("general") self.general: DescriptionType = DescriptionType("general")
"""The general tags.""" """The general tags."""
@ -194,14 +194,14 @@ class DescriptionDebitCredit:
"""The travel tags.""" """The travel tags."""
self.bus: DescriptionType = DescriptionType("bus") self.bus: DescriptionType = DescriptionType("bus")
"""The bus tags.""" """The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"], self.__type_dict: dict[Literal["general", "travel", "bus"],
DescriptionType] \ DescriptionType] \
= {x.id: x for x in {self.general, self.travel, self.bus}} = {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags.""" """A dictionary from the type ID to the corresponding tags."""
self.recurring: list[DescriptionRecurring] = [] self.recurring: list[DescriptionRecurring] = []
"""The recurring transactions.""" """The recurring transactions."""
def add_tag(self, tag_type: t.Literal["general", "travel", "bus"], def add_tag(self, tag_type: Literal["general", "travel", "bus"],
name: str, account: Account, freq: int) -> None: name: str, account: Account, freq: int) -> None:
"""Adds a tag. """Adds a tag.
@ -278,7 +278,7 @@ class DescriptionEditor:
accounts: dict[int, Account] \ accounts: dict[int, Account] \
= {x.id: x for x in Account.query = {x.id: x for x in Account.query
.filter(Account.id.in_({x.account_id for x in result})).all()} .filter(Account.id.in_({x.account_id for x in result})).all()}
debit_credit_dict: dict[t.Literal["debit", "credit"], debit_credit_dict: dict[Literal["debit", "credit"],
DescriptionDebitCredit] \ DescriptionDebitCredit] \
= {x.debit_credit: x for x in {self.debit, self.credit}} = {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result: for row in result:

View File

@ -17,19 +17,19 @@
"""The operators for different journal entry types. """The operators for different journal entry types.
""" """
import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Type
from flask import render_template, request, abort from flask import render_template, request, abort
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.journal_entry.forms import JournalEntryForm, \ from accounting.journal_entry.forms import JournalEntryForm, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \ CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm TransferJournalEntryForm
from accounting.journal_entry.forms.line_item import LineItemForm from accounting.journal_entry.forms.line_item import LineItemForm
from accounting.models import JournalEntry
from accounting.template_globals import default_currency_code
from accounting.utils.journal_entry_types import JournalEntryType
class JournalEntryOperator(ABC): class JournalEntryOperator(ABC):
@ -39,7 +39,7 @@ class JournalEntryOperator(ABC):
@property @property
@abstractmethod @abstractmethod
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@ -100,7 +100,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@ -170,7 +170,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.
@ -243,7 +243,7 @@ class TransferJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator.""" """The order when checking the journal entry operator."""
@property @property
def form(self) -> t.Type[JournalEntryForm]: def form(self) -> Type[JournalEntryForm]:
"""Returns the form class. """Returns the form class.
:return: The form class. :return: The form class.

View File

@ -24,7 +24,6 @@ from sqlalchemy.orm import selectinload
from accounting import db from accounting import db
from accounting.models import Account, JournalEntry, JournalEntryLineItem from accounting.models import Account, JournalEntry, JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias from accounting.utils.offset_alias import offset_alias
@ -45,8 +44,7 @@ def get_selectable_original_line_items(
offset: sa.Alias = offset_alias() offset: sa.Alias = offset_alias()
net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case( net_balance: sa.Label = (JournalEntryLineItem.amount + sa.func.sum(sa.case(
(offset.c.id.in_(line_item_id_on_form), 0), (offset.c.id.in_(line_item_id_on_form), 0),
(be(offset.c.is_debit == JournalEntryLineItem.is_debit), (offset.c.is_debit == JournalEntryLineItem.is_debit, offset.c.amount),
offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
conditions: list[sa.BinaryExpression] = [Account.is_need_offset] conditions: list[sa.BinaryExpression] = [Account.is_need_offset]
sub_conditions: list[sa.BinaryExpression] = [] sub_conditions: list[sa.BinaryExpression] = []
@ -60,8 +58,8 @@ def get_selectable_original_line_items(
select_net_balances: sa.Select \ select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance)\ = sa.select(JournalEntryLineItem.id, net_balance)\
.join(Account)\ .join(Account)\
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(*conditions)\ .filter(*conditions)\
.group_by(JournalEntryLineItem.id)\ .group_by(JournalEntryLineItem.id)\

View File

@ -17,7 +17,7 @@
"""The views for the journal entry management. """The views for the journal entry management.
""" """
from datetime import date import datetime as dt
from urllib.parse import parse_qsl, urlencode from urllib.parse import parse_qsl, urlencode
import sqlalchemy as sa import sqlalchemy as sa
@ -30,9 +30,9 @@ from accounting.locale import lazy_gettext
from accounting.models import JournalEntry from accounting.models import JournalEntry
from accounting.utils.cast import s from accounting.utils.cast import s
from accounting.utils.flash_errors import flash_form_errors from accounting.utils.flash_errors import flash_form_errors
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.next_uri import inherit_next, or_next from accounting.utils.next_uri import inherit_next, or_next
from accounting.utils.permission import has_permission, can_view, can_edit from accounting.utils.permission import has_permission, can_view, can_edit
from accounting.utils.journal_entry_types import JournalEntryType
from accounting.utils.user import get_current_user_pk from accounting.utils.user import get_current_user_pk
from .forms import sort_journal_entries_in, JournalEntryReorderForm from .forms import sort_journal_entries_in, JournalEntryReorderForm
from .template_filters import with_type, to_transfer, format_amount_input, \ from .template_filters import with_type, to_transfer, format_amount_input, \
@ -67,7 +67,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
form.validate() form.validate()
else: else:
form = journal_entry_op.form() form = journal_entry_op.form()
form.date.data = date.today() form.date.data = dt.date.today()
return journal_entry_op.render_create_template(form) return journal_entry_op.render_create_template(form)
@ -186,31 +186,31 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
return redirect(or_next(__get_default_page_uri())) return redirect(or_next(__get_default_page_uri()))
@bp.get("dates/<date:journal_entry_date>", endpoint="order") @bp.get("dates/<date:date>", endpoint="order")
@has_permission(can_view) @has_permission(can_view)
def show_journal_entry_order(journal_entry_date: date) -> str: def show_journal_entry_order(date: dt.date) -> str:
"""Shows the order of the journal entries in a same date. """Shows the order of the journal entries in a same date.
:param journal_entry_date: The date. :param date: The date.
:return: The order of the journal entries in the date. :return: The order of the journal entries in the date.
""" """
journal_entries: list[JournalEntry] = JournalEntry.query \ journal_entries: list[JournalEntry] = JournalEntry.query \
.filter(JournalEntry.date == journal_entry_date) \ .filter(JournalEntry.date == date) \
.order_by(JournalEntry.no).all() .order_by(JournalEntry.no).all()
return render_template("accounting/journal-entry/order.html", return render_template("accounting/journal-entry/order.html",
date=journal_entry_date, list=journal_entries) date=date, list=journal_entries)
@bp.post("dates/<date:journal_entry_date>", endpoint="sort") @bp.post("dates/<date:date>", endpoint="sort")
@has_permission(can_edit) @has_permission(can_edit)
def sort_journal_entries(journal_entry_date: date) -> redirect: def sort_journal_entries(date: dt.date) -> redirect:
"""Reorders the journal entries in a date. """Reorders the journal entries in a date.
:param journal_entry_date: The date. :param date: The date.
:return: The redirection to the incoming account or the account list. The :return: The redirection to the incoming account or the account list. The
reordering operation does not fail. reordering operation does not fail.
""" """
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date) form: JournalEntryReorderForm = JournalEntryReorderForm(date)
form.save_order() form.save_order()
if not form.is_modified: if not form.is_modified:
flash(s(lazy_gettext("The order was not modified.")), "success") flash(s(lazy_gettext("The order was not modified.")), "success")

View File

@ -19,14 +19,16 @@
""" """
from __future__ import annotations from __future__ import annotations
import datetime as dt
import re import re
import typing as t
from decimal import Decimal from decimal import Decimal
from typing import Type, Self
import sqlalchemy as sa import sqlalchemy as sa
from babel import Locale from babel import Locale
from flask_babel import get_locale, get_babel from flask_babel import get_locale, get_babel
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Mapped, mapped_column
from accounting import db from accounting import db
from accounting.locale import gettext from accounting.locale import gettext
@ -37,14 +39,14 @@ class BaseAccount(db.Model):
"""A base account.""" """A base account."""
__tablename__ = "accounting_base_accounts" __tablename__ = "accounting_base_accounts"
"""The table name.""" """The table name."""
code = db.Column(db.String, nullable=False, primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The code."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
l10n = db.relationship("BaseAccountL10n", back_populates="account", l10n: Mapped[list[BaseAccountL10n]] \
lazy=False) = db.relationship(back_populates="account", lazy=False)
"""The localized titles.""" """The localized titles."""
accounts = db.relationship("Account", back_populates="base") accounts: Mapped[list[Account]] = db.relationship(back_populates="base")
"""The descendant accounts under the base account.""" """The descendant accounts under the base account."""
def __str__(self) -> str: def __str__(self) -> str:
@ -81,17 +83,16 @@ class BaseAccountL10n(db.Model):
"""A localized base account title.""" """A localized base account title."""
__tablename__ = "accounting_base_accounts_l10n" __tablename__ = "accounting_base_accounts_l10n"
"""The table name.""" """The table name."""
account_code = db.Column(db.String, account_code: Mapped[str] \
db.ForeignKey(BaseAccount.code, = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
onupdate="CASCADE", ondelete="CASCADE"),
ondelete="CASCADE"), primary_key=True)
nullable=False, primary_key=True)
"""The code of the account.""" """The code of the account."""
account = db.relationship(BaseAccount, back_populates="l10n") account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
"""The account.""" """The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
title = db.Column(db.String, nullable=False) title: Mapped[str]
"""The localized title.""" """The localized title."""
@ -99,47 +100,43 @@ class Account(db.Model):
"""An account.""" """An account."""
__tablename__ = "accounting_accounts" __tablename__ = "accounting_accounts"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The account ID.""" """The account ID."""
base_code = db.Column(db.String, base_code: Mapped[str] \
db.ForeignKey(BaseAccount.code, onupdate="CASCADE", = mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"))
nullable=False)
"""The code of the base account.""" """The code of the base account."""
base = db.relationship(BaseAccount, back_populates="accounts") base: Mapped[BaseAccount] = db.relationship(back_populates="accounts")
"""The base account.""" """The base account."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the base account.""" """The account number under the base account."""
title_l10n = db.Column("title", db.String, nullable=False) title_l10n: Mapped[str] = mapped_column("title")
"""The title.""" """The title."""
is_need_offset = db.Column(db.Boolean, nullable=False, default=False) is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items of this account need offset.""" """Whether the journal entry line items of this account need offset."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The ID of the updator.""" """The last user who updated the record."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) l10n: Mapped[list[AccountL10n]] \
"""The updator.""" = db.relationship(back_populates="account", lazy=False)
l10n = db.relationship("AccountL10n", back_populates="account",
lazy=False)
"""The localized titles.""" """The localized titles."""
line_items = db.relationship("JournalEntryLineItem", line_items: Mapped[list[JournalEntryLineItem]] \
back_populates="account") = db.relationship(back_populates="account")
"""The journal entry line items.""" """The journal entry line items."""
CASH_CODE: str = "1111-001" CASH_CODE: str = "1111-001"
@ -185,6 +182,8 @@ class Account(db.Model):
:param value: The new title. :param value: The new title.
:return: None. :return: None.
""" """
if self.title == value:
return
if self.title_l10n is None: if self.title_l10n is None:
self.title_l10n = value self.title_l10n = value
return return
@ -225,13 +224,13 @@ class Account(db.Model):
return getattr(self, "__count") return getattr(self, "__count")
@count.setter @count.setter
def count(self, count: int) -> None: def count(self, value: int) -> None:
"""Sets the number of items in the account. """Sets the number of items in the account.
:param count: The number of items in the account. :param value: The number of items in the account.
:return: None. :return: None.
""" """
setattr(self, "__count", count) setattr(self, "__count", value)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
@ -270,11 +269,11 @@ class Account(db.Model):
:return: None. :return: None.
""" """
AccountL10n.query.filter(AccountL10n.account == self).delete() AccountL10n.query.filter(AccountL10n.account == self).delete()
cls: t.Type[t.Self] = self.__class__ cls: Type[Self] = self.__class__
cls.query.filter(cls.id == self.id).delete() cls.query.filter(cls.id == self.id).delete()
@classmethod @classmethod
def find_by_code(cls, code: str) -> t.Self | None: def find_by_code(cls, code: str) -> Self | None:
"""Finds an account by its code. """Finds an account by its code.
:param code: The code. :param code: The code.
@ -287,7 +286,7 @@ class Account(db.Model):
cls.no == int(m.group(2))).first() cls.no == int(m.group(2))).first()
@classmethod @classmethod
def selectable_debit(cls) -> list[t.Self]: def selectable_debit(cls) -> list[Self]:
"""Returns the selectable debit accounts. """Returns the selectable debit accounts.
Payable line items can not start from debit. Payable line items can not start from debit.
@ -310,7 +309,7 @@ class Account(db.Model):
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@classmethod @classmethod
def selectable_credit(cls) -> list[t.Self]: def selectable_credit(cls) -> list[Self]:
"""Returns the selectable debit accounts. """Returns the selectable debit accounts.
Receivable line items can not start from credit. Receivable line items can not start from credit.
@ -332,7 +331,7 @@ class Account(db.Model):
.order_by(cls.base_code, cls.no).all() .order_by(cls.base_code, cls.no).all()
@classmethod @classmethod
def cash(cls) -> t.Self: def cash(cls) -> Self:
"""Returns the cash account. """Returns the cash account.
:return: The cash account :return: The cash account
@ -340,7 +339,7 @@ class Account(db.Model):
return cls.find_by_code(cls.CASH_CODE) return cls.find_by_code(cls.CASH_CODE)
@classmethod @classmethod
def accumulated_change(cls) -> t.Self: def accumulated_change(cls) -> Self:
"""Returns the accumulated-change account. """Returns the accumulated-change account.
:return: The accumulated-change account :return: The accumulated-change account
@ -352,16 +351,16 @@ class AccountL10n(db.Model):
"""A localized account title.""" """A localized account title."""
__tablename__ = "accounting_accounts_l10n" __tablename__ = "accounting_accounts_l10n"
"""The table name.""" """The table name."""
account_id = db.Column(db.Integer, account_id: Mapped[int] \
db.ForeignKey(Account.id, onupdate="CASCADE", = mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) primary_key=True)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="l10n") account: Mapped[Account] = db.relationship(back_populates="l10n")
"""The account.""" """The account."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
title = db.Column(db.String, nullable=False) title: Mapped[str]
"""The localized title.""" """The localized title."""
@ -369,35 +368,34 @@ class Currency(db.Model):
"""A currency.""" """A currency."""
__tablename__ = "accounting_currencies" __tablename__ = "accounting_currencies"
"""The table name.""" """The table name."""
code = db.Column(db.String, nullable=False, primary_key=True) code: Mapped[str] = mapped_column(primary_key=True)
"""The code.""" """The code."""
name_l10n = db.Column("name", db.String, nullable=False) name_l10n: Mapped[str] = mapped_column("name")
"""The name.""" """The name."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] \
"""The ID of the updator.""" = db.relationship(foreign_keys=updated_by_id)
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) """The last user who updated the record."""
"""The updator.""" l10n: Mapped[list[CurrencyL10n]] \
l10n = db.relationship("CurrencyL10n", back_populates="currency", = db.relationship(back_populates="currency", lazy=False)
lazy=False)
"""The localized names.""" """The localized names."""
line_items = db.relationship("JournalEntryLineItem", line_items: Mapped[list[JournalEntryLineItem]] \
back_populates="currency") = db.relationship(back_populates="currency")
"""The journal entry line items.""" """The journal entry line items."""
def __str__(self) -> str: def __str__(self) -> str:
@ -428,6 +426,8 @@ class Currency(db.Model):
:param value: The new name. :param value: The new name.
:return: None. :return: None.
""" """
if self.name == value:
return
if self.name_l10n is None: if self.name_l10n is None:
self.name_l10n = value self.name_l10n = value
return return
@ -471,7 +471,7 @@ class Currency(db.Model):
:return: None. :return: None.
""" """
CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete() CurrencyL10n.query.filter(CurrencyL10n.currency == self).delete()
cls: t.Type[t.Self] = self.__class__ cls: Type[Self] = self.__class__
cls.query.filter(cls.code == self.code).delete() cls.query.filter(cls.code == self.code).delete()
@ -479,16 +479,16 @@ class CurrencyL10n(db.Model):
"""A localized currency name.""" """A localized currency name."""
__tablename__ = "accounting_currencies_l10n" __tablename__ = "accounting_currencies_l10n"
"""The table name.""" """The table name."""
currency_code = db.Column(db.String, currency_code: Mapped[str] \
db.ForeignKey(Currency.code, onupdate="CASCADE", = mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE",
ondelete="CASCADE"), ondelete="CASCADE"),
nullable=False, primary_key=True) primary_key=True)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="l10n") currency: Mapped[Currency] = db.relationship(back_populates="l10n")
"""The currency.""" """The currency."""
locale = db.Column(db.String, nullable=False, primary_key=True) locale: Mapped[str] = mapped_column(primary_key=True)
"""The locale.""" """The locale."""
name = db.Column(db.String, nullable=False) name: Mapped[str]
"""The localized name.""" """The localized name."""
@ -539,37 +539,34 @@ class JournalEntry(db.Model):
"""A journal entry.""" """A journal entry."""
__tablename__ = "accounting_journal_entries" __tablename__ = "accounting_journal_entries"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The journal entry ID.""" """The journal entry ID."""
date = db.Column(db.Date, nullable=False) date: Mapped[dt.date]
"""The date.""" """The date."""
no = db.Column(db.Integer, nullable=False, default=text("1")) no: Mapped[int] = mapped_column(default=text("1"))
"""The account number under the date.""" """The account number under the date."""
note = db.Column(db.String) note: Mapped[str | None]
"""The note.""" """The note."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The ID of the updator.""" """The last user who updated the record."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id) line_items: Mapped[list[JournalEntryLineItem]] \
"""The updator.""" = db.relationship(back_populates="journal_entry")
line_items = db.relationship("JournalEntryLineItem",
back_populates="journal_entry")
"""The line items.""" """The line items."""
def __str__(self) -> str: def __str__(self) -> str:
@ -659,44 +656,39 @@ class JournalEntryLineItem(db.Model):
"""A line item in the journal entry.""" """A line item in the journal entry."""
__tablename__ = "accounting_journal_entry_line_items" __tablename__ = "accounting_journal_entry_line_items"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=False)
autoincrement=False)
"""The line item ID.""" """The line item ID."""
journal_entry_id = db.Column(db.Integer, journal_entry_id: Mapped[int] \
db.ForeignKey(JournalEntry.id, = mapped_column(db.ForeignKey(JournalEntry.id, onupdate="CASCADE",
onupdate="CASCADE", ondelete="CASCADE"))
ondelete="CASCADE"),
nullable=False)
"""The journal entry ID.""" """The journal entry ID."""
journal_entry = db.relationship(JournalEntry, back_populates="line_items") journal_entry: Mapped[JournalEntry] \
= db.relationship(back_populates="line_items")
"""The journal entry.""" """The journal entry."""
is_debit = db.Column(db.Boolean, nullable=False) is_debit: Mapped[bool]
"""True for a debit line item, or False for a credit line item.""" """True for a debit line item, or False for a credit line item."""
no = db.Column(db.Integer, nullable=False) no: Mapped[int]
"""The line item number under the journal entry and debit or credit.""" """The line item number under the journal entry and debit or credit."""
original_line_item_id = db.Column(db.Integer, original_line_item_id: Mapped[int | None] \
db.ForeignKey(id, onupdate="CASCADE"), = mapped_column(db.ForeignKey(id, onupdate="CASCADE"))
nullable=True)
"""The ID of the original line item.""" """The ID of the original line item."""
original_line_item = db.relationship("JournalEntryLineItem", original_line_item: Mapped[JournalEntryLineItem | None] \
remote_side=id, passive_deletes=True) = db.relationship(remote_side=id, passive_deletes=True)
"""The original line item.""" """The original line item."""
currency_code = db.Column(db.String, currency_code: Mapped[str] \
db.ForeignKey(Currency.code, onupdate="CASCADE"), = mapped_column(db.ForeignKey(Currency.code, onupdate="CASCADE"))
nullable=False)
"""The currency code.""" """The currency code."""
currency = db.relationship(Currency, back_populates="line_items") currency: Mapped[Currency] = db.relationship(back_populates="line_items")
"""The currency.""" """The currency."""
account_id = db.Column(db.Integer, account_id: Mapped[int] \
db.ForeignKey(Account.id, = mapped_column(db.ForeignKey(Account.id, onupdate="CASCADE"))
onupdate="CASCADE"),
nullable=False)
"""The account ID.""" """The account ID."""
account = db.relationship(Account, back_populates="line_items", lazy=False) account: Mapped[Account] \
= db.relationship(back_populates="line_items", lazy=False)
"""The account.""" """The account."""
description = db.Column(db.String, nullable=True) description: Mapped[str | None]
"""The description.""" """The description."""
amount = db.Column(db.Numeric(14, 2), nullable=False) amount: Mapped[Decimal] = mapped_column(db.Numeric(14, 2))
"""The amount.""" """The amount."""
def __str__(self) -> str: def __str__(self) -> str:
@ -747,13 +739,13 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__debit") return getattr(self, "__debit")
@debit.setter @debit.setter
def debit(self, debit: Decimal | None) -> None: def debit(self, value: Decimal | None) -> None:
"""Sets the debit amount. """Sets the debit amount.
:param debit: The debit amount. :param value: The debit amount.
:return: None. :return: None.
""" """
setattr(self, "__debit", debit) setattr(self, "__debit", value)
@property @property
def credit(self) -> Decimal | None: def credit(self) -> Decimal | None:
@ -766,13 +758,13 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__credit") return getattr(self, "__credit")
@credit.setter @credit.setter
def credit(self, credit: Decimal | None) -> None: def credit(self, value: Decimal | None) -> None:
"""Sets the credit amount. """Sets the credit amount.
:param credit: The credit amount. :param value: The credit amount.
:return: None. :return: None.
""" """
setattr(self, "__credit", credit) setattr(self, "__credit", value)
@property @property
def net_balance(self) -> Decimal: def net_balance(self) -> Decimal:
@ -787,42 +779,42 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__net_balance") return getattr(self, "__net_balance")
@net_balance.setter @net_balance.setter
def net_balance(self, net_balance: Decimal) -> None: def net_balance(self, value: Decimal) -> None:
"""Sets the net balance. """Sets the net balance.
:param net_balance: The net balance. :param value: The net balance.
:return: None. :return: None.
""" """
setattr(self, "__net_balance", net_balance) setattr(self, "__net_balance", value)
@property @property
def balance(self) -> Decimal: def balance(self) -> Decimal:
"""Returns the net balance. """Returns the balance.
:return: The net balance. :return: The balance.
""" """
if not hasattr(self, "__balance"): if not hasattr(self, "__balance"):
setattr(self, "__balance", Decimal("0")) setattr(self, "__balance", Decimal("0"))
return getattr(self, "__balance") return getattr(self, "__balance")
@balance.setter @balance.setter
def balance(self, balance: Decimal) -> None: def balance(self, value: Decimal) -> None:
"""Sets the net balance. """Sets the balance.
:param balance: The net balance. :param value: The balance.
:return: None. :return: None.
""" """
setattr(self, "__balance", balance) setattr(self, "__balance", value)
@property @property
def offsets(self) -> list[t.Self]: def offsets(self) -> list[Self]:
"""Returns the offset items. """Returns the offset items.
:return: The offset items. :return: The offset items.
""" """
if not hasattr(self, "__offsets"): if not hasattr(self, "__offsets"):
cls: t.Type[t.Self] = self.__class__ cls: Type[Self] = self.__class__
offsets: list[t.Self] = cls.query.join(JournalEntry)\ offsets: list[Self] = cls.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id == self.id)\ .filter(JournalEntryLineItem.original_line_item_id == self.id)\
.order_by(JournalEntry.date, JournalEntry.no, .order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all() cls.is_debit, cls.no).all()
@ -840,17 +832,16 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__is_offset") return getattr(self, "__is_offset")
@is_offset.setter @is_offset.setter
def is_offset(self, is_offset: bool) -> None: def is_offset(self, value: bool) -> None:
"""Sets whether the line item is an offset. """Sets whether the line item is an offset.
:param is_offset: True if the line item is an offset, or False :param value: True if the line item is an offset, or False otherwise.
otherwise.
:return: None. :return: None.
""" """
setattr(self, "__is_offset", is_offset) setattr(self, "__is_offset", value)
@property @property
def match(self) -> t.Self | None: def match(self) -> Self | None:
"""Returns the match of the line item. """Returns the match of the line item.
:return: The match of the line item. :return: The match of the line item.
@ -860,13 +851,13 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__match") return getattr(self, "__match")
@match.setter @match.setter
def match(self, match: t.Self) -> None: def match(self, value: Self) -> None:
"""Sets the match of the line item. """Sets the match of the line item.
:param match: The matcho of the line item. :param value: The matcho of the line item.
:return: None. :return: None.
""" """
setattr(self, "__match", match) setattr(self, "__match", value)
@property @property
def query_values(self) -> list[str]: def query_values(self) -> list[str]:
@ -891,27 +882,25 @@ class Option(db.Model):
"""An option.""" """An option."""
__tablename__ = "accounting_options" __tablename__ = "accounting_options"
"""The table name.""" """The table name."""
name = db.Column(db.String, nullable=False, primary_key=True) name: Mapped[str] = mapped_column(primary_key=True)
"""The name.""" """The name."""
value = db.Column(db.Text, nullable=False) value: Mapped[str] = mapped_column(db.Text)
"""The option value.""" """The option value."""
created_at = db.Column(db.DateTime(timezone=True), nullable=False, created_at: Mapped[dt.datetime] \
server_default=db.func.now()) = mapped_column(db.DateTime(timezone=True),
"""The time of creation.""" server_default=db.func.now())
created_by_id = db.Column(db.Integer, """The date and time when this record was created."""
db.ForeignKey(user_pk_column, created_by_id: Mapped[int] \
onupdate="CASCADE"), = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
nullable=False) """The ID of the user who created the record."""
"""The ID of the creator.""" created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
created_by = db.relationship(user_cls, foreign_keys=created_by_id) """The user who created the record."""
"""The creator.""" updated_at: Mapped[dt.datetime] \
updated_at = db.Column(db.DateTime(timezone=True), nullable=False, = mapped_column(db.DateTime(timezone=True),
server_default=db.func.now()) server_default=db.func.now())
"""The time of last update.""" """The date and time when this record was last updated."""
updated_by_id = db.Column(db.Integer, updated_by_id: Mapped[int] \
db.ForeignKey(user_pk_column, = mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
onupdate="CASCADE"), """The ID of the last user who updated the record."""
nullable=False) updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The ID of the updator.""" """The last user who updated the record."""
updated_by = db.relationship(user_cls, foreign_keys=updated_by_id)
"""The updator."""

View File

@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com). 2021/9/16 by imacat (imacat@nanoparma.com).
""" """
import typing as t import datetime as dt
from datetime import date from collections.abc import Callable
from accounting.models import JournalEntry from accounting.models import JournalEntry
from .period import Period from .period import Period
@ -32,13 +32,13 @@ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
class PeriodChooser: class PeriodChooser:
"""The period chooser.""" """The period chooser."""
def __init__(self, get_url: t.Callable[[Period], str]): def __init__(self, get_url: Callable[[Period], str]):
"""Constructs a period chooser. """Constructs a period chooser.
:param get_url: The callback to return the URL of the current report in :param get_url: The callback to return the URL of the current report in
a period. a period.
""" """
self.__get_url: t.Callable[[Period], str] = get_url self.__get_url: Callable[[Period], str] = get_url
"""The callback to return the URL of the current report in a period.""" """The callback to return the URL of the current report in a period."""
# Shortcut periods # Shortcut periods
@ -63,10 +63,10 @@ class PeriodChooser:
first: JournalEntry | None \ first: JournalEntry | None \
= JournalEntry.query.order_by(JournalEntry.date).first() = JournalEntry.query.order_by(JournalEntry.date).first()
start: date | None = None if first is None else first.date start: dt.date | None = None if first is None else first.date
# Attributes # Attributes
self.data_start: date | None = start self.data_start: dt.date | None = start
"""The start of the data.""" """The start of the data."""
self.has_data: bool = start is not None self.has_data: bool = start is not None
"""Whether there is any data.""" """Whether there is any data."""
@ -80,8 +80,8 @@ class PeriodChooser:
"""The available years.""" """The available years."""
if self.has_data: if self.has_data:
today: date = date.today() today: dt.date = dt.date.today()
self.has_last_month = start < date(today.year, today.month, 1) self.has_last_month = start < dt.date(today.year, today.month, 1)
self.has_last_year = start.year < today.year self.has_last_year = start.year < today.year
self.has_yesterday = start < today self.has_yesterday = start < today
if start.year < today.year - 1: if start.year < today.year - 1:

View File

@ -17,12 +17,12 @@
"""The period description composer. """The period description composer.
""" """
from datetime import date, timedelta import datetime as dt
from accounting.locale import gettext from accounting.locale import gettext
def get_desc(start: date | None, end: date | None) -> str: def get_desc(start: dt.date | None, end: dt.date | None) -> str:
"""Returns the period description. """Returns the period description.
:param start: The start of the period. :param start: The start of the period.
@ -46,7 +46,7 @@ def get_desc(start: date | None, end: date | None) -> str:
return __get_day_desc(start, end) return __get_day_desc(start, end)
def __get_since_desc(start: date) -> str: def __get_since_desc(start: dt.date) -> str:
"""Returns the description without the end day. """Returns the description without the end day.
:param start: The start of the period. :param start: The start of the period.
@ -67,7 +67,7 @@ def __get_since_desc(start: date) -> str:
return gettext("since %(start)s", start=get_start_desc()) return gettext("since %(start)s", start=get_start_desc())
def __get_until_desc(end: date) -> str: def __get_until_desc(end: dt.date) -> str:
"""Returns the description without the start day. """Returns the description without the start day.
:param end: The end of the period. :param end: The end of the period.
@ -81,14 +81,14 @@ def __get_until_desc(end: date) -> str:
""" """
if end.month == 12 and end.day == 31: if end.month == 12 and end.day == 31:
return str(end.year) return str(end.year)
if (end + timedelta(days=1)).day == 1: if (end + dt.timedelta(days=1)).day == 1:
return __format_month(end) return __format_month(end)
return __format_day(end) return __format_day(end)
return gettext("until %(end)s", end=get_end_desc()) return gettext("until %(end)s", end=get_end_desc())
def __get_year_desc(start: date, end: date) -> str: def __get_year_desc(start: dt.date, end: dt.date) -> str:
"""Returns the description as a year range. """Returns the description as a year range.
:param start: The start of the period. :param start: The start of the period.
@ -105,7 +105,7 @@ def __get_year_desc(start: date, end: date) -> str:
return __get_from_to_desc(start_text, str(end.year)) return __get_from_to_desc(start_text, str(end.year))
def __get_month_desc(start: date, end: date) -> str: def __get_month_desc(start: dt.date, end: dt.date) -> str:
"""Returns the description as a month range. """Returns the description as a month range.
:param start: The start of the period. :param start: The start of the period.
@ -113,7 +113,7 @@ def __get_month_desc(start: date, end: date) -> str:
:return: The description as a month range. :return: The description as a month range.
:raise ValueError: The period is not a month range. :raise ValueError: The period is not a month range.
""" """
if start.day != 1 or (end + timedelta(days=1)).day != 1: if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
raise ValueError raise ValueError
start_text: str = __format_month(start) start_text: str = __format_month(start)
if start.year == end.year and start.month == end.month: if start.year == end.year and start.month == end.month:
@ -123,7 +123,7 @@ def __get_month_desc(start: date, end: date) -> str:
return __get_from_to_desc(start_text, __format_month(end)) return __get_from_to_desc(start_text, __format_month(end))
def __get_day_desc(start: date, end: date) -> str: def __get_day_desc(start: dt.date, end: dt.date) -> str:
"""Returns the description as a day range. """Returns the description as a day range.
:param start: The start of the period. :param start: The start of the period.
@ -142,7 +142,7 @@ def __get_day_desc(start: date, end: date) -> str:
return __get_from_to_desc(start_text, __format_day(end)) return __get_from_to_desc(start_text, __format_day(end))
def __format_month(month: date) -> str: def __format_month(month: dt.date) -> str:
"""Formats a month. """Formats a month.
:param month: The month. :param month: The month.
@ -151,7 +151,7 @@ def __format_month(month: date) -> str:
return f"{month.year}/{month.month}" return f"{month.year}/{month.month}"
def __format_day(day: date) -> str: def __format_day(day: dt.date) -> str:
"""Formats a day. """Formats a day.
:param day: The day. :param day: The day.

View File

@ -18,14 +18,14 @@
""" """
import calendar import calendar
from datetime import date import datetime as dt
def month_end(day: date) -> date: def month_end(day: dt.date) -> dt.date:
"""Returns the end day of month for a date. """Returns the end day of month for a date.
:param day: The date. :param day: The date.
:return: The end day of the month of that day. :return: The end day of the month of that day.
""" """
last_day: int = calendar.monthrange(day.year, day.month)[1] last_day: int = calendar.monthrange(day.year, day.month)[1]
return date(day.year, day.month, last_day) return dt.date(day.year, day.month, last_day)

View File

@ -18,9 +18,10 @@
""" """
import calendar import calendar
import datetime as dt
import re import re
import typing as t from collections.abc import Callable
from datetime import date from typing import Type
from .period import Period from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@ -39,7 +40,7 @@ def get_period(spec: str | None = None) -> Period:
""" """
if spec is None: if spec is None:
return ThisMonth() return ThisMonth()
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = { named_periods: dict[str, Type[Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(), "this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(), "last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(), "since-last-month": lambda: SinceLastMonth(),
@ -57,7 +58,7 @@ def get_period(spec: str | None = None) -> Period:
return Period(start, end) return Period(start, end)
def __parse_spec(text: str) -> tuple[date | None, date | None]: def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
"""Parses the period specification. """Parses the period specification.
:param text: The period specification. :param text: The period specification.
@ -84,7 +85,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
raise ValueError raise ValueError
def __get_start(year: str, month: str | None, day: str | None) -> date: def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
"""Returns the start of the period from the date representation. """Returns the start of the period from the date representation.
:param year: The year. :param year: The year.
@ -94,13 +95,13 @@ def __get_start(year: str, month: str | None, day: str | None) -> date:
:raise ValueError: When the date is invalid. :raise ValueError: When the date is invalid.
""" """
if day is not None: if day is not None:
return date(int(year), int(month), int(day)) return dt.date(int(year), int(month), int(day))
if month is not None: if month is not None:
return date(int(year), int(month), 1) return dt.date(int(year), int(month), 1)
return date(int(year), 1, 1) return dt.date(int(year), 1, 1)
def __get_end(year: str, month: str | None, day: str | None) -> date: def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
"""Returns the end of the period from the date representation. """Returns the end of the period from the date representation.
:param year: The year. :param year: The year.
@ -110,10 +111,10 @@ def __get_end(year: str, month: str | None, day: str | None) -> date:
:raise ValueError: When the date is invalid. :raise ValueError: When the date is invalid.
""" """
if day is not None: if day is not None:
return date(int(year), int(month), int(day)) return dt.date(int(year), int(month), int(day))
if month is not None: if month is not None:
year_n: int = int(year) year_n: int = int(year)
month_n: int = int(month) month_n: int = int(month)
day_n: int = calendar.monthrange(year_n, month_n)[1] day_n: int = calendar.monthrange(year_n, month_n)[1]
return date(year_n, month_n, day_n) return dt.date(year_n, month_n, day_n)
return date(int(year), 12, 31) return dt.date(int(year), 12, 31)

View File

@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
2021/9/16 by imacat (imacat@nanoparma.com). 2021/9/16 by imacat (imacat@nanoparma.com).
""" """
import typing as t import datetime as dt
from datetime import date, timedelta from typing import Self
from .description import get_desc from .description import get_desc
from .month_end import month_end from .month_end import month_end
@ -31,15 +31,15 @@ from .specification import get_spec
class Period: class Period:
"""A date period.""" """A date period."""
def __init__(self, start: date | None, end: date | None): def __init__(self, start: dt.date | None, end: dt.date | None):
"""Constructs a new date period. """Constructs a new date period.
:param start: The start date, or None from the very beginning. :param start: The start date, or None from the very beginning.
:param end: The end date, or None till no end. :param end: The end date, or None till no end.
""" """
self.start: date | None = start self.start: dt.date | None = start
"""The start of the period.""" """The start of the period."""
self.end: date | None = end self.end: dt.date | None = end
"""The end of the period.""" """The end of the period."""
self.is_default: bool = False self.is_default: bool = False
"""Whether the is the default period.""" """Whether the is the default period."""
@ -95,8 +95,8 @@ class Period:
self.is_a_month = self.start.day == 1 \ self.is_a_month = self.start.day == 1 \
and self.end == month_end(self.start) and self.end == month_end(self.start)
self.is_type_month = self.is_a_month self.is_type_month = self.is_a_month
self.is_a_year = self.start == date(self.start.year, 1, 1) \ self.is_a_year = self.start == dt.date(self.start.year, 1, 1) \
and self.end == date(self.start.year, 12, 31) and self.end == dt.date(self.start.year, 12, 31)
self.is_a_day = self.start == self.end self.is_a_day = self.start == self.end
def is_year(self, year: int) -> bool: def is_year(self, year: int) -> bool:
@ -119,11 +119,11 @@ class Period:
and not self.is_a_day and not self.is_a_day
@property @property
def before(self) -> t.Self | None: def before(self) -> Self | None:
"""Returns the period before this period. """Returns the period before this period.
:return: The period before this period. :return: The period before this period.
""" """
if self.start is None: if self.start is None:
return None return None
return Period(None, self.start - timedelta(days=1)) return Period(None, self.start - dt.timedelta(days=1))

View File

@ -17,7 +17,7 @@
"""The named shortcut periods. """The named shortcut periods.
""" """
from datetime import date, timedelta import datetime as dt
from accounting.locale import gettext from accounting.locale import gettext
from .month_end import month_end from .month_end import month_end
@ -27,8 +27,8 @@ from .period import Period
class ThisMonth(Period): class ThisMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
this_month_start: date = date(today.year, today.month, 1) this_month_start: dt.date = dt.date(today.year, today.month, 1)
super().__init__(this_month_start, month_end(today)) super().__init__(this_month_start, month_end(today))
self.is_default = True self.is_default = True
self.is_this_month = True self.is_this_month = True
@ -43,13 +43,13 @@ class ThisMonth(Period):
class LastMonth(Period): class LastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
year = year - 1 year = year - 1
month = 12 month = 12
start: date = date(year, month, 1) start: dt.date = dt.date(year, month, 1)
super().__init__(start, month_end(start)) super().__init__(start, month_end(start))
self.is_last_month = True self.is_last_month = True
@ -63,13 +63,13 @@ class LastMonth(Period):
class SinceLastMonth(Period): class SinceLastMonth(Period):
"""The period of this month.""" """The period of this month."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
year: int = today.year year: int = today.year
month: int = today.month - 1 month: int = today.month - 1
if month < 1: if month < 1:
year = year - 1 year = year - 1
month = 12 month = 12
start: date = date(year, month, 1) start: dt.date = dt.date(year, month, 1)
super().__init__(start, None) super().__init__(start, None)
self.is_since_last_month = True self.is_since_last_month = True
@ -82,9 +82,9 @@ class SinceLastMonth(Period):
class ThisYear(Period): class ThisYear(Period):
"""The period of this year.""" """The period of this year."""
def __init__(self): def __init__(self):
year: int = date.today().year year: int = dt.date.today().year
start: date = date(year, 1, 1) start: dt.date = dt.date(year, 1, 1)
end: date = date(year, 12, 31) end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end) super().__init__(start, end)
self.is_this_year = True self.is_this_year = True
@ -97,9 +97,9 @@ class ThisYear(Period):
class LastYear(Period): class LastYear(Period):
"""The period of last year.""" """The period of last year."""
def __init__(self): def __init__(self):
year: int = date.today().year year: int = dt.date.today().year
start: date = date(year - 1, 1, 1) start: dt.date = dt.date(year - 1, 1, 1)
end: date = date(year - 1, 12, 31) end: dt.date = dt.date(year - 1, 12, 31)
super().__init__(start, end) super().__init__(start, end)
self.is_last_year = True self.is_last_year = True
@ -112,7 +112,7 @@ class LastYear(Period):
class Today(Period): class Today(Period):
"""The period of today.""" """The period of today."""
def __init__(self): def __init__(self):
today: date = date.today() today: dt.date = dt.date.today()
super().__init__(today, today) super().__init__(today, today)
self.is_today = True self.is_today = True
@ -125,7 +125,7 @@ class Today(Period):
class Yesterday(Period): class Yesterday(Period):
"""The period of yesterday.""" """The period of yesterday."""
def __init__(self): def __init__(self):
yesterday: date = date.today() - timedelta(days=1) yesterday: dt.date = dt.date.today() - dt.timedelta(days=1)
super().__init__(yesterday, yesterday) super().__init__(yesterday, yesterday)
self.is_yesterday = True self.is_yesterday = True
@ -163,6 +163,6 @@ class YearPeriod(Period):
:param year: The year. :param year: The year.
""" """
start: date = date(year, 1, 1) start: dt.date = dt.date(year, 1, 1)
end: date = date(year, 12, 31) end: dt.date = dt.date(year, 12, 31)
super().__init__(start, end) super().__init__(start, end)

View File

@ -17,10 +17,10 @@
"""The period specification composer. """The period specification composer.
""" """
from datetime import date, timedelta import datetime as dt
def get_spec(start: date | None, end: date | None) -> str: def get_spec(start: dt.date | None, end: dt.date | None) -> str:
"""Returns the period specification. """Returns the period specification.
:param start: The start of the period. :param start: The start of the period.
@ -44,7 +44,7 @@ def get_spec(start: date | None, end: date | None) -> str:
return __get_day_spec(start, end) return __get_day_spec(start, end)
def __get_since_spec(start: date) -> str: def __get_since_spec(start: dt.date) -> str:
"""Returns the period specification without the end day. """Returns the period specification without the end day.
:param start: The start of the period. :param start: The start of the period.
@ -57,7 +57,7 @@ def __get_since_spec(start: date) -> str:
return start.strftime("%Y-%m-%d-") return start.strftime("%Y-%m-%d-")
def __get_until_spec(end: date) -> str: def __get_until_spec(end: dt.date) -> str:
"""Returns the period specification without the start day. """Returns the period specification without the start day.
:param end: The end of the period. :param end: The end of the period.
@ -65,12 +65,12 @@ def __get_until_spec(end: date) -> str:
""" """
if end.month == 12 and end.day == 31: if end.month == 12 and end.day == 31:
return end.strftime("-%Y") return end.strftime("-%Y")
if (end + timedelta(days=1)).day == 1: if (end + dt.timedelta(days=1)).day == 1:
return end.strftime("-%Y-%m") return end.strftime("-%Y-%m")
return end.strftime("-%Y-%m-%d") return end.strftime("-%Y-%m-%d")
def __get_year_spec(start: date, end: date) -> str: def __get_year_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification as a year range. """Returns the period specification as a year range.
:param start: The start of the period. :param start: The start of the period.
@ -88,7 +88,7 @@ def __get_year_spec(start: date, end: date) -> str:
return f"{start_spec}-{end_spec}" return f"{start_spec}-{end_spec}"
def __get_month_spec(start: date, end: date) -> str: def __get_month_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification as a month range. """Returns the period specification as a month range.
:param start: The start of the period. :param start: The start of the period.
@ -96,7 +96,7 @@ def __get_month_spec(start: date, end: date) -> str:
:return: The period specification as a month range. :return: The period specification as a month range.
:raise ValueError: The period is not a month range. :raise ValueError: The period is not a month range.
""" """
if start.day != 1 or (end + timedelta(days=1)).day != 1: if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
raise ValueError raise ValueError
start_spec: str = start.strftime("%Y-%m") start_spec: str = start.strftime("%Y-%m")
if start.year == end.year and start.month == end.month: if start.year == end.year and start.month == end.month:
@ -105,7 +105,7 @@ def __get_month_spec(start: date, end: date) -> str:
return f"{start_spec}-{end_spec}" return f"{start_spec}-{end_spec}"
def __get_day_spec(start: date, end: date) -> str: def __get_day_spec(start: dt.date, end: dt.date) -> str:
"""Returns the period specification as a day range. """Returns the period specification as a day range.
:param start: The start of the period. :param start: The start of the period.

View File

@ -17,7 +17,7 @@
"""The income and expenses log. """The income and expenses log.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -37,7 +37,6 @@ from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import income_expenses_url from accounting.report.utils.urls import income_expenses_url
from accounting.utils.cast import be
from accounting.utils.current_account import CurrentAccount from accounting.utils.current_account import CurrentAccount
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -54,7 +53,7 @@ class ReportLineItem:
"""Whether this is the brought-forward line item.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total line item.""" """Whether this is the total line item."""
self.date: date | None = None self.date: dt.date | None = None
"""The date.""" """The date."""
self.account: Account | None = None self.account: Account | None = None
"""The account.""" """The account."""
@ -122,8 +121,7 @@ class LineItemCollector:
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func)\ select: sa.Select = sa.Select(balance_func)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.__currency.code,
== self.__currency.code),
self.__account_condition, self.__account_condition,
JournalEntry.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
@ -215,7 +213,7 @@ class LineItemCollector:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: date | str | None, def __init__(self, date: dt.date | str | None,
account: str | None, account: str | None,
description: str | None, description: str | None,
income: str | Decimal | None, income: str | Decimal | None,
@ -224,7 +222,7 @@ class CSVRow(BaseCSVRow):
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param journal_entry_date: The journal entry date. :param date: The journal entry date.
:param account: The account. :param account: The account.
:param description: The description. :param description: The description.
:param income: The income. :param income: The income.
@ -232,7 +230,7 @@ class CSVRow(BaseCSVRow):
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = journal_entry_date self.date: dt.date | str | None = date
"""The date.""" """The date."""
self.account: str | None = account self.account: str | None = account
"""The account.""" """The account."""
@ -347,8 +345,7 @@ class PageParams(BasePageParams):
self.account.id == 0)] self.account.id == 0)]
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.join(Account)\ .join(Account)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.currency.code,
== self.currency.code),
CurrentAccount.sql_condition())\ CurrentAccount.sql_condition())\
.group_by(JournalEntryLineItem.account_id) .group_by(JournalEntryLineItem.account_id)
options.extend([OptionLink(str(x), options.extend([OptionLink(str(x),

View File

@ -17,7 +17,7 @@
"""The journal. """The journal.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -67,7 +67,7 @@ class ReportLineItem:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: str | date, def __init__(self, journal_entry_date: str | dt.date,
currency: str, currency: str,
account: str, account: str,
description: str | None, description: str | None,
@ -84,7 +84,7 @@ class CSVRow(BaseCSVRow):
:param credit: The credit amount. :param credit: The credit amount.
:param note: The note. :param note: The note.
""" """
self.date: str | date = journal_entry_date self.date: str | dt.date = journal_entry_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""

View File

@ -17,7 +17,7 @@
"""The ledger. """The ledger.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -37,7 +37,6 @@ from accounting.report.utils.option_link import OptionLink
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.report.utils.urls import ledger_url from accounting.report.utils.urls import ledger_url
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
@ -53,7 +52,7 @@ class ReportLineItem:
"""Whether this is the brought-forward line item.""" """Whether this is the brought-forward line item."""
self.is_total: bool = False self.is_total: bool = False
"""Whether this is the total line item.""" """Whether this is the total line item."""
self.date: date | None = None self.date: dt.date | None = None
"""The date.""" """The date."""
self.description: str | None = None self.description: str | None = None
"""The description.""" """The description."""
@ -118,10 +117,8 @@ class LineItemCollector:
(JournalEntryLineItem.is_debit, JournalEntryLineItem.amount), (JournalEntryLineItem.is_debit, JournalEntryLineItem.amount),
else_=-JournalEntryLineItem.amount)) else_=-JournalEntryLineItem.amount))
select: sa.Select = sa.Select(balance_func).join(JournalEntry)\ select: sa.Select = sa.Select(balance_func).join(JournalEntry)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.__currency.code,
== self.__currency.code), JournalEntryLineItem.account_id == self.__account.id,
be(JournalEntryLineItem.account_id
== self.__account.id),
JournalEntry.date < self.__period.start) JournalEntry.date < self.__period.start)
balance: int | None = db.session.scalar(select) balance: int | None = db.session.scalar(select)
if balance is None: if balance is None:
@ -199,7 +196,7 @@ class LineItemCollector:
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: date | str | None, def __init__(self, date: dt.date | str | None,
description: str | None, description: str | None,
debit: str | Decimal | None, debit: str | Decimal | None,
credit: str | Decimal | None, credit: str | Decimal | None,
@ -207,14 +204,14 @@ class CSVRow(BaseCSVRow):
note: str | None): note: str | None):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
:param journal_entry_date: The journal entry date. :param date: The journal entry date.
:param description: The description. :param description: The description.
:param debit: The debit amount. :param debit: The debit amount.
:param credit: The credit amount. :param credit: The credit amount.
:param balance: The balance. :param balance: The balance.
:param note: The note. :param note: The note.
""" """
self.date: date | str | None = journal_entry_date self.date: dt.date | str | None = date
"""The date.""" """The date."""
self.description: str | None = description self.description: str | None = description
"""The description.""" """The description."""
@ -313,8 +310,7 @@ class PageParams(BasePageParams):
:return: The account options. :return: The account options.
""" """
in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\ in_use: sa.Select = sa.Select(JournalEntryLineItem.account_id)\
.filter(be(JournalEntryLineItem.currency_code .filter(JournalEntryLineItem.currency_code == self.currency.code)\
== self.currency.code))\
.group_by(JournalEntryLineItem.account_id) .group_by(JournalEntryLineItem.account_id)
return [OptionLink(str(x), ledger_url(self.currency, x, self.period), return [OptionLink(str(x), ledger_url(self.currency, x, self.period),
x.id == self.account.id) x.id == self.account.id)

View File

@ -17,7 +17,7 @@
"""The search. """The search.
""" """
from datetime import datetime import datetime as dt
from decimal import Decimal from decimal import Decimal
import sqlalchemy as sa import sqlalchemy as sa
@ -32,7 +32,6 @@ from accounting.report.utils.base_report import BaseReport
from accounting.report.utils.csv_export import csv_download from accounting.report.utils.csv_export import csv_download
from accounting.report.utils.report_chooser import ReportChooser from accounting.report.utils.report_chooser import ReportChooser
from accounting.report.utils.report_type import ReportType from accounting.report.utils.report_type import ReportType
from accounting.utils.cast import be
from accounting.utils.pagination import Pagination from accounting.utils.pagination import Pagination
from accounting.utils.query import parse_query_keywords from accounting.utils.query import parse_query_keywords
from .journal import get_csv_rows from .journal import get_csv_rows
@ -125,41 +124,33 @@ class LineItemCollector:
""" """
conditions: list[sa.BinaryExpression] \ conditions: list[sa.BinaryExpression] \
= [JournalEntry.note.icontains(k)] = [JournalEntry.note.icontains(k)]
journal_entry_date: datetime date: dt.datetime
try: try:
journal_entry_date = datetime.strptime(k, "%Y") date = dt.datetime.strptime(k, "%Y")
conditions.append( conditions.append(
be(sa.extract("year", JournalEntry.date) sa.extract("year", JournalEntry.date) == date.year)
== journal_entry_date.year))
except ValueError: except ValueError:
pass pass
try: try:
journal_entry_date = datetime.strptime(k, "%Y/%m") date = dt.datetime.strptime(k, "%Y/%m")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("year", JournalEntry.date) sa.extract("year", JournalEntry.date) == date.year,
== journal_entry_date.year, sa.extract("month", JournalEntry.date) == date.month))
sa.extract("month", JournalEntry.date)
== journal_entry_date.month))
except ValueError: except ValueError:
pass pass
try: try:
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d") date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("month", JournalEntry.date) sa.extract("month", JournalEntry.date) == date.month,
== journal_entry_date.month, sa.extract("day", JournalEntry.date) == date.day))
sa.extract("day", JournalEntry.date)
== journal_entry_date.day))
except ValueError: except ValueError:
pass pass
try: try:
journal_entry_date = datetime.strptime(k, "%Y/%m/%d") date = dt.datetime.strptime(k, "%Y/%m/%d")
conditions.append(sa.and_( conditions.append(sa.and_(
sa.extract("year", JournalEntry.date) sa.extract("year", JournalEntry.date) == date.year,
== journal_entry_date.year, sa.extract("month", JournalEntry.date) == date.month,
sa.extract("month", JournalEntry.date) sa.extract("day", JournalEntry.date) == date.day))
== journal_entry_date.month,
sa.extract("day", JournalEntry.date)
== journal_entry_date.day))
except ValueError: except ValueError:
pass pass
return sa.select(JournalEntry.id).filter(sa.or_(*conditions)) return sa.select(JournalEntry.id).filter(sa.or_(*conditions))

View File

@ -17,7 +17,7 @@
"""The unapplied original line items. """The unapplied original line items.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
@ -41,7 +41,7 @@ from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: str | date, currency: str, def __init__(self, journal_entry_date: str | dt.date, currency: str,
description: str | None, amount: str | Decimal, description: str | None, amount: str | Decimal,
net_balance: str | Decimal): net_balance: str | Decimal):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
:param amount: The amount. :param amount: The amount.
:param net_balance: The net balance. :param net_balance: The net balance.
""" """
self.date: str | date = journal_entry_date self.date: str | dt.date = journal_entry_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""
@ -64,7 +64,7 @@ class CSVRow(BaseCSVRow):
"""The net balance.""" """The net balance."""
@property @property
def values(self) -> list[str | date | Decimal | None]: def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row. """Returns the values of the row.
:return: The values of the row. :return: The values of the row.

View File

@ -17,7 +17,7 @@
"""The accounts with unapplied original line items. """The accounts with unapplied original line items.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
"""The number of unapplied original line items.""" """The number of unapplied original line items."""
@property @property
def values(self) -> list[str | date | Decimal | None]: def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row. """Returns the values of the row.
:return: The values of the row. :return: The values of the row.
@ -143,7 +143,7 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport):
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = f"unapplied-accounts.csv" filename: str = "unapplied-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts)) return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str: def html(self) -> str:

View File

@ -17,7 +17,7 @@
"""The unmatched offsets. """The unmatched offsets.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
@ -40,7 +40,7 @@ from accounting.utils.pagination import Pagination
class CSVRow(BaseCSVRow): class CSVRow(BaseCSVRow):
"""A row in the CSV.""" """A row in the CSV."""
def __init__(self, journal_entry_date: str | date, currency: str, def __init__(self, journal_entry_date: str | dt.date, currency: str,
description: str | None, debit: str | Decimal, description: str | None, debit: str | Decimal,
credit: str | Decimal, balance: str | Decimal): credit: str | Decimal, balance: str | Decimal):
"""Constructs a row in the CSV. """Constructs a row in the CSV.
@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
:param credit: The credit amount. :param credit: The credit amount.
:param balance: The balance. :param balance: The balance.
""" """
self.date: str | date = journal_entry_date self.date: str | dt.date = journal_entry_date
"""The date.""" """The date."""
self.currency: str = currency self.currency: str = currency
"""The currency.""" """The currency."""
@ -66,7 +66,7 @@ class CSVRow(BaseCSVRow):
"""The balance.""" """The balance."""
@property @property
def values(self) -> list[str | date | Decimal | None]: def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row. """Returns the values of the row.
:return: The values of the row. :return: The values of the row.

View File

@ -17,7 +17,7 @@
"""The accounts with unmatched offsets. """The accounts with unmatched offsets.
""" """
from datetime import date import datetime as dt
from decimal import Decimal from decimal import Decimal
from flask import render_template, Response from flask import render_template, Response
@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
"""The number of unapplied original line items.""" """The number of unapplied original line items."""
@property @property
def values(self) -> list[str | date | Decimal | None]: def values(self) -> list[str | dt.date | Decimal | None]:
"""Returns the values of the row. """Returns the values of the row.
:return: The values of the row. :return: The values of the row.
@ -144,7 +144,7 @@ class AccountsWithUnmatchedOffsets(BaseReport):
:return: The response of the report for download. :return: The response of the report for download.
""" """
filename: str = f"unapplied-accounts.csv" filename: str = "unmatched-accounts.csv"
return csv_download(filename, get_csv_rows(self.__accounts)) return csv_download(filename, get_csv_rows(self.__accounts))
def html(self) -> str: def html(self) -> str:

View File

@ -17,8 +17,9 @@
"""The page parameters of a report. """The page parameters of a report.
""" """
import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Type
from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \ from urllib.parse import urlparse, ParseResult, parse_qsl, urlencode, \
urlunparse urlunparse
@ -52,7 +53,7 @@ class BasePageParams(ABC):
""" """
@property @property
def journal_entry_types(self) -> t.Type[JournalEntryType]: def journal_entry_types(self) -> Type[JournalEntryType]:
"""Returns the journal entry types. """Returns the journal entry types.
:return: The journal entry types. :return: The journal entry types.
@ -72,7 +73,7 @@ class BasePageParams(ABC):
return urlunparse(parts) return urlunparse(parts)
@staticmethod @staticmethod
def _get_currency_options(get_url: t.Callable[[Currency], str], def _get_currency_options(get_url: Callable[[Currency], str],
active_currency: Currency) -> list[OptionLink]: active_currency: Currency) -> list[OptionLink]:
"""Returns the currency options. """Returns the currency options.

View File

@ -18,8 +18,8 @@
""" """
import csv import csv
import datetime as dt
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import timedelta, date
from decimal import Decimal from decimal import Decimal
from io import StringIO from io import StringIO
from urllib.parse import quote from urllib.parse import quote
@ -77,7 +77,7 @@ def period_spec(period: Period) -> str:
return f"{start}-{end}" return f"{start}-{end}"
def __get_start_str(start: date | None) -> str | None: def __get_start_str(start: dt.date | None) -> str | None:
"""Returns the string representation of the start date. """Returns the string representation of the start date.
:param start: The start date. :param start: The start date.
@ -93,7 +93,7 @@ def __get_start_str(start: date | None) -> str | None:
return start.strftime("%Y%m%d") return start.strftime("%Y%m%d")
def __get_end_str(end: date | None) -> str | None: def __get_end_str(end: dt.date | None) -> str | None:
"""Returns the string representation of the end date. """Returns the string representation of the end date.
:param end: The end date. :param end: The end date.
@ -104,6 +104,6 @@ def __get_end_str(end: date | None) -> str | None:
return None return None
if end.month == 12 and end.day == 31: if end.month == 12 and end.day == 31:
return str(end.year) return str(end.year)
if (end + timedelta(days=1)).day == 1: if (end + dt.timedelta(days=1)).day == 1:
return end.strftime("%Y%m") return end.strftime("%Y%m")
return end.strftime("%Y%m%d") return end.strftime("%Y%m%d")

View File

@ -123,15 +123,13 @@ class OffsetMatcher:
.options(selectinload(JournalEntryLineItem.currency), .options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all() selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in self.line_items: for line_item in self.line_items:
line_item.is_offset = line_item.id in net_balances line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items self.unapplied = [x for x in self.line_items if not x.is_offset]
if x.is_offset]
for line_item in self.unapplied: for line_item in self.unapplied:
line_item.net_balance = line_item.amount \ line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \ if net_balances[line_item.id] is None \
else net_balances[line_item.id] else net_balances[line_item.id]
self.unmatched = [x for x in self.line_items self.unmatched = [x for x in self.line_items if x.is_offset]
if not x.is_offset]
self.__populate_accumulated_balances() self.__populate_accumulated_balances()
def __populate_accumulated_balances(self) -> None: def __populate_accumulated_balances(self) -> None:

View File

@ -21,7 +21,7 @@ This file is largely taken from the NanoParma ERP project, first written in
""" """
import re import re
import typing as t from collections.abc import Iterator
from flask_babel import LazyString from flask_babel import LazyString
@ -190,7 +190,7 @@ class ReportChooser:
self.__active_report == ReportType.UNMATCHED, self.__active_report == ReportType.UNMATCHED,
fa_icon="fa-solid fa-file-circle-question") fa_icon="fa-solid fa-file-circle-question")
def __iter__(self) -> t.Iterator[OptionLink]: def __iter__(self) -> Iterator[OptionLink]:
"""Returns the iteration of the reports. """Returns the iteration of the reports.
:return: The iteration of the reports. :return: The iteration of the reports.

View File

@ -24,7 +24,6 @@ import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import Currency, Account, JournalEntry, \ from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem JournalEntryLineItem
from accounting.utils.cast import be
from accounting.utils.offset_alias import offset_alias from accounting.utils.offset_alias import offset_alias
@ -38,17 +37,17 @@ def get_accounts_with_unapplied(currency: Currency) -> list[Account]:
net_balance: sa.Label \ net_balance: sa.Label \
= (JournalEntryLineItem.amount = (JournalEntryLineItem.amount
+ sa.func.sum(sa.case( + sa.func.sum(sa.case(
(be(offset.c.is_debit == JournalEntryLineItem.is_debit), (offset.c.is_debit == JournalEntryLineItem.is_debit,
offset.c.amount), offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
select_unapplied: sa.Select \ select_unapplied: sa.Select \
= sa.select(JournalEntryLineItem.id)\ = sa.select(JournalEntryLineItem.id)\
.join(JournalEntry).join(Account)\ .join(JournalEntry).join(Account)\
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True)\ isouter=True)\
.filter(Account.is_need_offset, .filter(Account.is_need_offset,
be(JournalEntryLineItem.currency_code == currency.code), JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)), sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"), sa.and_(Account.base_code.startswith("1"),
@ -84,17 +83,17 @@ def get_net_balances(currency: Currency, account: Account) \
net_balance: sa.Label \ net_balance: sa.Label \
= (JournalEntryLineItem.amount = (JournalEntryLineItem.amount
+ sa.func.sum(sa.case( + sa.func.sum(sa.case(
(be(offset.c.is_debit == JournalEntryLineItem.is_debit), (offset.c.is_debit == JournalEntryLineItem.is_debit,
offset.c.amount), offset.c.amount),
else_=-offset.c.amount))).label("net_balance") else_=-offset.c.amount))).label("net_balance")
select_net_balances: sa.Select \ select_net_balances: sa.Select \
= sa.select(JournalEntryLineItem.id, net_balance) \ = sa.select(JournalEntryLineItem.id, net_balance) \
.join(JournalEntry).join(Account) \ .join(JournalEntry).join(Account) \
.join(offset, be(JournalEntryLineItem.id .join(offset,
== offset.c.original_line_item_id), JournalEntryLineItem.id == offset.c.original_line_item_id,
isouter=True) \ isouter=True) \
.filter(be(Account.id == account.id), .filter(Account.id == account.id,
be(JournalEntryLineItem.currency_code == currency.code), JournalEntryLineItem.currency_code == currency.code,
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
sa.not_(JournalEntryLineItem.is_debit)), sa.not_(JournalEntryLineItem.is_debit)),
sa.and_(Account.base_code.startswith("1"), sa.and_(Account.base_code.startswith("1"),

View File

@ -22,7 +22,6 @@ import sqlalchemy as sa
from accounting import db from accounting import db
from accounting.models import Currency, Account, JournalEntry, \ from accounting.models import Currency, Account, JournalEntry, \
JournalEntryLineItem JournalEntryLineItem
from accounting.utils.cast import be
def get_accounts_with_unmatched(currency: Currency) -> list[Account]: def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
@ -38,7 +37,7 @@ def get_accounts_with_unmatched(currency: Currency) -> list[Account]:
.select_from(Account)\ .select_from(Account)\
.join(JournalEntryLineItem, isouter=True).join(JournalEntry)\ .join(JournalEntryLineItem, isouter=True).join(JournalEntry)\
.filter(Account.is_need_offset, .filter(Account.is_need_offset,
be(JournalEntryLineItem.currency_code == currency.code), JournalEntryLineItem.currency_code == currency.code,
JournalEntryLineItem.original_line_item_id.is_(None), JournalEntryLineItem.original_line_item_id.is_(None),
sa.or_(sa.and_(Account.base_code.startswith("2"), sa.or_(sa.and_(Account.base_code.startswith("2"),
JournalEntryLineItem.is_debit), JournalEntryLineItem.is_debit),

View File

@ -276,19 +276,23 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = originalLineItem.date; this.originalLineItemDate = originalLineItem.date;
this.originalLineItemText = originalLineItem.text; this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text; this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableDescriptionAccount(false); if (this.description === null) {
if (originalLineItem.description === "") { if (originalLineItem.description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
} else { } else {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
}
this.description = originalLineItem.description === ""? null: originalLineItem.description;
this.#descriptionText.innerText = originalLineItem.description;
} }
this.description = originalLineItem.description === ""? null: originalLineItem.description; this.#setEnableAccount(false);
this.#descriptionText.innerText = originalLineItem.description;
this.#accountControl.classList.add("accounting-not-empty"); this.#accountControl.classList.add("accounting-not-empty");
this.account = originalLineItem.account.copy(); this.account = originalLineItem.account.copy();
this.isAccountConfirmed = false; this.isAccountConfirmed = false;
this.#accountText.innerText = this.account.text; this.#accountText.innerText = this.account.text;
this.#amountInput.value = String(originalLineItem.netBalance); if (this.#amountInput.value === "" || new Decimal(this.#amountInput.value).greaterThan(originalLineItem.netBalance)) {
this.#amountInput.value = String(originalLineItem.netBalance);
}
this.#amountInput.max = String(originalLineItem.netBalance); this.#amountInput.max = String(originalLineItem.netBalance);
this.#amountInput.min = "0"; this.#amountInput.min = "0";
this.#validate(); this.#validate();
@ -305,7 +309,7 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = null; this.originalLineItemDate = null;
this.originalLineItemText = null; this.originalLineItemText = null;
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true); this.#setEnableAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.account = null; this.account = null;
this.isAccountConfirmed = false; this.isAccountConfirmed = false;
@ -472,12 +476,13 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = null; this.originalLineItemDate = null;
this.originalLineItemText = null; this.originalLineItemText = null;
this.#originalLineItemText.innerText = ""; this.#originalLineItemText.innerText = "";
this.#setEnableDescriptionAccount(true); this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
this.#descriptionControl.classList.remove("is-invalid"); this.#descriptionControl.classList.remove("is-invalid");
this.description = null; this.description = null;
this.#descriptionText.innerText = "" this.#descriptionText.innerText = ""
this.#descriptionError.innerText = "" this.#descriptionError.innerText = ""
this.#setEnableAccount(true);
this.#accountControl.classList.remove("accounting-not-empty"); this.#accountControl.classList.remove("accounting-not-empty");
this.#accountControl.classList.remove("is-invalid"); this.#accountControl.classList.remove("is-invalid");
this.account = null; this.account = null;
@ -511,7 +516,7 @@ class JournalEntryLineItemEditor {
this.#originalLineItemContainer.classList.remove("d-none"); this.#originalLineItemContainer.classList.remove("d-none");
this.#originalLineItemControl.classList.add("accounting-not-empty"); this.#originalLineItemControl.classList.add("accounting-not-empty");
} }
this.#setEnableDescriptionAccount(!lineItem.isMatched && this.originalLineItemId === null); this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.description = lineItem.description; this.description = lineItem.description;
if (this.description === null) { if (this.description === null) {
this.#descriptionControl.classList.remove("accounting-not-empty"); this.#descriptionControl.classList.remove("accounting-not-empty");
@ -519,6 +524,7 @@ class JournalEntryLineItemEditor {
this.#descriptionControl.classList.add("accounting-not-empty"); this.#descriptionControl.classList.add("accounting-not-empty");
} }
this.#descriptionText.innerText = this.description === null? "": this.description; this.#descriptionText.innerText = this.description === null? "": this.description;
this.#setEnableAccount(!lineItem.isMatched && this.originalLineItemId === null);
this.account = lineItem.account; this.account = lineItem.account;
this.isAccountConfirmed = true; this.isAccountConfirmed = true;
if (this.account === null) { if (this.account === null) {
@ -547,25 +553,17 @@ class JournalEntryLineItemEditor {
} }
/** /**
* Sets the enable status of the description and account. * Sets the enable status of the account.
* *
* @param isEnabled {boolean} true to enable, or false otherwise * @param isEnabled {boolean} true to enable, or false otherwise
*/ */
#setEnableDescriptionAccount(isEnabled) { #setEnableAccount(isEnabled) {
if (isEnabled) { if (isEnabled) {
this.#descriptionControl.dataset.bsToggle = "modal";
this.#descriptionControl.dataset.bsTarget = `#accounting-description-editor-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#descriptionControl.classList.remove("accounting-disabled");
this.#descriptionControl.classList.add("accounting-clickable");
this.#accountControl.dataset.bsToggle = "modal"; this.#accountControl.dataset.bsToggle = "modal";
this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`; this.#accountControl.dataset.bsTarget = `#accounting-account-selector-${this.#debitCreditSubForm.debitCredit}-modal`;
this.#accountControl.classList.remove("accounting-disabled"); this.#accountControl.classList.remove("accounting-disabled");
this.#accountControl.classList.add("accounting-clickable"); this.#accountControl.classList.add("accounting-clickable");
} else { } else {
this.#descriptionControl.dataset.bsToggle = "";
this.#descriptionControl.dataset.bsTarget = "";
this.#descriptionControl.classList.add("accounting-disabled");
this.#descriptionControl.classList.remove("accounting-clickable");
this.#accountControl.dataset.bsToggle = ""; this.#accountControl.dataset.bsToggle = "";
this.#accountControl.dataset.bsTarget = ""; this.#accountControl.dataset.bsTarget = "";
this.#accountControl.classList.add("accounting-disabled"); this.#accountControl.classList.add("accounting-disabled");

View File

@ -17,9 +17,9 @@
"""The template filters. """The template filters.
""" """
import typing as t import datetime as dt
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any
from flask_babel import get_locale from flask_babel import get_locale
@ -41,24 +41,24 @@ def format_amount(value: Decimal | None) -> str | None:
return "{:,}".format(whole) + str(abs(frac))[1:] return "{:,}".format(whole) + str(abs(frac))[1:]
def format_date(value: date) -> str: def format_date(value: dt.date) -> str:
"""Formats a date to be human-friendly. """Formats a date to be human-friendly.
:param value: The date. :param value: The date.
:return: The human-friendly date text. :return: The human-friendly date text.
""" """
today: date = date.today() today: dt.date = dt.date.today()
if value == today: if value == today:
return gettext("Today") return gettext("Today")
if value == today - timedelta(days=1): if value == today - dt.timedelta(days=1):
return gettext("Yesterday") return gettext("Yesterday")
if value == today + timedelta(days=1): if value == today + dt.timedelta(days=1):
return gettext("Tomorrow") return gettext("Tomorrow")
locale = str(get_locale()) locale = str(get_locale())
if locale == "zh" or locale.startswith("zh_"): if locale == "zh" or locale.startswith("zh_"):
if value == today - timedelta(days=2): if value == today - dt.timedelta(days=2):
return gettext("The day before yesterday") return gettext("The day before yesterday")
if value == today + timedelta(days=2): if value == today + dt.timedelta(days=2):
return gettext("The day after tomorrow") return gettext("The day after tomorrow")
if locale == "zh" or locale.startswith("zh_"): if locale == "zh" or locale.startswith("zh_"):
weekdays = ["", "", "", "", "", "", ""] weekdays = ["", "", "", "", "", "", ""]
@ -71,7 +71,7 @@ def format_date(value: date) -> str:
return "{}/{}({})".format(value.month, value.day, weekday) return "{}/{}({})".format(value.month, value.day, weekday)
def default(value: t.Any, default_value: t.Any = "") -> t.Any: def default(value: Any, default_value: Any = "") -> Any:
"""Returns the default value if the given value is None. """Returns the default value if the given value is None.
:param value: The value. :param value: The value.

View File

@ -21,7 +21,7 @@ from accounting.models import Currency
from accounting.utils.options import options from accounting.utils.options import options
def currency_options() -> str: def currency_options() -> list[Currency]:
"""Returns the currency options. """Returns the currency options.
:return: The currency options. :return: The currency options.

View File

@ -23,6 +23,6 @@ First written: 2023/2/1
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %} {% 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 %} {% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}

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

@ -23,6 +23,6 @@ First written: 2023/2/6
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %} {% 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 %} {% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}

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

@ -23,6 +23,6 @@ First written: 2023/2/25
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %} {% 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 %} {% 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 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" 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 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

@ -36,7 +36,7 @@ First written: 2023/2/26
{{ A_("Edit") }} {{ A_("Edit") }}
</a> </a>
{% endif %} {% endif %}
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}"> <a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", date=obj.date)|accounting_append_next }}">
<i class="fa-solid fa-bars-staggered"></i> <i class="fa-solid fa-bars-staggered"></i>
<span class="d-none d-md-inline">{{ A_("Order") }}</span> <span class="d-none d-md-inline">{{ A_("Order") }}</span>
</a> </a>

View File

@ -20,6 +20,7 @@ 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">
<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 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

@ -38,7 +38,7 @@ First written: 2023/2/26
</div> </div>
{% if list|length > 1 and accounting_can_edit() %} {% if list|length > 1 and accounting_can_edit() %}
<form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post"> <form action="{{ url_for("accounting.journal-entry.sort", date=date) }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if request.args.next %} {% if request.args.next %}
<input type="hidden" name="next" value="{{ request.args.next }}"> <input type="hidden" name="next" value="{{ request.args.next }}">

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 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 %} {% 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 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 %} {% 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 First written: 2023/3/22
#} #}
<form id="accounting-recurring-item-editor-{{ expense_income }}"> <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 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

@ -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"> <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="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 fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">

View File

@ -14,28 +14,15 @@
# 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.
""" """
import typing as t from typing import Any
import sqlalchemy as sa
def be(expression: t.Any) -> sa.BinaryExpression: def s(message: Any) -> str:
"""Casts the SQLAlchemy binary expression to the binary expression type.
:param expression: The binary expression.
:return: The binary expression itself.
"""
assert isinstance(expression, sa.BinaryExpression)
return expression
def s(message: t.Any) -> str:
"""Casts the LazyString message to the string type. """Casts the LazyString message to the string type.
:param message: The message. :param message: The message.

View File

@ -17,12 +17,12 @@
"""The current assets and liabilities account. """The current assets and liabilities account.
""" """
import typing as t from typing import Self
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
import sqlalchemy as sa
class CurrentAccount: class CurrentAccount:
@ -54,7 +54,7 @@ class CurrentAccount:
return self.str return self.str
@classmethod @classmethod
def current_assets_and_liabilities(cls) -> t.Self: def current_assets_and_liabilities(cls) -> Self:
"""Returns the pseudo account for all current assets and liabilities. """Returns the pseudo account for all current assets and liabilities.
:return: The pseudo account for all current assets and liabilities. :return: The pseudo account for all current assets and liabilities.
@ -67,14 +67,14 @@ class CurrentAccount:
return account return account
@classmethod @classmethod
def accounts(cls) -> list[t.Self]: def accounts(cls) -> list[Self]:
"""Returns the current assets and liabilities accounts. """Returns the current assets and liabilities accounts.
:return: The current assets and liabilities accounts. :return: The current assets and liabilities accounts.
""" """
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

@ -19,7 +19,7 @@
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import typing as t from typing import Any
from flask import flash from flask import flash
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@ -34,7 +34,7 @@ def flash_form_errors(form: FlaskForm) -> None:
__flash_errors(form.errors) __flash_errors(form.errors)
def __flash_errors(error: t.Any) -> None: def __flash_errors(error: Any) -> None:
"""Flash all errors recursively. """Flash all errors recursively.
:param error: The errors. :param error: The errors.

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, \ from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
urlunparse 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: def append_next(uri: str) -> str:
@ -41,11 +51,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()
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 +61,24 @@ 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()
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") \ 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:
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: 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) uri_p: ParseResult = urlparse(uri)
params: list[tuple[str, str]] = parse_qsl(uri_p.query) params: list[tuple[str, str]] = parse_qsl(uri_p.query)
params = [x for x in params if x[0] != "next"] 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: list[str] = list(uri_p)
parts[4] = urlencode(params) parts[4] = urlencode(params)
return urlunparse(parts) 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: def init_app(bp: Blueprint) -> None:
"""Initializes the application. """Initializes the application.
:param bp: The blueprint of the accounting application. :param bp: The blueprint of the accounting application.
:return: None. :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(append_next, "accounting_append_next")
bp.add_app_template_filter(inherit_next, "accounting_inherit_next") bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
bp.add_app_template_filter(or_next, "accounting_or_next") bp.add_app_template_filter(or_next, "accounting_or_next")

View File

@ -17,7 +17,7 @@
"""The SQLAlchemy alias for the offset items. """The SQLAlchemy alias for the offset items.
""" """
import typing as t from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
@ -30,10 +30,10 @@ def offset_alias() -> sa.Alias:
:return: The SQLAlchemy alias for the offset items. :return: The SQLAlchemy alias for the offset items.
""" """
def as_from(model_cls: t.Any) -> sa.FromClause: def as_from(model_cls: Any) -> sa.FromClause:
return model_cls return model_cls
def as_alias(alias: t.Any) -> sa.Alias: def as_alias(alias: Any) -> sa.Alias:
return alias return alias
return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset")) return as_alias(sa.alias(as_from(JournalEntryLineItem), name="offset"))

View File

@ -19,7 +19,7 @@
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import typing as t from typing import TypeVar, Generic
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \ from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
ParseResult ParseResult
@ -62,10 +62,10 @@ class Redirection(RequestRedirect):
DEFAULT_PAGE_SIZE: int = 10 DEFAULT_PAGE_SIZE: int = 10
"""The default page size.""" """The default page size."""
T = t.TypeVar("T") T = TypeVar("T")
class Pagination(t.Generic[T]): class Pagination(Generic[T]):
"""The pagination utility.""" """The pagination utility."""
def __init__(self, items: list[T], is_reversed: bool = False): def __init__(self, items: list[T], is_reversed: bool = False):
@ -91,7 +91,7 @@ class Pagination(t.Generic[T]):
"""The options to the number of items in a page.""" """The options to the number of items in a page."""
class AbstractPagination(t.Generic[T]): class AbstractPagination(Generic[T]):
"""An abstract pagination.""" """An abstract pagination."""
def __init__(self): def __init__(self):

View File

@ -19,21 +19,21 @@
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import typing as t from collections.abc import Callable
from flask import abort, Blueprint, Response from flask import abort, Blueprint, Response
from accounting.utils.user import get_current_user, UserUtilityInterface from accounting.utils.user import get_current_user, UserUtilityInterface
def has_permission(rule: t.Callable[[], bool]) -> t.Callable: def has_permission(rule: Callable[[], bool]) -> Callable:
"""The permission decorator to check whether the current user is allowed. """The permission decorator to check whether the current user is allowed.
:param rule: The permission rule. :param rule: The permission rule.
:return: The view decorator. :return: The view decorator.
""" """
def decorator(view: t.Callable) -> t.Callable: def decorator(view: Callable) -> Callable:
"""The view decorator to decorate a view with permission tests. """The view decorator to decorate a view with permission tests.
:param view: The view. :param view: The view.
@ -61,16 +61,16 @@ def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
return decorator return decorator
__can_view_func: t.Callable[[], bool] = lambda: True __can_view_func: Callable[[], bool] = lambda: True
"""The callback that returns whether the current user can view the accounting """The callback that returns whether the current user can view the accounting
data.""" data."""
__can_edit_func: t.Callable[[], bool] = lambda: True __can_edit_func: Callable[[], bool] = lambda: True
"""The callback that returns whether the current user can edit the accounting """The callback that returns whether the current user can edit the accounting
data.""" data."""
__can_admin_func: t.Callable[[], bool] = lambda: True __can_admin_func: Callable[[], bool] = lambda: True
"""The callback that returns whether the current user can administrate the """The callback that returns whether the current user can administrate the
accounting settings.""" accounting settings."""
_unauthorized_func: t.Callable[[], Response | None] \ _unauthorized_func: Callable[[], Response | None] \
= lambda: Response(status=403) = lambda: Response(status=403)
"""The callback that returns the response to require the user to log in.""" """The callback that returns the response to require the user to log in."""

View File

@ -14,22 +14,22 @@
# 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 random ID mixin for the data models. """The random ID utility for the data models.
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import typing as t
from secrets import randbelow from secrets import randbelow
from typing import Type
from accounting import db from accounting import db
def new_id(cls: t.Type): def new_id(cls: Type[db.Model]):
"""Returns a new random ID for the data model. """Generates and returns a new, unused random ID for the data model.
:param cls: The data model. :param cls: The data model.
:return: The generated new random ID. :return: The newly-generated, unused random ID.
""" """
while True: while True:
obj_id: int = 100000000 + randbelow(900000000) obj_id: int = 100000000 + randbelow(900000000)

View File

@ -19,17 +19,17 @@
This module should not import any other module from the application. This module should not import any other module from the application.
""" """
import typing as t
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type
import sqlalchemy as sa import sqlalchemy as sa
from flask import g, Response from flask import g, Response
from flask_sqlalchemy.model import Model from flask_sqlalchemy.model import Model
T = t.TypeVar("T", bound=Model) T = TypeVar("T", bound=Model)
class UserUtilityInterface(t.Generic[T], ABC): class UserUtilityInterface(Generic[T], ABC):
"""The interface for the user utilities.""" """The interface for the user utilities."""
@abstractmethod @abstractmethod
@ -72,7 +72,7 @@ class UserUtilityInterface(t.Generic[T], ABC):
@property @property
@abstractmethod @abstractmethod
def cls(self) -> t.Type[T]: def cls(self) -> Type[T]:
"""Returns the class of the user data model. """Returns the class of the user data model.
:return: The class of the user data model. :return: The class of the user data model.
@ -112,7 +112,7 @@ class UserUtilityInterface(t.Generic[T], ABC):
__user_utils: UserUtilityInterface __user_utils: UserUtilityInterface
"""The user utilities.""" """The user utilities."""
user_cls: t.Type[Model] = Model user_cls: Type[Model] = Model
"""The user class.""" """The user class."""
user_pk_column: sa.Column = sa.Column(sa.Integer) user_pk_column: sa.Column = sa.Column(sa.Integer)
"""The primary key column of the user class.""" """The primary key column of the user class."""

View File

@ -17,12 +17,13 @@
"""The test for the account management. """The test for the account management.
""" """
import datetime as dt
import unittest import unittest
from datetime import timedelta, date
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \ from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
add_journal_entry add_journal_entry
@ -78,6 +79,7 @@ class AccountTestCase(unittest.TestCase):
AccountL10n.query.delete() AccountL10n.query.delete()
Account.query.delete() Account.query.delete()
db.session.commit() db.session.commit()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "editor") self.client, self.csrf_token = get_client(self.app, "editor")
response: httpx.Response response: httpx.Response
@ -143,7 +145,7 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/bases/{CASH.base_code}", response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
f"{cash_id}-no": "5"}) f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -192,7 +194,7 @@ class AccountTestCase(unittest.TestCase):
response = client.post(f"{PREFIX}/bases/{CASH.base_code}", response = client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
f"{cash_id}-no": "5"}) f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
@ -244,7 +246,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}", response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
f"{cash_id}-no": "5"}) f"{cash_id}-no": "5"})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
@ -461,7 +463,7 @@ class AccountTestCase(unittest.TestCase):
account = Account.find_by_code(CASH.code) account = Account.find_by_code(CASH.code)
self.assertIsNotNone(account) self.assertIsNotNone(account)
account.created_at \ account.created_at \
= account.created_at - timedelta(seconds=5) = account.created_at - dt.timedelta(seconds=5)
account.updated_at = account.created_at account.updated_at = account.created_at
db.session.commit() db.session.commit()
@ -526,7 +528,7 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(account.title_l10n, CASH.title) self.assertEqual(account.title_l10n, CASH.title)
self.assertEqual(account.l10n, []) 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, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, 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}, self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")}) {("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, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, 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}, self.assertEqual({(x.locale, x.title) for x in account.l10n},
{("zh_Hant", f"{CASH.title}-zh_Hant")}) {("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, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -591,8 +593,8 @@ class AccountTestCase(unittest.TestCase):
add_journal_entry(self.client, add_journal_entry(self.client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": "USD", "currency-1-code": "USD",
"currency-1-credit-1-account_code": BANK.code, "currency-1-credit-1-account_code": BANK.code,
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})
@ -709,7 +711,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/1111", response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
f"{id_1}-no": "4", f"{id_1}-no": "4",
f"{id_2}-no": "1", f"{id_2}-no": "1",
f"{id_3}-no": "5", f"{id_3}-no": "5",
@ -736,7 +738,7 @@ class AccountTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/bases/1111", response = self.client.post(f"{PREFIX}/bases/1111",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
f"{id_2}-no": "3a", f"{id_2}-no": "3a",
f"{id_3}-no": "5", f"{id_3}-no": "5",
f"{id_4}-no": "2"}) f"{id_4}-no": "2"})

View File

@ -18,8 +18,8 @@
""" """
import csv import csv
import typing as t
import unittest import unittest
from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
from click.testing import Result from click.testing import Result
@ -80,7 +80,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
from accounting.models import BaseAccount from accounting.models import BaseAccount
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
data: dict[dict[str, t.Any]] \ data: dict[dict[str, Any]] \
= {x["code"]: {"code": x["code"], = {x["code"]: {"code": x["code"],
"title": x["title"], "title": x["title"],
"l10n": {y[5:]: x[y] "l10n": {y[5:]: x[y]
@ -135,7 +135,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
from accounting.models import Currency from accounting.models import Currency
with open(data_dir / "currencies.csv") as fp: with open(data_dir / "currencies.csv") as fp:
data: dict[dict[str, t.Any]] \ data: dict[dict[str, Any]] \
= {x["code"]: {"code": x["code"], = {x["code"]: {"code": x["code"],
"name": x["name"], "name": x["name"],
"l10n": {y[5:]: x[y] "l10n": {y[5:]: x[y]

View File

@ -17,12 +17,13 @@
"""The test for the currency management. """The test for the currency management.
""" """
import datetime as dt
import unittest import unittest
from datetime import timedelta, date
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \ from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
add_journal_entry add_journal_entry
@ -384,7 +385,7 @@ class CurrencyTestCase(unittest.TestCase):
currency = db.session.get(Currency, USD.code) currency = db.session.get(Currency, USD.code)
self.assertIsNotNone(currency) self.assertIsNotNone(currency)
currency.created_at \ currency.created_at \
= currency.created_at - timedelta(seconds=5) = currency.created_at - dt.timedelta(seconds=5)
currency.updated_at = currency.created_at currency.updated_at = currency.created_at
db.session.commit() db.session.commit()
@ -468,7 +469,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(currency.name_l10n, USD.name) self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual(currency.l10n, []) 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, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, 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}, self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")}) {("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, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, 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}, self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")}) {("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, response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
@ -521,6 +522,8 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{JPY.code}" detail_uri: str = f"{PREFIX}/{JPY.code}"
delete_uri: str = f"{PREFIX}/{JPY.code}/delete" 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 list_uri: str = PREFIX
response: httpx.Response response: httpx.Response
@ -533,8 +536,8 @@ class CurrencyTestCase(unittest.TestCase):
add_journal_entry(self.client, add_journal_entry(self.client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": EUR.code, "currency-1-code": EUR.code,
"currency-1-credit-1-account_code": "1111-001", "currency-1-credit-1-account_code": "1111-001",
"currency-1-credit-1-amount": "20"}) "currency-1-credit-1-amount": "20"})

View File

@ -17,11 +17,12 @@
"""The test for the description editor. """The test for the description editor.
""" """
import datetime as dt
import unittest import unittest
from datetime import date
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \ from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
add_journal_entry add_journal_entry
@ -41,6 +42,7 @@ class DescriptionEditorTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.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.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 \ from accounting.journal_entry.utils.description_editor import \
DescriptionEditor 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) add_journal_entry(self.client, form)
with self.app.app_context(): with self.app.app_context():
editor: DescriptionEditor = DescriptionEditor() editor: DescriptionEditor = DescriptionEditor()
@ -143,22 +145,24 @@ class DescriptionEditorTestCase(unittest.TestCase):
Accounts.PREPAID) 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. """Returns the form data for multiple journal entry forms.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param encoded_next_uri: The encoded next URI.
:return: A list of the form data. :return: A list of the form data.
""" """
journal_entry_date: str = date.today().isoformat() journal_entry_date: str = dt.date.today().isoformat()
return [{"csrf_token": csrf_token, return [{"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-credit-0-account_code": Accounts.SERVICE, "currency-0-credit-0-account_code": Accounts.SERVICE,
"currency-0-credit-0-description": " Salary ", "currency-0-credit-0-description": " Salary ",
"currency-0-credit-0-amount": "2500"}, "currency-0-credit-0-amount": "2500"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "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-description": " Dinner—Hamburger ",
"currency-0-credit-2-amount": "4.25"}, "currency-0-credit-2-amount": "4.25"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "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-description": " Dinner—Steak ",
"currency-0-credit-1-amount": "8.28"}, "currency-0-credit-1-amount": "8.28"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.MEAL, "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-description": " Lunch—Noodles ",
"currency-0-credit-1-amount": "7.47"}, "currency-0-credit-1-amount": "7.47"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "currency-0-debit-0-account_code": Accounts.TRAVEL,
"currency-0-debit-0-description": " Airplane—Lake City↔Hill Town", "currency-0-debit-0-description": " Airplane—Lake City↔Hill Town",
"currency-0-debit-0-amount": "800"}, "currency-0-debit-0-amount": "800"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "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-description": " Train—Red—Mall→Museum ",
"currency-0-credit-3-amount": "4.4"}, "currency-0-credit-3-amount": "4.4"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.TRAVEL, "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-description": " Bike—Theatre→Home ",
"currency-0-credit-6-amount": "5.5"}, "currency-0-credit-6-amount": "5.5"},
{"csrf_token": csrf_token, {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry_date, "date": journal_entry_date,
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-account_code": Accounts.PETTY_CASH, "currency-0-debit-0-account_code": Accounts.PETTY_CASH,

View File

@ -17,13 +17,14 @@
"""The test for the journal entry management. """The test for the journal entry management.
""" """
import datetime as dt
import unittest import unittest
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \ from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
add_journal_entry, match_journal_entry_detail add_journal_entry, match_journal_entry_detail
@ -53,6 +54,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.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.client, self.csrf_token = get_client(self.app, "editor")
@ -153,7 +155,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
data=update_form) data=update_form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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", response = self.client.post(f"{PREFIX}/{journal_entry_id}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token})
@ -166,7 +169,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import JournalEntry, JournalEntryCurrency 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" store_uri: str = f"{PREFIX}/store/receipt"
response: httpx.Response response: httpx.Response
form: dict[str, str] form: dict[str, str]
@ -322,8 +326,10 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
edit_uri: str = f"{PREFIX}/{journal_entry_id}/edit?next=%2F_next" 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" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id) 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 from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
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" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
response: httpx.Response response: httpx.Response
@ -500,7 +507,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
self.assertIsNotNone(journal_entry) self.assertIsNotNone(journal_entry)
journal_entry.created_at \ journal_entry.created_at \
= journal_entry.created_at - timedelta(seconds=5) = journal_entry.created_at - dt.timedelta(seconds=5)
journal_entry.updated_at = journal_entry.created_at journal_entry.updated_at = journal_entry.created_at
db.session.commit() db.session.commit()
@ -524,7 +531,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, admin_username = "editor", "admin" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
response: httpx.Response response: httpx.Response
@ -557,7 +565,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
journal_entry_id_1: int \ journal_entry_id_1: int \
= add_journal_entry(self.client, self.__get_add_form()) = 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" delete_uri: str = f"{PREFIX}/{journal_entry_id_1}/delete"
response: httpx.Response response: httpx.Response
@ -575,8 +584,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
add_journal_entry( add_journal_entry(
self.client, self.client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-1-code": line_item.currency_code, "currency-1-code": line_item.currency_code,
"currency-1-debit-1-original_line_item_id": line_item.id, "currency-1-debit-1-original_line_item_id": line_item.id,
"currency-1-debit-1-account_code": line_item.account_code, "currency-1-debit-1-account_code": line_item.account_code,
@ -585,17 +594,18 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
# Cannot delete the journal entry that is in use # Cannot delete the journal entry that is in use
response = self.client.post(f"{PREFIX}/{journal_entry_id_2}/delete", response = self.client.post(f"{PREFIX}/{journal_entry_id_2}/delete",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI}) "next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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 # Success
response = self.client.get(detail_uri) response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri, response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI}) "next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
@ -603,7 +613,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri, response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI}) "next": self.encoded_next_uri})
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def __get_add_form(self) -> dict[str, str]: 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. :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} form = {x: form[x] for x in form if "-debit-" not in x}
return form return form
@ -625,7 +636,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
not changed. not changed.
""" """
form: dict[str, str] = get_unchanged_update_form( 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} form = {x: form[x] for x in form if "-debit-" not in x}
return form return form
@ -638,7 +649,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
changed. changed.
""" """
form: dict[str, str] = get_update_form( 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} form = {x: form[x] for x in form if "-debit-" not in x}
return form return form
@ -658,6 +670,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.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.client, self.csrf_token = get_client(self.app, "editor")
@ -758,7 +771,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
data=update_form) data=update_form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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", response = self.client.post(f"{PREFIX}/{journal_entry_id}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token})
@ -771,7 +785,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import JournalEntry, JournalEntryCurrency 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" store_uri: str = f"{PREFIX}/store/disbursement"
response: httpx.Response response: httpx.Response
form: dict[str, str] form: dict[str, str]
@ -930,8 +945,10 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
edit_uri: str = f"{PREFIX}/{journal_entry_id}/edit?next=%2F_next" 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" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id) 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 from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
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" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
response: httpx.Response response: httpx.Response
@ -1112,7 +1130,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
self.assertIsNotNone(journal_entry) self.assertIsNotNone(journal_entry)
journal_entry.created_at \ journal_entry.created_at \
= journal_entry.created_at - timedelta(seconds=5) = journal_entry.created_at - dt.timedelta(seconds=5)
journal_entry.updated_at = journal_entry.created_at journal_entry.updated_at = journal_entry.created_at
db.session.commit() db.session.commit()
@ -1136,7 +1154,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, admin_username = "editor", "admin" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
response: httpx.Response response: httpx.Response
@ -1168,7 +1187,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
""" """
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
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" delete_uri: str = f"{PREFIX}/{journal_entry_id}/delete"
response: httpx.Response response: httpx.Response
@ -1176,7 +1196,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri, response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI}) "next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
@ -1184,7 +1204,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri, response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI}) "next": self.encoded_next_uri})
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def __get_add_form(self) -> dict[str, str]: 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. :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} form = {x: form[x] for x in form if "-credit-" not in x}
return form return form
@ -1206,7 +1227,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
not changed. not changed.
""" """
form: dict[str, str] = get_unchanged_update_form( 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} form = {x: form[x] for x in form if "-credit-" not in x}
return form return form
@ -1219,7 +1240,8 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
changed. changed.
""" """
form: dict[str, str] = get_update_form( 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} form = {x: form[x] for x in form if "-credit-" not in x}
return form return form
@ -1240,6 +1262,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
JournalEntryLineItem JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.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.client, self.csrf_token = get_client(self.app, "editor")
@ -1340,7 +1363,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
data=update_form) data=update_form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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", response = self.client.post(f"{PREFIX}/{journal_entry_id}/delete",
data={"csrf_token": self.csrf_token}) data={"csrf_token": self.csrf_token})
@ -1353,7 +1377,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import JournalEntry, JournalEntryCurrency 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" store_uri: str = f"{PREFIX}/store/transfer"
response: httpx.Response response: httpx.Response
form: dict[str, str] form: dict[str, str]
@ -1548,8 +1573,10 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
edit_uri: str = f"{PREFIX}/{journal_entry_id}/edit?next=%2F_next" 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" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
form_0: dict[str, str] = self.__get_update_form(journal_entry_id) 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 from accounting.models import JournalEntry
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
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" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
response: httpx.Response response: httpx.Response
@ -1773,7 +1801,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
journal_entry = db.session.get(JournalEntry, journal_entry_id) journal_entry = db.session.get(JournalEntry, journal_entry_id)
self.assertIsNotNone(journal_entry) self.assertIsNotNone(journal_entry)
journal_entry.created_at \ journal_entry.created_at \
= journal_entry.created_at - timedelta(seconds=5) = journal_entry.created_at - dt.timedelta(seconds=5)
journal_entry.updated_at = journal_entry.created_at journal_entry.updated_at = journal_entry.created_at
db.session.commit() db.session.commit()
@ -1797,7 +1825,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
editor_username, admin_username = "editor", "admin" editor_username, admin_username = "editor", "admin"
client, csrf_token = get_client(self.app, admin_username) client, csrf_token = get_client(self.app, admin_username)
detail_uri: str = f"{PREFIX}/{journal_entry_id}?next=%2F_next" detail_uri: str = (f"{PREFIX}/{journal_entry_id}?"
f"next={self.encoded_next_uri}")
update_uri: str = f"{PREFIX}/{journal_entry_id}/update" update_uri: str = f"{PREFIX}/{journal_entry_id}/update"
journal_entry: JournalEntry journal_entry: JournalEntry
response: httpx.Response response: httpx.Response
@ -1831,7 +1860,8 @@ class TransferJournalEntryTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryCurrency from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
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" 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: 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} 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 from accounting.models import JournalEntry, JournalEntryCurrency
journal_entry_id: int \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = add_journal_entry(self.client, self.__get_add_form())
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" 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: 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} 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 \ journal_entry_id: int \
= add_journal_entry(self.client, self.__get_add_form()) = 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" delete_uri: str = f"{PREFIX}/{journal_entry_id}/delete"
response: httpx.Response response: httpx.Response
@ -2043,7 +2075,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.post(delete_uri, response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI}) "next": self.encoded_next_uri})
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], NEXT_URI) self.assertEqual(response.headers["Location"], NEXT_URI)
@ -2051,7 +2083,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
response = self.client.post(delete_uri, response = self.client.post(delete_uri,
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": NEXT_URI}) "next": self.encoded_next_uri})
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
def __get_add_form(self) -> dict[str, str]: 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: 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) \ def __get_unchanged_update_form(self, journal_entry_id: int) \
-> dict[str, str]: -> dict[str, str]:
@ -2071,7 +2103,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
not changed. not changed.
""" """
return get_unchanged_update_form( 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]: 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 """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 :return: The form data to update the journal entry, where the data are
changed. changed.
""" """
return get_update_form(journal_entry_id, return get_update_form(
self.app, self.csrf_token, None) journal_entry_id, self.app, self.csrf_token, self.encoded_next_uri,
None)
class JournalEntryReorderTestCase(unittest.TestCase): class JournalEntryReorderTestCase(unittest.TestCase):
@ -2100,6 +2133,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.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.client, self.csrf_token = get_client(self.app, "editor")
@ -2124,9 +2158,9 @@ class JournalEntryReorderTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
journal_entry_1: JournalEntry = db.session.get(JournalEntry, id_1) journal_entry_1: JournalEntry = db.session.get(JournalEntry, id_1)
journal_entry_date_2: date = journal_entry_1.date journal_entry_date_2: dt.date = journal_entry_1.date
journal_entry_date_1: date \ journal_entry_date_1: dt.date \
= journal_entry_date_2 - timedelta(days=1) = journal_entry_date_2 - dt.timedelta(days=1)
journal_entry_1.date = journal_entry_date_1 journal_entry_1.date = journal_entry_date_1
journal_entry_1.no = 3 journal_entry_1.no = 3
journal_entry_2: JournalEntry = db.session.get(JournalEntry, id_2) journal_entry_2: JournalEntry = db.session.get(JournalEntry, id_2)
@ -2147,7 +2181,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
response = self.client.post(f"{PREFIX}/{id_2}/update", data=form) response = self.client.post(f"{PREFIX}/{id_2}/update", data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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(): with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 1) self.assertEqual(db.session.get(JournalEntry, id_1).no, 1)
@ -2176,19 +2210,19 @@ class JournalEntryReorderTestCase(unittest.TestCase):
self.__get_add_disbursement_form()) self.__get_add_disbursement_form())
with self.app.app_context(): with self.app.app_context():
journal_entry_date: date = db.session.get(JournalEntry, id_1).date date: dt.date = db.session.get(JournalEntry, id_1).date
response = self.client.post( response = self.client.post(
f"{PREFIX}/dates/{journal_entry_date.isoformat()}", f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": self.encoded_next_uri,
f"{id_1}-no": "4", f"{id_1}-no": "4",
f"{id_2}-no": "1", f"{id_2}-no": "1",
f"{id_3}-no": "5", f"{id_3}-no": "5",
f"{id_4}-no": "2", f"{id_4}-no": "2",
f"{id_5}-no": "3"}) f"{id_5}-no": "3"})
self.assertEqual(response.status_code, 302) 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(): with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 4) self.assertEqual(db.session.get(JournalEntry, id_1).no, 4)
@ -2207,14 +2241,14 @@ class JournalEntryReorderTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
f"{PREFIX}/dates/{journal_entry_date.isoformat()}", f"{PREFIX}/dates/{date.isoformat()}",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"next": "/next", "next": self.encoded_next_uri,
f"{id_2}-no": "3a", f"{id_2}-no": "3a",
f"{id_3}-no": "5", f"{id_3}-no": "5",
f"{id_4}-no": "2"}) f"{id_4}-no": "2"})
self.assertEqual(response.status_code, 302) 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(): with self.app.app_context():
self.assertEqual(db.session.get(JournalEntry, id_1).no, 3) 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. :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} form = {x: form[x] for x in form if "-debit-" not in x}
return form return form
@ -2237,7 +2272,8 @@ class JournalEntryReorderTestCase(unittest.TestCase):
:return: The form data to add a new cash disbursement journal entry. :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} form = {x: form[x] for x in form if "-credit-" not in x}
return form return form
@ -2251,7 +2287,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
where the data are not changed. where the data are not changed.
""" """
form: dict[str, str] = get_unchanged_update_form( 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} form = {x: form[x] for x in form if "-credit-" not in x}
return form return form
@ -2260,4 +2296,4 @@ class JournalEntryReorderTestCase(unittest.TestCase):
:return: The form data to add a new journal entry. :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 import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from test_site.lib import JournalEntryLineItemData, JournalEntryCurrencyData, \ from test_site.lib import JournalEntryLineItemData, JournalEntryCurrencyData, \
JournalEntryData, BaseTestData JournalEntryData, BaseTestData
@ -50,6 +51,7 @@ class OffsetTestCase(unittest.TestCase):
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import JournalEntry, JournalEntryLineItem
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.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.client, self.csrf_token = get_client(self.app, "editor")
self.data: OffsetTestData = OffsetTestData(self.app, "editor") self.data: OffsetTestData = OffsetTestData(self.app, "editor")
@ -61,7 +63,8 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, JournalEntry 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" store_uri: str = f"{PREFIX}/store/receipt"
form: dict[str, str] form: dict[str, str]
old_amount: Decimal old_amount: Decimal
@ -85,14 +88,16 @@ class OffsetTestCase(unittest.TestCase):
original_line_item=self.data.l_r_or3d)])]) original_line_item=self.data.l_r_or3d)])])
# Non-existing original line item ID # 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" form["currency-1-credit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit # 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"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id) = str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -108,7 +113,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
store_uri, 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.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.app.app_context():
@ -117,7 +123,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # 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"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id) = str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account 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) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # 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" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # 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 form["currency-1-credit-1-account_code"] = Accounts.NOTES_RECEIVABLE
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # 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"] \ form["currency-1-credit-1-amount"] \
= str(journal_entry_data.currencies[0].credit[0].amount = str(journal_entry_data.currencies[0].credit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -149,7 +159,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # 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"] \ form["currency-1-credit-3-amount"] \
= str(journal_entry_data.currencies[0].credit[2].amount = str(journal_entry_data.currencies[0].credit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -160,14 +171,16 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days = journal_entry_data.days old_days = journal_entry_data.days
journal_entry_data.days = old_days + 1 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) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # 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) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
@ -184,7 +197,8 @@ class OffsetTestCase(unittest.TestCase):
""" """
from accounting.models import Account from accounting.models import Account
journal_entry_data: JournalEntryData = self.data.j_r_of2 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" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
@ -196,14 +210,16 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("600") journal_entry_data.currencies[0].credit[2].amount = Decimal("600")
# Non-existing original line item ID # 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" form["currency-1-credit-1-original_line_item_id"] = "9999"
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit # 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"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_or1c.id) = str(self.data.l_p_or1c.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account form["currency-1-credit-1-account_code"] = self.data.l_p_or1c.account
@ -220,7 +236,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
update_uri, 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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.app.app_context():
@ -229,7 +246,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # 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"] \ form["currency-1-credit-1-original_line_item_id"] \
= str(self.data.l_p_of1d.id) = str(self.data.l_p_of1d.id)
form["currency-1-credit-1-account_code"] = self.data.l_p_of1d.account 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) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # 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" form["currency-1-code"] = "EUR"
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # 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 form["currency-1-credit-1-account_code"] = Accounts.NOTES_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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # 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"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -264,7 +285,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # 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"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -278,18 +300,21 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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: def test_edit_receivable_original_line_item(self) -> None:
"""Tests to edit the receivable original line item. """Tests to edit the receivable original line item.
@ -298,7 +323,8 @@ class OffsetTestCase(unittest.TestCase):
""" """
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.j_r_or1 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" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
@ -310,21 +336,24 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4") journal_entry_data.currencies[0].credit[1].amount = Decimal("3.4")
# Not the same currency # 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" form["currency-1-code"] = "EUR"
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # 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 form["currency-1-debit-1-account_code"] = Accounts.NOTES_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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # 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"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -336,7 +365,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # 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"] \ form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount = str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -350,25 +380,29 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items # Not after the offset items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Not deleting matched original line items # 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"] del form["currency-1-debit-1-id"]
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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 # The original line item is always before the offset item, even when
# they happen in the same day. # they happen in the same day.
@ -388,7 +422,8 @@ class OffsetTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Account, JournalEntry 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" store_uri: str = f"{PREFIX}/store/disbursement"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
@ -411,14 +446,16 @@ class OffsetTestCase(unittest.TestCase):
[])]) [])])
# Non-existing original line item ID # 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" form["currency-1-debit-1-original_line_item_id"] = "9999"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# The same debit or credit # 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"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id) = str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -434,7 +471,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
store_uri, 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.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
with self.app.app_context(): with self.app.app_context():
@ -443,7 +481,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # 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"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id) = str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account 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) self.assertEqual(response.headers["Location"], create_uri)
# Not the same currency # 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" form["currency-1-code"] = "EUR"
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not the same account # 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 form["currency-1-debit-1-account_code"] = Accounts.NOTES_PAYABLE
response = self.client.post(store_uri, data=form) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - partially offset # 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"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -475,7 +517,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
# Not exceeding net balance - unmatched # 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"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -486,14 +529,16 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 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) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], create_uri) self.assertEqual(response.headers["Location"], create_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # 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) response = self.client.post(store_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
@ -510,7 +555,8 @@ class OffsetTestCase(unittest.TestCase):
""" """
from accounting.models import Account, JournalEntry from accounting.models import Account, JournalEntry
journal_entry_data: JournalEntryData = self.data.j_p_of2 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" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
@ -522,14 +568,16 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[2].amount = Decimal("900") journal_entry_data.currencies[0].credit[2].amount = Decimal("900")
# Non-existing original line item ID # 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" form["currency-1-debit-1-original_line_item_id"] = "9999"
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# The same debit or credit # 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"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_or1d.id) = str(self.data.l_r_or1d.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account form["currency-1-debit-1-account_code"] = self.data.l_r_or1d.account
@ -546,7 +594,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
response = self.client.post( response = self.client.post(
update_uri, 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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
with self.app.app_context(): with self.app.app_context():
@ -555,7 +604,8 @@ class OffsetTestCase(unittest.TestCase):
db.session.commit() db.session.commit()
# The original line item is also an offset # 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"] \ form["currency-1-debit-1-original_line_item_id"] \
= str(self.data.l_r_of1c.id) = str(self.data.l_r_of1c.id)
form["currency-1-debit-1-account_code"] = self.data.l_r_of1c.account 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) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same currency # 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" form["currency-1-code"] = "EUR"
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # 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 form["currency-1-debit-1-account_code"] = Accounts.NOTES_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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - partially offset # 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"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -590,7 +643,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not exceeding net balance - unmatched # 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"] \ form["currency-1-debit-3-amount"] \
= str(journal_entry_data.currencies[0].debit[2].amount = str(journal_entry_data.currencies[0].debit[2].amount
+ Decimal("0.01")) + Decimal("0.01"))
@ -604,14 +658,16 @@ class OffsetTestCase(unittest.TestCase):
# Not before the original line items # Not before the original line items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days + 1 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Success # 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
journal_entry_id: int \ journal_entry_id: int \
@ -628,7 +684,8 @@ class OffsetTestCase(unittest.TestCase):
""" """
from accounting.models import JournalEntry from accounting.models import JournalEntry
journal_entry_data: JournalEntryData = self.data.j_p_or1 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" update_uri: str = f"{PREFIX}/{journal_entry_data.id}/update"
form: dict[str, str] form: dict[str, str]
response: httpx.Response response: httpx.Response
@ -640,21 +697,24 @@ class OffsetTestCase(unittest.TestCase):
journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9") journal_entry_data.currencies[0].credit[1].amount = Decimal("0.9")
# Not the same currency # 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" form["currency-1-code"] = "EUR"
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not the same account # 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 form["currency-1-credit-1-account_code"] = Accounts.NOTES_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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - partially offset # 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"] \ form["currency-1-debit-1-amount"] \
= str(journal_entry_data.currencies[0].debit[0].amount = str(journal_entry_data.currencies[0].debit[0].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -666,7 +726,8 @@ class OffsetTestCase(unittest.TestCase):
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Not less than offset total - fully offset # 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"] \ form["currency-1-debit-2-amount"] \
= str(journal_entry_data.currencies[0].debit[1].amount = str(journal_entry_data.currencies[0].debit[1].amount
- Decimal("0.01")) - Decimal("0.01"))
@ -680,25 +741,29 @@ class OffsetTestCase(unittest.TestCase):
# Not after the offset items # Not after the offset items
old_days: int = journal_entry_data.days old_days: int = journal_entry_data.days
journal_entry_data.days = old_days - 1 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
journal_entry_data.days = old_days journal_entry_data.days = old_days
# Not deleting matched original line items # 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"] del form["currency-1-credit-1-id"]
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.status_code, 302)
self.assertEqual(response.headers["Location"], edit_uri) self.assertEqual(response.headers["Location"], edit_uri)
# Success # 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) response = self.client.post(update_uri, data=form)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], 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 # The original line item is always before the offset item, even when
# they happen in the same day # they happen in the same day

View File

@ -17,23 +17,18 @@
"""The test for the options. """The test for the options.
""" """
import datetime as dt
import unittest import unittest
from datetime import datetime, timedelta
import httpx import httpx
from flask import Flask from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client from testlib import NEXT_URI, Accounts, create_test_app, get_client
PREFIX: str = "/accounting/options" PREFIX: str = "/accounting/options"
"""The URL prefix for the option management.""" """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): class OptionTestCase(unittest.TestCase):
@ -50,6 +45,7 @@ class OptionTestCase(unittest.TestCase):
with self.app.app_context(): with self.app.app_context():
from accounting.models import Option from accounting.models import Option
Option.query.delete() Option.query.delete()
self.encoded_next_uri: str = encode_next(NEXT_URI)
self.client, self.csrf_token = get_client(self.app, "admin") self.client, self.csrf_token = get_client(self.app, "admin")
@ -59,15 +55,18 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "nobody") 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: httpx.Response
response = client.get(DETAIL_URI) response = client.get(detail_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI) response = client.get(edit_uri)
self.assertEqual(response.status_code, 403) 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) self.assertEqual(response.status_code, 403)
def test_viewer(self) -> None: def test_viewer(self) -> None:
@ -76,15 +75,18 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "viewer") 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: httpx.Response
response = client.get(DETAIL_URI) response = client.get(detail_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI) response = client.get(edit_uri)
self.assertEqual(response.status_code, 403) 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) self.assertEqual(response.status_code, 403)
def test_editor(self) -> None: def test_editor(self) -> None:
@ -93,15 +95,18 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
client, csrf_token = get_client(self.app, "editor") 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: httpx.Response
response = client.get(DETAIL_URI) response = client.get(detail_uri)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
response = client.get(EDIT_URI) response = client.get(edit_uri)
self.assertEqual(response.status_code, 403) 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) self.assertEqual(response.status_code, 403)
def test_admin(self) -> None: def test_admin(self) -> None:
@ -109,17 +114,20 @@ class OptionTestCase(unittest.TestCase):
:return: None. :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: httpx.Response
response = self.client.get(DETAIL_URI) response = self.client.get(detail_uri)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
response = self.client.get(EDIT_URI) response = self.client.get(edit_uri)
self.assertEqual(response.status_code, 200) 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
def test_set(self) -> None: def test_set(self) -> None:
"""Test to set the options. """Test to set the options.
@ -127,59 +135,62 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.utils.options import options 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] form: dict[str, str]
response: httpx.Response response: httpx.Response
# Empty currency code # Empty currency code
form = self.__get_form() form = self.__get_form()
form["default_currency_code"] = " " 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing currency code # Non-existing currency code
form = self.__get_form() form = self.__get_form()
form["default_currency_code"] = "ZZZ" 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Empty current account # Empty current account
form = self.__get_form() form = self.__get_form()
form["default_ie_account_code"] = " " 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Non-existing current account # Non-existing current account
form = self.__get_form() form = self.__get_form()
form["default_ie_account_code"] = "9999-999" 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Not a current account # Not a current account
form = self.__get_form() form = self.__get_form()
form["default_ie_account_code"] = Accounts.MEAL 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item name empty # Recurring item name empty
form = self.__get_form() form = self.__get_form()
key = [x for x in form if x.endswith("-name")][0] key = [x for x in form if x.endswith("-name")][0]
form[key] = " " 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item account empty # Recurring item account empty
form = self.__get_form() form = self.__get_form()
key = [x for x in form if x.endswith("-account_code")][0] key = [x for x in form if x.endswith("-account_code")][0]
form[key] = " " 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item non-expense account # Recurring item non-expense account
form = self.__get_form() form = self.__get_form()
@ -187,9 +198,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.SERVICE 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item non-income account # Recurring item non-income account
form = self.__get_form() form = self.__get_form()
@ -197,9 +208,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-income-") if x.startswith("recurring-income-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.UTILITIES 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item payable expense # Recurring item payable expense
form = self.__get_form() form = self.__get_form()
@ -207,9 +218,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.PAYABLE 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item receivable income # Recurring item receivable income
form = self.__get_form() form = self.__get_form()
@ -217,17 +228,17 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-income-") if x.startswith("recurring-income-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.RECEIVABLE 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Recurring item description template empty # Recurring item description template empty
form = self.__get_form() form = self.__get_form()
key = [x for x in form if x.endswith("-description_template")][0] key = [x for x in form if x.endswith("-description_template")][0]
form[key] = " " 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.status_code, 302)
self.assertEqual(response.headers["Location"], EDIT_URI) self.assertEqual(response.headers["Location"], edit_uri)
# Success, with malformed order # Success, with malformed order
with self.app.app_context(): 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.expenses), 0)
self.assertEqual(len(options.recurring.incomes), 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
self.assertEqual(options.default_currency_code, "EUR") self.assertEqual(options.default_currency_code, "EUR")
@ -261,9 +272,9 @@ class OptionTestCase(unittest.TestCase):
# Success, with no recurring data # Success, with no recurring data
form = self.__get_form() form = self.__get_form()
form = {x: form[x] for x in form if not x.startswith("recurring-")} 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
self.assertEqual(len(options.recurring.expenses), 0) self.assertEqual(len(options.recurring.expenses), 0)
@ -275,18 +286,21 @@ class OptionTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import Option 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] form: dict[str, str]
option: Option | None option: Option | None
resource: httpx.Response 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
self.assertIsNotNone(option) self.assertIsNotNone(option)
timestamp: datetime = option.created_at - timedelta(seconds=5) timestamp: dt.datetime \
= option.created_at - dt.timedelta(seconds=5)
option.created_at = timestamp option.created_at = timestamp
option.updated_at = timestamp option.updated_at = timestamp
db.session.commit() db.session.commit()
@ -294,9 +308,9 @@ class OptionTestCase(unittest.TestCase):
# The recurring setting was not modified # The recurring setting was not modified
form = self.__get_form() form = self.__get_form()
form["default_currency_code"] = "JPY" 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
@ -310,9 +324,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
@ -327,12 +341,14 @@ class OptionTestCase(unittest.TestCase):
from accounting.models import Option from accounting.models import Option
from accounting.utils.user import get_user_pk from accounting.utils.user import get_user_pk
admin_username, editor_username = "admin", "editor" 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 option: Option | None
response: httpx.Response 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
editor_pk: int = get_user_pk(editor_username) editor_pk: int = get_user_pk(editor_username)
@ -347,9 +363,9 @@ class OptionTestCase(unittest.TestCase):
if x.startswith("recurring-expense-") if x.startswith("recurring-expense-")
and x.endswith("-account_code")][0] and x.endswith("-account_code")][0]
form[key] = Accounts.MEAL 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.status_code, 302)
self.assertEqual(response.headers["Location"], DETAIL_URI) self.assertEqual(response.headers["Location"], detail_uri)
with self.app.app_context(): with self.app.app_context():
option = db.session.get(Option, "recurring") option = db.session.get(Option, "recurring")
@ -366,7 +382,7 @@ class OptionTestCase(unittest.TestCase):
if csrf_token is None: if csrf_token is None:
csrf_token = self.csrf_token csrf_token = self.csrf_token
return {"csrf_token": csrf_token, return {"csrf_token": csrf_token,
"next": NEXT_URI, "next": self.encoded_next_uri,
"default_currency_code": "EUR", "default_currency_code": "EUR",
"default_ie_account_code": "0000-000", "default_ie_account_code": "0000-000",
"recurring-expense-1-name": "Water bill", "recurring-expense-1-name": "Water bill",

View File

@ -17,8 +17,8 @@
"""The test for the reports. """The test for the reports.
""" """
import datetime as dt
import unittest import unittest
from datetime import date
import httpx import httpx
from flask import Flask from flask import Flask
@ -446,15 +446,15 @@ class ReportTestData(BaseTestData):
"""The report test data.""" """The report test data."""
def _init_data(self) -> None: def _init_data(self) -> None:
today: date = date.today() today: dt.date = dt.date.today()
year: int = today.year - 5 year: int = today.year - 5
month: int = today.month month: int = today.month
while True: while True:
j_date: date = date(year, month, 5) date: dt.date = dt.date(year, month, 5)
if j_date > today: if date > today:
break break
self._add_simple_journal_entry( self._add_simple_journal_entry(
(j_date - today).days, "USD", (date - today).days, "USD",
"Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE) "Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE)
month = month + 1 month = month + 1
if month > 12: if month > 12:

View File

@ -18,18 +18,20 @@
""" """
import os import os
import typing as t
from secrets import token_urlsafe from secrets import token_urlsafe
from typing import Type
from click.testing import Result from click.testing import Result
from flask import Flask, Blueprint, render_template, redirect, Response, \ from flask import Flask, Blueprint, render_template, redirect, Response, \
url_for url_for, request
from flask.testing import FlaskCliRunner from flask.testing import FlaskCliRunner
from flask_babel_js import BabelJS from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
from sqlalchemy import Column from sqlalchemy import Column
from accounting.utils.next_uri import encode_next
bp: Blueprint = Blueprint("home", __name__) bp: Blueprint = Blueprint("home", __name__)
"""The global blueprint.""" """The global blueprint."""
babel_js: BabelJS = BabelJS() 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" 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|简体中文",
@ -94,7 +98,7 @@ def create_app(is_testing: bool = False) -> Flask:
return redirect(append_next(url_for("auth.login-form"))) return redirect(append_next(url_for("auth.login-form")))
@property @property
def cls(self) -> t.Type[auth.User]: def cls(self) -> Type[auth.User]:
return auth.User return auth.User
@property @property

View File

@ -17,24 +17,25 @@
"""The authentication for the Mia! Accounting demonstration website. """The authentication for the Mia! Accounting demonstration website.
""" """
import typing as t from collections.abc import Callable
from flask import Blueprint, render_template, Flask, redirect, url_for, \ from flask import Blueprint, render_template, Flask, redirect, url_for, \
session, request, g, Response, abort session, request, g, Response, abort
from sqlalchemy.orm import Mapped, mapped_column
from . import db from . import db
bp: Blueprint = Blueprint("auth", __name__, url_prefix="/") bp: Blueprint = Blueprint("auth", __name__, url_prefix="/")
"""The authentication blueprint."""
class User(db.Model): class User(db.Model):
"""A user.""" """A user."""
__tablename__ = "users" __tablename__ = "users"
"""The table name.""" """The table name."""
id = db.Column(db.Integer, nullable=False, primary_key=True, id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
autoincrement=True) """The ID."""
"""The ID""" username: Mapped[str] = mapped_column(unique=True)
username = db.Column(db.String, nullable=False, unique=True)
"""The username.""" """The username."""
def __str__(self) -> str: def __str__(self) -> str:
@ -95,7 +96,7 @@ def current_user() -> User | None:
return g.user return g.user
def admin_required(view: t.Callable) -> t.Callable: def admin_required(view: Callable) -> Callable:
"""The view decorator to require the user to be an administrator. """The view decorator to require the user to be an administrator.
:param view: The view. :param view: The view.

View File

@ -19,11 +19,11 @@
""" """
from __future__ import annotations from __future__ import annotations
import typing as t import datetime as dt
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from secrets import randbelow from secrets import randbelow
from typing import Any
import sqlalchemy as sa import sqlalchemy as sa
from flask import Flask from flask import Flask
@ -140,37 +140,39 @@ class JournalEntryData:
for line_item in currency.credit: for line_item in currency.credit:
line_item.journal_entry = self 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. """Returns the journal entry as a creation form.
:param csrf_token: The CSRF token. :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: 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. """Returns the journal entry as an update form.
:param csrf_token: The CSRF token. :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: 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) \ def __form(self, csrf_token: str, encoded_next_uri: str,
-> dict[str, str]: is_update: bool = False) -> dict[str, str]:
"""Returns the journal entry as a form. """Returns the journal entry as a form.
:param csrf_token: The CSRF token. :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 :param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form. :return: The journal entry as a form.
""" """
journal_entry_date: date = date.today() - timedelta(days=self.days) date: dt.date = dt.date.today() - dt.timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token, form: dict[str, str] = {"csrf_token": csrf_token,
"next": next_uri, "next": encoded_next_uri,
"date": journal_entry_date.isoformat()} "date": date.isoformat()}
for i in range(len(self.currencies)): for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update)) form.update(self.currencies[i].form(i + 1, is_update))
if self.note is not None: if self.note is not None:
@ -193,8 +195,8 @@ class BaseTestData(ABC):
.filter(User.username == username).first() .filter(User.username == username).first()
assert current_user is not None assert current_user is not None
self.__current_user_id: int = current_user.id self.__current_user_id: int = current_user.id
self.__journal_entries: list[dict[str, t.Any]] = [] self.__journal_entries: list[dict[str, Any]] = []
self.__line_items: list[dict[str, t.Any]] = [] self.__line_items: list[dict[str, Any]] = []
self._init_data() self._init_data()
@abstractmethod @abstractmethod
@ -240,11 +242,12 @@ class BaseTestData(ABC):
existing_j_id: set[int] = {x["id"] for x in self.__journal_entries} existing_j_id: set[int] = {x["id"] for x in self.__journal_entries}
existing_l_id: set[int] = {x["id"] for x in self.__line_items} existing_l_id: set[int] = {x["id"] for x in self.__line_items}
journal_entry_data.id = self.__new_id(existing_j_id) journal_entry_data.id = self.__new_id(existing_j_id)
j_date: date = date.today() - timedelta(days=journal_entry_data.days) date: dt.date \
= dt.date.today() - dt.timedelta(days=journal_entry_data.days)
self.__journal_entries.append( self.__journal_entries.append(
{"id": journal_entry_data.id, {"id": journal_entry_data.id,
"date": j_date, "date": date,
"no": self.__next_j_no(j_date), "no": self.__next_j_no(date),
"note": journal_entry_data.note, "note": journal_entry_data.note,
"created_by_id": self.__current_user_id, "created_by_id": self.__current_user_id,
"updated_by_id": self.__current_user_id}) "updated_by_id": self.__current_user_id})
@ -257,7 +260,7 @@ class BaseTestData(ABC):
assert account is not None assert account is not None
debit_no = debit_no + 1 debit_no = debit_no + 1
line_item.id = self.__new_id(existing_l_id) line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \ data: dict[str, Any] \
= {"id": line_item.id, = {"id": line_item.id,
"journal_entry_id": journal_entry_data.id, "journal_entry_id": journal_entry_data.id,
"is_debit": True, "is_debit": True,
@ -276,7 +279,7 @@ class BaseTestData(ABC):
assert account is not None assert account is not None
credit_no = credit_no + 1 credit_no = credit_no + 1
line_item.id = self.__new_id(existing_l_id) line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \ data: dict[str, Any] \
= {"id": line_item.id, = {"id": line_item.id,
"journal_entry_id": journal_entry_data.id, "journal_entry_id": journal_entry_data.id,
"is_debit": False, "is_debit": False,
@ -303,14 +306,14 @@ class BaseTestData(ABC):
existing_id.add(obj_id) existing_id.add(obj_id)
return obj_id return obj_id
def __next_j_no(self, j_date: date) -> int: def __next_j_no(self, date: dt.date) -> int:
"""Returns the next journal entry number in a day. """Returns the next journal entry number in a day.
:param j_date: The journal entry date. :param date: The journal entry date.
:return: The next journal entry number. :return: The next journal entry number.
""" """
existing: set[int] = {x["no"] for x in self.__journal_entries existing: set[int] = {x["no"] for x in self.__journal_entries
if x["date"] == j_date} if x["date"] == date}
return 1 if len(existing) == 0 else max(existing) + 1 return 1 if len(existing) == 0 else max(existing) + 1
def _add_simple_journal_entry( def _add_simple_journal_entry(

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

@ -17,7 +17,7 @@
"""The data reset for the Mia! Accounting demonstration website. """The data reset for the Mia! Accounting demonstration website.
""" """
from datetime import date, timedelta import datetime as dt
from flask import Flask, Blueprint, url_for, flash, redirect, session, \ from flask import Flask, Blueprint, url_for, flash, redirect, session, \
render_template, current_app render_template, current_app
@ -116,15 +116,15 @@ class SampleData(BaseTestData):
:return: None. :return: None.
""" """
today: date = date.today() today: dt.date = dt.date.today()
days: int days: int
year: int year: int
month: int month: int
# Recurring in USD # Recurring in USD
j_date: date = date(today.year - 5, today.month, today.day) date: dt.date = dt.date(today.year - 5, today.month, today.day)
j_date = j_date + timedelta(days=(4 - j_date.weekday())) date = date + dt.timedelta(days=(4 - date.weekday()))
days = (today - j_date).days days = (today - date).days
while True: while True:
if days < 0: if days < 0:
break break
@ -147,7 +147,7 @@ class SampleData(BaseTestData):
if month > 12: if month > 12:
year = year + 1 year = year + 1
month = 1 month = 1
days = (today - date(year, month, 1)).days days = (today - dt.date(year, month, 1)).days
if days < 0: if days < 0:
break break
self.__add_journal_entry( self.__add_journal_entry(
@ -159,12 +159,12 @@ class SampleData(BaseTestData):
:return: None. :return: None.
""" """
today: date = date.today() today: dt.date = dt.date.today()
year: int = today.year - 5 year: int = today.year - 5
month: int = today.month month: int = today.month
while True: while True:
days: int = (today - date(year, month, 5)).days days: int = (today - dt.date(year, month, 5)).days
if days < 0: if days < 0:
break break
self.__add_journal_entry( self.__add_journal_entry(

View File

@ -25,14 +25,14 @@ First written: 2023/1/27
<meta charset="UTF-8"> <meta charset="UTF-8">
<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" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" 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" integrity="sha384-iw3OoTErCYJJB9mCa8LNS2hbsQ7M3C0EpIsO/H5+EGAkPGc6rk+V8i04oW/K5xq0" 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/@eonasdan/tempus-dominus@6.7.7/dist/css/tempus-dominus.min.css" integrity="sha384-l66rSL7gUubrdJxFRbXUo/tO7eNPAcCiZXFs/Xl147146xNqQ1qt4oPW6jlVezsS" 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>
@ -96,7 +96,7 @@ First written: 2023/1/27
</span> </span>
<form action="{{ url_for("locale.set-locale") }}" method="post"> <form action="{{ url_for("locale.set-locale") }}" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <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"> <ul class="dropdown-menu dropdown-menu-end">
{% for locale_code, locale_name in get_all_linguas().items() %} {% for locale_code, locale_name in get_all_linguas().items() %}
<li> <li>
@ -121,10 +121,10 @@ First written: 2023/1/27
{% if messages %} {% if messages %}
{% for category, message in messages %} {% for category, message in messages %}
{% if category == "success" %} {% if category == "success" %}
<div class="alert alert-success alert-dismissible fade show" role="alert"> <div class="alert alert-success alert-dismissible fade show" role="alert">
{{ message }} {{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
{% elif category == "error" %} {% elif category == "error" %}
<div class="alert alert-danger alert-dismissible fade show" role="alert"> <div class="alert alert-danger alert-dismissible fade show" role="alert">
<strong>{{ _("Error:") }}</strong> {{ message }} <strong>{{ _("Error:") }}</strong> {{ message }}

View File

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

View File

@ -22,11 +22,12 @@ from urllib.parse import quote_plus
import httpx import httpx
from flask import Flask, request from flask import Flask, request
from itsdangerous import URLSafeSerializer
from accounting.utils.next_uri import append_next, inherit_next, or_next from accounting.utils.next_uri import append_next, inherit_next, or_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords 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): class NextUriTestCase(unittest.TestCase):
@ -40,6 +41,8 @@ class NextUriTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
self.serializer: URLSafeSerializer \
= URLSafeSerializer(self.app.config["SECRET_KEY"])
def test_next_uri(self) -> None: def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI. """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 \ current_uri: str = request.full_path if request.query_string \
else request.path else request.path
self.assertEqual(append_next(self.TARGET), 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" \ next_uri: str = request.form["next"] if request.method == "POST" \
else request.args["next"] else request.args["next"]
self.assertEqual(inherit_next(self.TARGET), self.assertEqual(inherit_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(next_uri)}") f"{self.TARGET}?next={next_uri}")
self.assertEqual(or_next(self.TARGET), next_uri) self.assertEqual(or_next(self.TARGET), self.__decode(next_uri))
return "" return ""
self.app.add_url_rule("/test-next", view_func=test_next_uri_view, 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) csrf_token: str = get_csrf_token(client)
response: httpx.Response 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) self.assertEqual(response.status_code, 200)
response = client.post("/test-next", data={"csrf_token": csrf_token, response = client.post("/test-next", data={"csrf_token": csrf_token,
"next": "/next", "next": encoded_uri,
"name": "viewer"}) "name": "viewer"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -80,10 +84,6 @@ class NextUriTestCase(unittest.TestCase):
""" """
def test_no_next_uri_view() -> str: def test_no_next_uri_view() -> str:
"""The test view without the next URI.""" """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(inherit_next(self.TARGET), self.TARGET)
self.assertEqual(or_next(self.TARGET), self.TARGET) self.assertEqual(or_next(self.TARGET), self.TARGET)
return "" return ""
@ -101,6 +101,53 @@ 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), 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): class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser.""" """The test case for the query keyword parser."""

View File

@ -20,11 +20,12 @@
from __future__ import annotations from __future__ import annotations
import re import re
import typing as t from typing import Literal
import httpx import httpx
from flask import Flask, render_template_string from flask import Flask, render_template_string
from accounting.utils.next_uri import encode_next
from test_site import create_app from test_site import create_app
TEST_SERVER: str = "https://testserver" TEST_SERVER: str = "https://testserver"
@ -71,9 +72,9 @@ def create_test_app() -> Flask:
"""The test view to return the CSRF token.""" """The test view to return the CSRF token."""
return render_template_string("{{csrf_token()}}") return render_template_string("{{csrf_token()}}")
@app.get("/.errors") @app.get("/.messages")
def get_errors_view() -> str: def get_messages_view() -> str:
"""The test view to return the CSRF token.""" """The test view to return the flashed messages."""
return render_template_string("{{get_flashed_messages()|tojson}}") return render_template_string("{{get_flashed_messages()|tojson}}")
return app 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: httpx.Client = httpx.Client(app=app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client) 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", response: httpx.Response = client.post("/login",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"next": "/", "next": encoded_next_uri,
"username": username}) "username": username})
assert response.status_code == 302 assert response.status_code == 302
assert response.headers["Location"] == "/" assert response.headers["Location"] == NEXT_URI
return client, csrf_token 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: t.Literal["en", "zh_Hant", "zh_Hans"]) -> None: locale: Literal["en", "zh_Hant", "zh_Hans"]) -> None:
"""Sets the current locale. """Sets the current locale.
:param app: The Flask application.
:param client: The test client. :param client: The test client.
:param csrf_token: The CSRF token. :param csrf_token: The CSRF token.
:param locale: The locale. :param locale: The locale.
:return: None. :return: None.
""" """
with app.app_context():
encoded_next_uri: str = encode_next(NEXT_URI)
response: httpx.Response = client.post("/locale", response: httpx.Response = client.post("/locale",
data={"csrf_token": csrf_token, data={"csrf_token": csrf_token,
"locale": locale, "locale": locale,
"next": "/next"}) "next": encoded_next_uri})
assert response.status_code == 302 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: 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. :raise AssertionError: When the location is not the journal entry detail.
""" """
m: re.Match = re.match( 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 assert m is not None
return int(m.group(1)) return int(m.group(1))

View File

@ -17,8 +17,8 @@
"""The common test libraries for the journal entry test cases. """The common test libraries for the journal entry test cases.
""" """
import datetime as dt
import re import re
from datetime import date
from decimal import Decimal from decimal import Decimal
from secrets import randbelow from secrets import randbelow
@ -33,15 +33,16 @@ EMPTY_NOTE: str = " \n\n "
"""The empty note content.""" """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. """Returns the form data to add a new journal entry.
:param csrf_token: The CSRF token. :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: The form data to add a new journal entry.
""" """
return {"csrf_token": csrf_token, return {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": date.today().isoformat(), "date": dt.date.today().isoformat(),
"currency-0-code": "USD", "currency-0-code": "USD",
"currency-0-debit-0-no": "16", "currency-0-debit-0-no": "16",
"currency-0-debit-0-account_code": Accounts.CASH, "currency-0-debit-0-account_code": Accounts.CASH,
@ -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, 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 """Returns the form data to update a journal entry, where the data are not
changed. changed.
:param journal_entry_id: The journal entry ID. :param journal_entry_id: The journal entry ID.
:param app: The Flask application. :param app: The Flask application.
:param csrf_token: The CSRF token. :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 :return: The form data to update the journal entry, where the data are not
changed. changed.
""" """
@ -121,7 +124,7 @@ def get_unchanged_update_form(journal_entry_id: int, app: Flask,
form: dict[str, str] \ form: dict[str, str] \
= {"csrf_token": csrf_token, = {"csrf_token": csrf_token,
"next": NEXT_URI, "next": encoded_next_uri,
"date": journal_entry.date, "date": journal_entry.date,
"note": " \n \n\n " if journal_entry.note is None "note": " \n \n\n " if journal_entry.note is None
else f"\n \n\n \n \n{journal_entry.note} \n\n "} 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, 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 """Returns the form data to update a journal entry, where the data are
changed. changed.
:param journal_entry_id: The journal entry ID. :param journal_entry_id: The journal entry ID.
:param app: The Flask application. :param app: The Flask application.
:param csrf_token: The CSRF token. :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 :param is_debit: True for a cash disbursement journal entry, False for a
cash receipt journal entry, or None for a transfer journal entry. cash receipt journal entry, or None for a transfer journal entry.
:return: The form data to update the journal entry, where the data are :return: The form data to update the journal entry, where the data are
changed. changed.
""" """
form: dict[str, str] = get_unchanged_update_form( 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 # Mess up the line items in a currency
currency_prefix: str = __get_currency_prefix(form, "USD") currency_prefix: str = __get_currency_prefix(form, "USD")