38 Commits

Author SHA1 Message Date
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
52 changed files with 743 additions and 443 deletions

View File

@ -2,6 +2,62 @@ Change Log
==========
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
-------------

View File

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

View File

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

View File

@ -17,15 +17,15 @@
"""The console commands for the account management.
"""
import typing as t
from secrets import randbelow
from typing import Any
import click
import sqlalchemy as sa
from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk
import sqlalchemy as sa
AccountData = tuple[int, str, int, str, str, str, bool]
"""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)
return new_id
data: list[dict[str, t.Any]] = []
l10n_data: list[dict[str, t.Any]] = []
data: list[dict[str, Any]] = []
l10n_data: list[dict[str, Any]] = []
for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
account_id: int = get_new_id()

View File

@ -18,7 +18,7 @@
"""
import csv
import typing as t
from typing import Any
import sqlalchemy as sa
@ -39,11 +39,11 @@ def init_currencies_command(username: str) -> None:
return
creator_pk: int = get_user_pk(username)
currency_data: list[dict[str, t.Any]] = [{"code": x["code"],
"name_l10n": x["name"],
"created_by_id": creator_pk,
"updated_by_id": creator_pk}
for x in to_add]
currency_data: list[dict[str, Any]] = [{"code": x["code"],
"name_l10n": x["name"],
"created_by_id": creator_pk,
"updated_by_id": creator_pk}
for x in to_add]
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"],
"locale": y,

View File

@ -18,8 +18,8 @@
"""
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
from typing import TypeVar, Generic, Type
import sqlalchemy as sa
from flask_babel import LazyString
@ -29,13 +29,13 @@ from wtforms import DateField, FieldList, FormField, TextAreaField, \
from wtforms.validators import DataRequired, ValidationError
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.models import JournalEntry, Account, JournalEntryLineItem, \
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.strip_text import strip_multiline_text
from accounting.utils.user import get_current_user_pk
@ -123,7 +123,7 @@ class JournalEntryForm(FlaskForm):
super().__init__(*args, **kwargs)
self.is_modified: bool = False
"""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
collector only to provide the correct type. The subclass forms should
provide their own collectors."""
@ -155,7 +155,7 @@ class JournalEntryForm(FlaskForm):
self.__set_date(obj, self.date.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.collect()
@ -309,11 +309,11 @@ class JournalEntryForm(FlaskForm):
return db.session.scalar(select)
T = t.TypeVar("T", bound=JournalEntryForm)
T = TypeVar("T", bound=JournalEntryForm)
"""A journal entry form variant."""
class LineItemCollector(t.Generic[T], ABC):
class LineItemCollector(Generic[T], ABC):
"""The line item collector."""
def __init__(self, form: T, obj: JournalEntry):

View File

@ -18,7 +18,7 @@
"""
import re
import typing as t
from typing import Literal
import sqlalchemy as sa
@ -124,12 +124,12 @@ class DescriptionTag:
class DescriptionType:
"""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.
: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."""
self.__tag_dict: dict[str, DescriptionTag] = {}
"""A dictionary from the tag name to their corresponding tag."""
@ -181,12 +181,12 @@ class DescriptionRecurring:
class DescriptionDebitCredit:
"""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.
: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."""
self.general: DescriptionType = DescriptionType("general")
"""The general tags."""
@ -194,14 +194,14 @@ class DescriptionDebitCredit:
"""The travel tags."""
self.bus: DescriptionType = DescriptionType("bus")
"""The bus tags."""
self.__type_dict: dict[t.Literal["general", "travel", "bus"],
self.__type_dict: dict[Literal["general", "travel", "bus"],
DescriptionType] \
= {x.id: x for x in {self.general, self.travel, self.bus}}
"""A dictionary from the type ID to the corresponding tags."""
self.recurring: list[DescriptionRecurring] = []
"""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:
"""Adds a tag.
@ -278,7 +278,7 @@ class DescriptionEditor:
accounts: dict[int, Account] \
= {x.id: x for x in Account.query
.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] \
= {x.debit_credit: x for x in {self.debit, self.credit}}
for row in result:

View File

@ -17,19 +17,19 @@
"""The operators for different journal entry types.
"""
import typing as t
from abc import ABC, abstractmethod
from typing import Type
from flask import render_template, request, abort
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, \
CashReceiptJournalEntryForm, CashDisbursementJournalEntryForm, \
TransferJournalEntryForm
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):
@ -39,7 +39,7 @@ class JournalEntryOperator(ABC):
@property
@abstractmethod
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@ -100,7 +100,7 @@ class CashReceiptJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@ -170,7 +170,7 @@ class CashDisbursementJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.
@ -243,7 +243,7 @@ class TransferJournalEntry(JournalEntryOperator):
"""The order when checking the journal entry operator."""
@property
def form(self) -> t.Type[JournalEntryForm]:
def form(self) -> Type[JournalEntryForm]:
"""Returns the form class.
:return: The form class.

View File

@ -21,8 +21,8 @@ from __future__ import annotations
import datetime as dt
import re
import typing as t
from decimal import Decimal
from typing import Type, Self
import sqlalchemy as sa
from babel import Locale
@ -34,18 +34,6 @@ from accounting import db
from accounting.locale import gettext
from accounting.utils.user import user_cls, user_pk_column
timestamp: t.Type[dt.datetime] \
= t.Annotated[dt.datetime, mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())]
"""The timestamp."""
user_pk: t.Type[int] \
= t.Annotated[int, mapped_column(db.ForeignKey(user_pk_column,
onupdate="CASCADE"))]
"""The user primary key."""
random_pk: t.Type[int] \
= t.Annotated[int, mapped_column(primary_key=True, autoincrement=False)]
"""The random primary key."""
class BaseAccount(db.Model):
"""A base account."""
@ -126,18 +114,24 @@ class Account(db.Model):
"""The title."""
is_need_offset: Mapped[bool] = mapped_column(default=False)
"""Whether the journal entry line items of this account need offset."""
created_at: Mapped[timestamp]
"""The time of creation."""
created_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the creator."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
updated_at: Mapped[timestamp]
"""The time of last update."""
updated_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the updator."""
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator."""
"""The last user who updated the record."""
l10n: Mapped[list[AccountL10n]] \
= db.relationship(back_populates="account", lazy=False)
"""The localized titles."""
@ -188,6 +182,8 @@ class Account(db.Model):
:param value: The new title.
:return: None.
"""
if self.title == value:
return
if self.title_l10n is None:
self.title_l10n = value
return
@ -273,11 +269,11 @@ class Account(db.Model):
:return: None.
"""
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()
@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.
:param code: The code.
@ -290,7 +286,7 @@ class Account(db.Model):
cls.no == int(m.group(2))).first()
@classmethod
def selectable_debit(cls) -> list[t.Self]:
def selectable_debit(cls) -> list[Self]:
"""Returns the selectable debit accounts.
Payable line items can not start from debit.
@ -313,7 +309,7 @@ class Account(db.Model):
.order_by(cls.base_code, cls.no).all()
@classmethod
def selectable_credit(cls) -> list[t.Self]:
def selectable_credit(cls) -> list[Self]:
"""Returns the selectable debit accounts.
Receivable line items can not start from credit.
@ -335,7 +331,7 @@ class Account(db.Model):
.order_by(cls.base_code, cls.no).all()
@classmethod
def cash(cls) -> t.Self:
def cash(cls) -> Self:
"""Returns the cash account.
:return: The cash account
@ -343,7 +339,7 @@ class Account(db.Model):
return cls.find_by_code(cls.CASH_CODE)
@classmethod
def accumulated_change(cls) -> t.Self:
def accumulated_change(cls) -> Self:
"""Returns the accumulated-change account.
:return: The accumulated-change account
@ -376,19 +372,25 @@ class Currency(db.Model):
"""The code."""
name_l10n: Mapped[str] = mapped_column("name")
"""The name."""
created_at: Mapped[timestamp]
"""The time of creation."""
created_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the creator."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
updated_at: Mapped[timestamp]
"""The time of last update."""
updated_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the updator."""
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] \
= db.relationship(foreign_keys=updated_by_id)
"""The updator."""
"""The last user who updated the record."""
l10n: Mapped[list[CurrencyL10n]] \
= db.relationship(back_populates="currency", lazy=False)
"""The localized names."""
@ -424,6 +426,8 @@ class Currency(db.Model):
:param value: The new name.
:return: None.
"""
if self.name == value:
return
if self.name_l10n is None:
self.name_l10n = value
return
@ -467,7 +471,7 @@ class Currency(db.Model):
:return: None.
"""
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()
@ -543,18 +547,24 @@ class JournalEntry(db.Model):
"""The account number under the date."""
note: Mapped[str | None]
"""The note."""
created_at: Mapped[timestamp]
"""The time of creation."""
created_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the creator."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
updated_at: Mapped[timestamp]
"""The time of last update."""
updated_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the updator."""
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator."""
"""The last user who updated the record."""
line_items: Mapped[list[JournalEntryLineItem]] \
= db.relationship(back_populates="journal_entry")
"""The line items."""
@ -797,14 +807,14 @@ class JournalEntryLineItem(db.Model):
setattr(self, "__balance", value)
@property
def offsets(self) -> list[t.Self]:
def offsets(self) -> list[Self]:
"""Returns the offset items.
:return: The offset items.
"""
if not hasattr(self, "__offsets"):
cls: t.Type[t.Self] = self.__class__
offsets: list[t.Self] = cls.query.join(JournalEntry)\
cls: Type[Self] = self.__class__
offsets: list[Self] = cls.query.join(JournalEntry)\
.filter(JournalEntryLineItem.original_line_item_id == self.id)\
.order_by(JournalEntry.date, JournalEntry.no,
cls.is_debit, cls.no).all()
@ -831,7 +841,7 @@ class JournalEntryLineItem(db.Model):
setattr(self, "__is_offset", value)
@property
def match(self) -> t.Self | None:
def match(self) -> Self | None:
"""Returns the match of the line item.
:return: The match of the line item.
@ -841,7 +851,7 @@ class JournalEntryLineItem(db.Model):
return getattr(self, "__match")
@match.setter
def match(self, value: t.Self) -> None:
def match(self, value: Self) -> None:
"""Sets the match of the line item.
:param value: The matcho of the line item.
@ -876,15 +886,21 @@ class Option(db.Model):
"""The name."""
value: Mapped[str] = mapped_column(db.Text)
"""The option value."""
created_at: Mapped[timestamp]
"""The time of creation."""
created_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the creator."""
created_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was created."""
created_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the user who created the record."""
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
"""The creator."""
updated_at: Mapped[timestamp]
"""The time of last update."""
updated_by_id: Mapped[user_pk] = mapped_column()
"""The ID of the updator."""
"""The user who created the record."""
updated_at: Mapped[dt.datetime] \
= mapped_column(db.DateTime(timezone=True),
server_default=db.func.now())
"""The date and time when this record was last updated."""
updated_by_id: Mapped[int] \
= mapped_column(db.ForeignKey(user_pk_column, onupdate="CASCADE"))
"""The ID of the last user who updated the record."""
updated_by: Mapped[user_cls] = db.relationship(foreign_keys=updated_by_id)
"""The updator."""
"""The last user who updated the record."""

View File

@ -21,7 +21,7 @@ This file is largely taken from the NanoParma ERP project, first written in
"""
import datetime as dt
import typing as t
from collections.abc import Callable
from accounting.models import JournalEntry
from .period import Period
@ -32,13 +32,13 @@ from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
class PeriodChooser:
"""The period chooser."""
def __init__(self, get_url: t.Callable[[Period], str]):
def __init__(self, get_url: Callable[[Period], str]):
"""Constructs a period chooser.
:param get_url: The callback to return the URL of the current report in
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."""
# Shortcut periods

View File

@ -20,7 +20,8 @@
import calendar
import datetime as dt
import re
import typing as t
from collections.abc import Callable
from typing import Type
from .period import Period
from .shortcuts import ThisMonth, LastMonth, SinceLastMonth, ThisYear, \
@ -39,7 +40,7 @@ def get_period(spec: str | None = None) -> Period:
"""
if spec is None:
return ThisMonth()
named_periods: dict[str, t.Type[t.Callable[[], Period]]] = {
named_periods: dict[str, Type[Callable[[], Period]]] = {
"this-month": lambda: ThisMonth(),
"last-month": lambda: LastMonth(),
"since-last-month": lambda: SinceLastMonth(),

View File

@ -21,7 +21,7 @@ This file is largely taken from the NanoParma ERP project, first written in
"""
import datetime as dt
import typing as t
from typing import Self
from .description import get_desc
from .month_end import month_end
@ -119,7 +119,7 @@ class Period:
and not self.is_a_day
@property
def before(self) -> t.Self | None:
def before(self) -> Self | None:
"""Returns the period before this period.
:return: The period before this period.

View File

@ -143,7 +143,7 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport):
: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))
def html(self) -> str:

View File

@ -144,7 +144,7 @@ class AccountsWithUnmatchedOffsets(BaseReport):
: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))
def html(self) -> str:

View File

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

View File

@ -123,15 +123,13 @@ class OffsetMatcher:
.options(selectinload(JournalEntryLineItem.currency),
selectinload(JournalEntryLineItem.journal_entry)).all()
for line_item in self.line_items:
line_item.is_offset = line_item.id in net_balances
self.unapplied = [x for x in self.line_items
if x.is_offset]
line_item.is_offset = line_item.id not in net_balances
self.unapplied = [x for x in self.line_items if not x.is_offset]
for line_item in self.unapplied:
line_item.net_balance = line_item.amount \
if net_balances[line_item.id] is None \
else net_balances[line_item.id]
self.unmatched = [x for x in self.line_items
if not x.is_offset]
self.unmatched = [x for x in self.line_items if x.is_offset]
self.__populate_accumulated_balances()
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 typing as t
from collections.abc import Iterator
from flask_babel import LazyString
@ -190,7 +190,7 @@ class ReportChooser:
self.__active_report == ReportType.UNMATCHED,
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.
:return: The iteration of the reports.

View File

@ -276,7 +276,6 @@ class JournalEntryLineItemEditor {
this.originalLineItemDate = originalLineItem.date;
this.originalLineItemText = originalLineItem.text;
this.#originalLineItemText.innerText = originalLineItem.text;
this.#setEnableDescriptionAccount(false);
if (this.description === null) {
if (originalLineItem.description === "") {
this.#descriptionControl.classList.remove("accounting-not-empty");
@ -291,7 +290,9 @@ class JournalEntryLineItemEditor {
this.account = originalLineItem.account.copy();
this.isAccountConfirmed = false;
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.min = "0";
this.#validate();

View File

@ -18,8 +18,8 @@
"""
import datetime as dt
import typing as t
from decimal import Decimal
from typing import Any
from flask_babel import get_locale
@ -71,7 +71,7 @@ def format_date(value: dt.date) -> str:
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.
:param value: The value.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,16 +14,15 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The utility to cast a SQLAlchemy column into the column type, to avoid
warnings from the IDE.
"""The utilities to cast values into desired types, to avoid IDE warnings.
This module should not import any other module from the application.
"""
import typing as t
from typing import Any
def s(message: t.Any) -> str:
def s(message: Any) -> str:
"""Casts the LazyString message to the string type.
:param message: The message.

View File

@ -17,12 +17,12 @@
"""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.models import Account
import sqlalchemy as sa
class CurrentAccount:
@ -54,7 +54,7 @@ class CurrentAccount:
return self.str
@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.
:return: The pseudo account for all current assets and liabilities.
@ -67,14 +67,14 @@ class CurrentAccount:
return account
@classmethod
def accounts(cls) -> list[t.Self]:
def accounts(cls) -> list[Self]:
"""Returns the current assets and liabilities accounts.
:return: The current assets and liabilities accounts.
"""
accounts: list[cls] = [cls.current_assets_and_liabilities()]
accounts.extend([CurrentAccount(x)
for x in db.session.query(Account)
for x in Account.query
.filter(cls.sql_condition())
.order_by(Account.base_code, Account.no)])
return accounts

View File

@ -19,7 +19,7 @@
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_wtf import FlaskForm
@ -34,7 +34,7 @@ def flash_form_errors(form: FlaskForm) -> None:
__flash_errors(form.errors)
def __flash_errors(error: t.Any) -> None:
def __flash_errors(error: Any) -> None:
"""Flash all errors recursively.
:param error: The errors.

View File

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

View File

@ -17,7 +17,7 @@
"""The SQLAlchemy alias for the offset items.
"""
import typing as t
from typing import Any
import sqlalchemy as sa
@ -30,10 +30,10 @@ def offset_alias() -> sa.Alias:
: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
def as_alias(alias: t.Any) -> sa.Alias:
def as_alias(alias: Any) -> sa.Alias:
return alias
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.
"""
import typing as t
from typing import TypeVar, Generic
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
ParseResult
@ -62,10 +62,10 @@ class Redirection(RequestRedirect):
DEFAULT_PAGE_SIZE: int = 10
"""The default page size."""
T = t.TypeVar("T")
T = TypeVar("T")
class Pagination(t.Generic[T]):
class Pagination(Generic[T]):
"""The pagination utility."""
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."""
class AbstractPagination(t.Generic[T]):
class AbstractPagination(Generic[T]):
"""An abstract pagination."""
def __init__(self):

View File

@ -19,21 +19,21 @@
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 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.
:param rule: The permission rule.
: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.
:param view: The view.
@ -61,16 +61,16 @@ def has_permission(rule: t.Callable[[], bool]) -> t.Callable:
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
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
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
accounting settings."""
_unauthorized_func: t.Callable[[], Response | None] \
_unauthorized_func: Callable[[], Response | None] \
= lambda: Response(status=403)
"""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.
# See the License for the specific language governing permissions and
# 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.
"""
import typing as t
from secrets import randbelow
from typing import Type
from accounting import db
def new_id(cls: t.Type):
"""Returns a new random ID for the data model.
def new_id(cls: Type[db.Model]):
"""Generates and returns a new, unused random ID for the data model.
:param cls: The data model.
:return: The generated new random ID.
:return: The newly-generated, unused random ID.
"""
while True:
obj_id: int = 100000000 + randbelow(900000000)

View File

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

View File

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

View File

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

View File

@ -23,6 +23,7 @@ import unittest
import httpx
from flask import Flask
from accounting.utils.next_uri import encode_next
from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
add_journal_entry
@ -468,7 +469,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual(currency.name_l10n, USD.name)
self.assertEqual(currency.l10n, [])
set_locale(self.client, self.csrf_token, "zh_Hant")
set_locale(self.app, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
@ -483,7 +484,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "en")
set_locale(self.app, self.client, self.csrf_token, "en")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
@ -498,7 +499,7 @@ class CurrencyTestCase(unittest.TestCase):
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
{("zh_Hant", f"{USD.name}-zh_Hant")})
set_locale(self.client, self.csrf_token, "zh_Hant")
set_locale(self.app, self.client, self.csrf_token, "zh_Hant")
response = self.client.post(update_uri,
data={"csrf_token": self.csrf_token,
@ -521,6 +522,8 @@ class CurrencyTestCase(unittest.TestCase):
from accounting.models import Currency
detail_uri: str = f"{PREFIX}/{JPY.code}"
delete_uri: str = f"{PREFIX}/{JPY.code}/delete"
with self.app.app_context():
encoded_next_uri: str = encode_next(NEXT_URI)
list_uri: str = PREFIX
response: httpx.Response
@ -533,7 +536,7 @@ class CurrencyTestCase(unittest.TestCase):
add_journal_entry(self.client,
form={"csrf_token": self.csrf_token,
"next": NEXT_URI,
"next": encoded_next_uri,
"date": dt.date.today().isoformat(),
"currency-1-code": EUR.code,
"currency-1-credit-1-account_code": "1111-001",

View File

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

View File

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

View File

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

View File

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

View File

@ -18,18 +18,20 @@
"""
import os
import typing as t
from secrets import token_urlsafe
from typing import Type
from click.testing import Result
from flask import Flask, Blueprint, render_template, redirect, Response, \
url_for
url_for, request
from flask.testing import FlaskCliRunner
from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect
from sqlalchemy import Column
from accounting.utils.next_uri import encode_next
bp: Blueprint = Blueprint("home", __name__)
"""The global blueprint."""
babel_js: BabelJS = BabelJS()
@ -52,6 +54,8 @@ def create_app(is_testing: bool = False) -> Flask:
db_uri: str = "sqlite:///" if is_testing else "sqlite:///local.sqlite"
app.config.from_mapping({
"SECRET_KEY": os.environ.get("SECRET_KEY", token_urlsafe(32)),
"SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": True,
"SQLALCHEMY_DATABASE_URI": db_uri,
"BABEL_DEFAULT_LOCALE": "en",
"ALL_LINGUAS": "zh_Hant|正體中文,en|English,zh_Hans|简体中文",
@ -64,6 +68,7 @@ def create_app(is_testing: bool = False) -> Flask:
db.init_app(app)
app.register_blueprint(bp, url_prefix="/")
app.add_template_global(__as_next, "accounting_as_next")
from . import locale
locale.init_app(app)
@ -94,7 +99,7 @@ def create_app(is_testing: bool = False) -> Flask:
return redirect(append_next(url_for("auth.login-form")))
@property
def cls(self) -> t.Type[auth.User]:
def cls(self) -> Type[auth.User]:
return auth.User
@property
@ -144,3 +149,12 @@ def get_home() -> str:
:return: The home page.
"""
return render_template("home.html")
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)

View File

@ -17,7 +17,7 @@
"""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, \
session, request, g, Response, abort
@ -96,7 +96,7 @@ def current_user() -> User | None:
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.
:param view: The view.

View File

@ -20,10 +20,10 @@
from __future__ import annotations
import datetime as dt
import typing as t
from abc import ABC, abstractmethod
from decimal import Decimal
from secrets import randbelow
from typing import Any
import sqlalchemy as sa
from flask import Flask
@ -140,36 +140,38 @@ class JournalEntryData:
for line_item in currency.credit:
line_item.journal_entry = self
def new_form(self, csrf_token: str, next_uri: str) -> dict[str, str]:
def new_form(self, csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the journal entry as a creation form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:param encoded_next_uri: The encoded next URI.
:return: The journal entry as a creation form.
"""
return self.__form(csrf_token, next_uri, is_update=False)
return self.__form(csrf_token, encoded_next_uri, is_update=False)
def update_form(self, csrf_token: str, next_uri: str) -> dict[str, str]:
def update_form(self, csrf_token: str, encoded_next_uri: str) \
-> dict[str, str]:
"""Returns the journal entry as an update form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:param encoded_next_uri: The encoded next URI.
:return: The journal entry as an update form.
"""
return self.__form(csrf_token, next_uri, is_update=True)
return self.__form(csrf_token, encoded_next_uri, is_update=True)
def __form(self, csrf_token: str, next_uri: str, is_update: bool = False) \
-> dict[str, str]:
def __form(self, csrf_token: str, encoded_next_uri: str,
is_update: bool = False) -> dict[str, str]:
"""Returns the journal entry as a form.
:param csrf_token: The CSRF token.
:param next_uri: The next URI.
:param encoded_next_uri: The encoded next URI.
:param is_update: True for an update operation, or False otherwise
:return: The journal entry as a form.
"""
date: dt.date = dt.date.today() - dt.timedelta(days=self.days)
form: dict[str, str] = {"csrf_token": csrf_token,
"next": next_uri,
"next": encoded_next_uri,
"date": date.isoformat()}
for i in range(len(self.currencies)):
form.update(self.currencies[i].form(i + 1, is_update))
@ -193,8 +195,8 @@ class BaseTestData(ABC):
.filter(User.username == username).first()
assert current_user is not None
self.__current_user_id: int = current_user.id
self.__journal_entries: list[dict[str, t.Any]] = []
self.__line_items: list[dict[str, t.Any]] = []
self.__journal_entries: list[dict[str, Any]] = []
self.__line_items: list[dict[str, Any]] = []
self._init_data()
@abstractmethod
@ -258,7 +260,7 @@ class BaseTestData(ABC):
assert account is not None
debit_no = debit_no + 1
line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \
data: dict[str, Any] \
= {"id": line_item.id,
"journal_entry_id": journal_entry_data.id,
"is_debit": True,
@ -277,7 +279,7 @@ class BaseTestData(ABC):
assert account is not None
credit_no = credit_no + 1
line_item.id = self.__new_id(existing_l_id)
data: dict[str, t.Any] \
data: dict[str, Any] \
= {"id": line_item.id,
"journal_entry_id": journal_entry_data.id,
"is_debit": False,

View File

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

View File

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

View File

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

View File

@ -22,11 +22,12 @@ from urllib.parse import quote_plus
import httpx
from flask import Flask, request
from itsdangerous import URLSafeSerializer
from accounting.utils.next_uri import append_next, inherit_next, or_next
from accounting.utils.pagination import Pagination, DEFAULT_PAGE_SIZE
from accounting.utils.query import parse_query_keywords
from testlib import TEST_SERVER, create_test_app, get_csrf_token
from testlib import TEST_SERVER, create_test_app, get_csrf_token, NEXT_URI
class NextUriTestCase(unittest.TestCase):
@ -40,6 +41,8 @@ class NextUriTestCase(unittest.TestCase):
:return: None.
"""
self.app: Flask = create_test_app()
self.serializer: URLSafeSerializer \
= URLSafeSerializer(self.app.config["SECRET_KEY"])
def test_next_uri(self) -> None:
"""Tests the next URI utilities with the next URI.
@ -51,12 +54,12 @@ class NextUriTestCase(unittest.TestCase):
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(current_uri)}")
f"{self.TARGET}?next={self.__encode(current_uri)}")
next_uri: str = request.form["next"] if request.method == "POST" \
else request.args["next"]
self.assertEqual(inherit_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(next_uri)}")
self.assertEqual(or_next(self.TARGET), next_uri)
f"{self.TARGET}?next={next_uri}")
self.assertEqual(or_next(self.TARGET), self.__decode(next_uri))
return ""
self.app.add_url_rule("/test-next", view_func=test_next_uri_view,
@ -66,10 +69,11 @@ class NextUriTestCase(unittest.TestCase):
csrf_token: str = get_csrf_token(client)
response: httpx.Response
response = client.get("/test-next?next=/next&q=abc&page-no=4")
encoded_uri: str = self.__encode(NEXT_URI)
response = client.get(f"/test-next?next={encoded_uri}&q=abc&page-no=4")
self.assertEqual(response.status_code, 200)
response = client.post("/test-next", data={"csrf_token": csrf_token,
"next": "/next",
"next": encoded_uri,
"name": "viewer"})
self.assertEqual(response.status_code, 200)
@ -80,10 +84,6 @@ class NextUriTestCase(unittest.TestCase):
"""
def test_no_next_uri_view() -> str:
"""The test view without the next URI."""
current_uri: str = request.full_path if request.query_string \
else request.path
self.assertEqual(append_next(self.TARGET),
f"{self.TARGET}?next={quote_plus(current_uri)}")
self.assertEqual(inherit_next(self.TARGET), self.TARGET)
self.assertEqual(or_next(self.TARGET), self.TARGET)
return ""
@ -101,6 +101,53 @@ class NextUriTestCase(unittest.TestCase):
"name": "viewer"})
self.assertEqual(response.status_code, 200)
def test_invalid(self) -> None:
"""Tests the next URI utilities without an invalid next URI.
:return: None.
"""
def test_invalid_next_uri_view() -> str:
"""The test view without the next URI."""
self.assertEqual(inherit_next(self.TARGET), self.TARGET)
self.assertEqual(or_next(self.TARGET), self.TARGET)
return ""
self.app.add_url_rule("/test-invalid-next",
view_func=test_invalid_next_uri_view,
methods=["GET", "POST"])
client: httpx.Client = httpx.Client(app=self.app, base_url=TEST_SERVER)
client.headers["Referer"] = TEST_SERVER
csrf_token: str = get_csrf_token(client)
next_uri: str
expected1: str
expected2: str
response: httpx.Response
# A foreign URI
next_uri = "https://example.com"
response = client.get(f"/test-invalid-next?next={quote_plus(next_uri)}")
self.assertEqual(response.status_code, 200)
response = client.post("/test-invalid-next",
data={"csrf_token": csrf_token,
"next": next_uri})
self.assertEqual(response.status_code, 200)
def __encode(self, uri: str) -> str:
"""Encodes the next URI.
:param uri: The next URI.
:return: The encoded next URI.
"""
return self.serializer.dumps(uri, "next")
def __decode(self, uri: str) -> str:
"""Decodes the next URI.
:param uri: The encoded next URI.
:return: The next URI.
"""
return self.serializer.loads(uri, "next")
class QueryKeywordParserTestCase(unittest.TestCase):
"""The test case for the query keyword parser."""

View File

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

View File

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