Compare commits
67 Commits
Author | SHA1 | Date | |
---|---|---|---|
352867797d | |||
09a344d749 | |||
818c357613 | |||
822c8fc49b | |||
3b8a2e3bb1 | |||
9e4927ee0b | |||
3b030c577c | |||
60b33f2a3b | |||
08fdf59844 | |||
b397515457 | |||
abe90d3483 | |||
65e7dcdf6d | |||
74e414badf | |||
69175979ff | |||
2f69e0f215 | |||
961385c389 | |||
a691cfd2da | |||
482a0faa23 | |||
0ecf7b6617 | |||
4408bbfc82 | |||
433110f486 | |||
0b1dd4f4fc | |||
46bd27e126 | |||
b718d19450 | |||
2969e83afe | |||
a732656746 | |||
1daed940b6 | |||
f29cb00aec | |||
693f07a49c | |||
8c899776f2 | |||
f9aa226bf9 | |||
c9bb4197be | |||
9ae8d587d8 | |||
158058dcfb | |||
0bc9947234 | |||
8c58a9083a | |||
f45663754c | |||
cda9e4e3c6 | |||
ee5b447c23 | |||
25bfcf4aa4 | |||
5956d2cd4c | |||
833285d924 | |||
dee4f5e83f | |||
f0d1cae32d | |||
5dc71697b3 | |||
1bb1e03c08 | |||
914ff92e0f | |||
8a1cf463b1 | |||
d4cf224d6b | |||
8d412ec00a | |||
2986c518ce | |||
f1351243a6 | |||
969e8c76a6 | |||
10f5e75752 | |||
169b3c292a | |||
3eb3aef2f2 | |||
6c455a615c | |||
4f3339bf68 | |||
b5aa7e923f | |||
359c335662 | |||
c11ae23885 | |||
e083b11394 | |||
167990fc4c | |||
d5c1be3d80 | |||
f6567794e0 | |||
ded85d88f7 | |||
6d780e9296 |
@ -1,5 +1,61 @@
|
||||
Changes
|
||||
=======
|
||||
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
|
||||
|
@ -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/
|
||||
|
@ -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."""
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The path converters for the journal entry management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
|
||||
from flask import abort
|
||||
from werkzeug.routing import BaseConverter
|
||||
@ -82,18 +82,18 @@ class DateConverter(BaseConverter):
|
||||
"""The date converter to convert the ISO date from and to the
|
||||
corresponding date in the routes."""
|
||||
|
||||
def to_python(self, value: str) -> date:
|
||||
def to_python(self, value: str) -> dt.date:
|
||||
"""Converts an ISO date to a date.
|
||||
|
||||
:param value: The ISO date.
|
||||
:return: The corresponding date.
|
||||
"""
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
return dt.date.fromisoformat(value)
|
||||
except ValueError:
|
||||
abort(404)
|
||||
|
||||
def to_url(self, value: date) -> str:
|
||||
def to_url(self, value: dt.date) -> str:
|
||||
"""Converts a date to its ISO date.
|
||||
|
||||
:param value: The date.
|
||||
|
@ -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):
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The line item sub-forms for the journal entry management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -307,7 +307,7 @@ class LineItemForm(FlaskForm):
|
||||
return getattr(self, "____original_line_item")
|
||||
|
||||
@property
|
||||
def original_line_item_date(self) -> date | None:
|
||||
def original_line_item_date(self) -> dt.date | None:
|
||||
"""Returns the text representation of the original line item.
|
||||
|
||||
:return: The text representation of the original line item.
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The reorder forms for the journal entry management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import request
|
||||
@ -26,17 +26,15 @@ from accounting import db
|
||||
from accounting.models import JournalEntry
|
||||
|
||||
|
||||
def sort_journal_entries_in(journal_entry_date: date,
|
||||
exclude: int | None = None) -> None:
|
||||
def sort_journal_entries_in(date: dt.date, exclude: int | None = None) -> None:
|
||||
"""Sorts the journal entries under a date after changing the date or
|
||||
deleting a journal entry.
|
||||
|
||||
:param journal_entry_date: The date of the journal entry.
|
||||
:param date: The date of the journal entry.
|
||||
:param exclude: The journal entry ID to exclude.
|
||||
:return: None.
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.date == journal_entry_date]
|
||||
conditions: list[sa.BinaryExpression] = [JournalEntry.date == date]
|
||||
if exclude is not None:
|
||||
conditions.append(JournalEntry.id != exclude)
|
||||
journal_entries: list[JournalEntry] = JournalEntry.query\
|
||||
@ -50,12 +48,12 @@ def sort_journal_entries_in(journal_entry_date: date,
|
||||
class JournalEntryReorderForm:
|
||||
"""The form to reorder the journal entries."""
|
||||
|
||||
def __init__(self, journal_entry_date: date):
|
||||
def __init__(self, date: dt.date):
|
||||
"""Constructs the form to reorder the journal entries in a day.
|
||||
|
||||
:param journal_entry_date: The date.
|
||||
:param date: The date.
|
||||
"""
|
||||
self.date: date = journal_entry_date
|
||||
self.date: dt.date = date
|
||||
self.is_modified: bool = False
|
||||
|
||||
def save_order(self) -> None:
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The views for the journal entry management.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -30,9 +30,9 @@ from accounting.locale import lazy_gettext
|
||||
from accounting.models import JournalEntry
|
||||
from accounting.utils.cast import s
|
||||
from accounting.utils.flash_errors import flash_form_errors
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from accounting.utils.next_uri import inherit_next, or_next
|
||||
from accounting.utils.permission import has_permission, can_view, can_edit
|
||||
from accounting.utils.journal_entry_types import JournalEntryType
|
||||
from accounting.utils.user import get_current_user_pk
|
||||
from .forms import sort_journal_entries_in, JournalEntryReorderForm
|
||||
from .template_filters import with_type, to_transfer, format_amount_input, \
|
||||
@ -67,7 +67,7 @@ def show_add_journal_entry_form(journal_entry_type: JournalEntryType) -> str:
|
||||
form.validate()
|
||||
else:
|
||||
form = journal_entry_op.form()
|
||||
form.date.data = date.today()
|
||||
form.date.data = dt.date.today()
|
||||
return journal_entry_op.render_create_template(form)
|
||||
|
||||
|
||||
@ -186,31 +186,31 @@ def delete_journal_entry(journal_entry: JournalEntry) -> redirect:
|
||||
return redirect(or_next(__get_default_page_uri()))
|
||||
|
||||
|
||||
@bp.get("dates/<date:journal_entry_date>", endpoint="order")
|
||||
@bp.get("dates/<date:date>", endpoint="order")
|
||||
@has_permission(can_view)
|
||||
def show_journal_entry_order(journal_entry_date: date) -> str:
|
||||
def show_journal_entry_order(date: dt.date) -> str:
|
||||
"""Shows the order of the journal entries in a same date.
|
||||
|
||||
:param journal_entry_date: The date.
|
||||
:param date: The date.
|
||||
:return: The order of the journal entries in the date.
|
||||
"""
|
||||
journal_entries: list[JournalEntry] = JournalEntry.query \
|
||||
.filter(JournalEntry.date == journal_entry_date) \
|
||||
.filter(JournalEntry.date == date) \
|
||||
.order_by(JournalEntry.no).all()
|
||||
return render_template("accounting/journal-entry/order.html",
|
||||
date=journal_entry_date, list=journal_entries)
|
||||
date=date, list=journal_entries)
|
||||
|
||||
|
||||
@bp.post("dates/<date:journal_entry_date>", endpoint="sort")
|
||||
@bp.post("dates/<date:date>", endpoint="sort")
|
||||
@has_permission(can_edit)
|
||||
def sort_journal_entries(journal_entry_date: date) -> redirect:
|
||||
def sort_journal_entries(date: dt.date) -> redirect:
|
||||
"""Reorders the journal entries in a date.
|
||||
|
||||
:param journal_entry_date: The date.
|
||||
:param date: The date.
|
||||
:return: The redirection to the incoming account or the account list. The
|
||||
reordering operation does not fail.
|
||||
"""
|
||||
form: JournalEntryReorderForm = JournalEntryReorderForm(journal_entry_date)
|
||||
form: JournalEntryReorderForm = JournalEntryReorderForm(date)
|
||||
form.save_order()
|
||||
if not form.is_modified:
|
||||
flash(s(lazy_gettext("The order was not modified.")), "success")
|
||||
|
@ -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
|
||||
@ -117,21 +117,21 @@ class Account(db.Model):
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
"""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 creator."""
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
"""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 updator."""
|
||||
"""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."""
|
||||
@ -182,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
|
||||
@ -222,13 +224,13 @@ class Account(db.Model):
|
||||
return getattr(self, "__count")
|
||||
|
||||
@count.setter
|
||||
def count(self, count: int) -> None:
|
||||
def count(self, value: int) -> None:
|
||||
"""Sets the number of items in the account.
|
||||
|
||||
:param count: The number of items in the account.
|
||||
:param value: The number of items in the account.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__count", count)
|
||||
setattr(self, "__count", value)
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
@ -267,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.
|
||||
@ -284,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.
|
||||
|
||||
@ -307,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.
|
||||
|
||||
@ -329,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
|
||||
@ -337,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
|
||||
@ -373,22 +375,22 @@ class Currency(db.Model):
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
"""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 creator."""
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
"""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 updator."""
|
||||
"""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()
|
||||
|
||||
|
||||
@ -546,21 +550,21 @@ class JournalEntry(db.Model):
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
"""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 creator."""
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
"""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 updator."""
|
||||
"""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."""
|
||||
@ -735,13 +739,13 @@ class JournalEntryLineItem(db.Model):
|
||||
return getattr(self, "__debit")
|
||||
|
||||
@debit.setter
|
||||
def debit(self, debit: Decimal | None) -> None:
|
||||
def debit(self, value: Decimal | None) -> None:
|
||||
"""Sets the debit amount.
|
||||
|
||||
:param debit: The debit amount.
|
||||
:param value: The debit amount.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__debit", debit)
|
||||
setattr(self, "__debit", value)
|
||||
|
||||
@property
|
||||
def credit(self) -> Decimal | None:
|
||||
@ -754,13 +758,13 @@ class JournalEntryLineItem(db.Model):
|
||||
return getattr(self, "__credit")
|
||||
|
||||
@credit.setter
|
||||
def credit(self, credit: Decimal | None) -> None:
|
||||
def credit(self, value: Decimal | None) -> None:
|
||||
"""Sets the credit amount.
|
||||
|
||||
:param credit: The credit amount.
|
||||
:param value: The credit amount.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__credit", credit)
|
||||
setattr(self, "__credit", value)
|
||||
|
||||
@property
|
||||
def net_balance(self) -> Decimal:
|
||||
@ -775,42 +779,42 @@ class JournalEntryLineItem(db.Model):
|
||||
return getattr(self, "__net_balance")
|
||||
|
||||
@net_balance.setter
|
||||
def net_balance(self, net_balance: Decimal) -> None:
|
||||
def net_balance(self, value: Decimal) -> None:
|
||||
"""Sets the net balance.
|
||||
|
||||
:param net_balance: The net balance.
|
||||
:param value: The net balance.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__net_balance", net_balance)
|
||||
setattr(self, "__net_balance", value)
|
||||
|
||||
@property
|
||||
def balance(self) -> Decimal:
|
||||
"""Returns the net balance.
|
||||
"""Returns the balance.
|
||||
|
||||
:return: The net balance.
|
||||
:return: The balance.
|
||||
"""
|
||||
if not hasattr(self, "__balance"):
|
||||
setattr(self, "__balance", Decimal("0"))
|
||||
return getattr(self, "__balance")
|
||||
|
||||
@balance.setter
|
||||
def balance(self, balance: Decimal) -> None:
|
||||
"""Sets the net balance.
|
||||
def balance(self, value: Decimal) -> None:
|
||||
"""Sets the balance.
|
||||
|
||||
:param balance: The net balance.
|
||||
:param value: The balance.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__balance", balance)
|
||||
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()
|
||||
@ -828,17 +832,16 @@ class JournalEntryLineItem(db.Model):
|
||||
return getattr(self, "__is_offset")
|
||||
|
||||
@is_offset.setter
|
||||
def is_offset(self, is_offset: bool) -> None:
|
||||
def is_offset(self, value: bool) -> None:
|
||||
"""Sets whether the line item is an offset.
|
||||
|
||||
:param is_offset: True if the line item is an offset, or False
|
||||
otherwise.
|
||||
:param value: True if the line item is an offset, or False otherwise.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__is_offset", is_offset)
|
||||
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.
|
||||
@ -848,13 +851,13 @@ class JournalEntryLineItem(db.Model):
|
||||
return getattr(self, "__match")
|
||||
|
||||
@match.setter
|
||||
def match(self, match: t.Self) -> None:
|
||||
def match(self, value: Self) -> None:
|
||||
"""Sets the match of the line item.
|
||||
|
||||
:param match: The matcho of the line item.
|
||||
:param value: The matcho of the line item.
|
||||
:return: None.
|
||||
"""
|
||||
setattr(self, "__match", match)
|
||||
setattr(self, "__match", value)
|
||||
|
||||
@property
|
||||
def query_values(self) -> list[str]:
|
||||
@ -886,18 +889,18 @@ class Option(db.Model):
|
||||
created_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of creation."""
|
||||
"""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 creator."""
|
||||
"""The ID of the user who created the record."""
|
||||
created_by: Mapped[user_cls] = db.relationship(foreign_keys=created_by_id)
|
||||
"""The creator."""
|
||||
"""The user who created the record."""
|
||||
updated_at: Mapped[dt.datetime] \
|
||||
= mapped_column(db.DateTime(timezone=True),
|
||||
server_default=db.func.now())
|
||||
"""The time of last update."""
|
||||
"""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 updator."""
|
||||
"""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."""
|
||||
|
@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
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
|
||||
@ -63,10 +63,10 @@ class PeriodChooser:
|
||||
|
||||
first: JournalEntry | None \
|
||||
= JournalEntry.query.order_by(JournalEntry.date).first()
|
||||
start: date | None = None if first is None else first.date
|
||||
start: dt.date | None = None if first is None else first.date
|
||||
|
||||
# Attributes
|
||||
self.data_start: date | None = start
|
||||
self.data_start: dt.date | None = start
|
||||
"""The start of the data."""
|
||||
self.has_data: bool = start is not None
|
||||
"""Whether there is any data."""
|
||||
@ -80,8 +80,8 @@ class PeriodChooser:
|
||||
"""The available years."""
|
||||
|
||||
if self.has_data:
|
||||
today: date = date.today()
|
||||
self.has_last_month = start < date(today.year, today.month, 1)
|
||||
today: dt.date = dt.date.today()
|
||||
self.has_last_month = start < dt.date(today.year, today.month, 1)
|
||||
self.has_last_year = start.year < today.year
|
||||
self.has_yesterday = start < today
|
||||
if start.year < today.year - 1:
|
||||
|
@ -17,12 +17,12 @@
|
||||
"""The period description composer.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
import datetime as dt
|
||||
|
||||
from accounting.locale import gettext
|
||||
|
||||
|
||||
def get_desc(start: date | None, end: date | None) -> str:
|
||||
def get_desc(start: dt.date | None, end: dt.date | None) -> str:
|
||||
"""Returns the period description.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -46,7 +46,7 @@ def get_desc(start: date | None, end: date | None) -> str:
|
||||
return __get_day_desc(start, end)
|
||||
|
||||
|
||||
def __get_since_desc(start: date) -> str:
|
||||
def __get_since_desc(start: dt.date) -> str:
|
||||
"""Returns the description without the end day.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -67,7 +67,7 @@ def __get_since_desc(start: date) -> str:
|
||||
return gettext("since %(start)s", start=get_start_desc())
|
||||
|
||||
|
||||
def __get_until_desc(end: date) -> str:
|
||||
def __get_until_desc(end: dt.date) -> str:
|
||||
"""Returns the description without the start day.
|
||||
|
||||
:param end: The end of the period.
|
||||
@ -81,14 +81,14 @@ def __get_until_desc(end: date) -> str:
|
||||
"""
|
||||
if end.month == 12 and end.day == 31:
|
||||
return str(end.year)
|
||||
if (end + timedelta(days=1)).day == 1:
|
||||
if (end + dt.timedelta(days=1)).day == 1:
|
||||
return __format_month(end)
|
||||
return __format_day(end)
|
||||
|
||||
return gettext("until %(end)s", end=get_end_desc())
|
||||
|
||||
|
||||
def __get_year_desc(start: date, end: date) -> str:
|
||||
def __get_year_desc(start: dt.date, end: dt.date) -> str:
|
||||
"""Returns the description as a year range.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -105,7 +105,7 @@ def __get_year_desc(start: date, end: date) -> str:
|
||||
return __get_from_to_desc(start_text, str(end.year))
|
||||
|
||||
|
||||
def __get_month_desc(start: date, end: date) -> str:
|
||||
def __get_month_desc(start: dt.date, end: dt.date) -> str:
|
||||
"""Returns the description as a month range.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -113,7 +113,7 @@ def __get_month_desc(start: date, end: date) -> str:
|
||||
:return: The description as a month range.
|
||||
:raise ValueError: The period is not a month range.
|
||||
"""
|
||||
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
||||
if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
|
||||
raise ValueError
|
||||
start_text: str = __format_month(start)
|
||||
if start.year == end.year and start.month == end.month:
|
||||
@ -123,7 +123,7 @@ def __get_month_desc(start: date, end: date) -> str:
|
||||
return __get_from_to_desc(start_text, __format_month(end))
|
||||
|
||||
|
||||
def __get_day_desc(start: date, end: date) -> str:
|
||||
def __get_day_desc(start: dt.date, end: dt.date) -> str:
|
||||
"""Returns the description as a day range.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -142,7 +142,7 @@ def __get_day_desc(start: date, end: date) -> str:
|
||||
return __get_from_to_desc(start_text, __format_day(end))
|
||||
|
||||
|
||||
def __format_month(month: date) -> str:
|
||||
def __format_month(month: dt.date) -> str:
|
||||
"""Formats a month.
|
||||
|
||||
:param month: The month.
|
||||
@ -151,7 +151,7 @@ def __format_month(month: date) -> str:
|
||||
return f"{month.year}/{month.month}"
|
||||
|
||||
|
||||
def __format_day(day: date) -> str:
|
||||
def __format_day(day: dt.date) -> str:
|
||||
"""Formats a day.
|
||||
|
||||
:param day: The day.
|
||||
|
@ -18,14 +18,14 @@
|
||||
|
||||
"""
|
||||
import calendar
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
|
||||
|
||||
def month_end(day: date) -> date:
|
||||
def month_end(day: dt.date) -> dt.date:
|
||||
"""Returns the end day of month for a date.
|
||||
|
||||
:param day: The date.
|
||||
:return: The end day of the month of that day.
|
||||
"""
|
||||
last_day: int = calendar.monthrange(day.year, day.month)[1]
|
||||
return date(day.year, day.month, last_day)
|
||||
return dt.date(day.year, day.month, last_day)
|
||||
|
@ -18,9 +18,10 @@
|
||||
|
||||
"""
|
||||
import calendar
|
||||
import datetime as dt
|
||||
import re
|
||||
import typing as t
|
||||
from datetime import date
|
||||
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(),
|
||||
@ -57,7 +58,7 @@ def get_period(spec: str | None = None) -> Period:
|
||||
return Period(start, end)
|
||||
|
||||
|
||||
def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
||||
def __parse_spec(text: str) -> tuple[dt.date | None, dt.date | None]:
|
||||
"""Parses the period specification.
|
||||
|
||||
:param text: The period specification.
|
||||
@ -84,7 +85,7 @@ def __parse_spec(text: str) -> tuple[date | None, date | None]:
|
||||
raise ValueError
|
||||
|
||||
|
||||
def __get_start(year: str, month: str | None, day: str | None) -> date:
|
||||
def __get_start(year: str, month: str | None, day: str | None) -> dt.date:
|
||||
"""Returns the start of the period from the date representation.
|
||||
|
||||
:param year: The year.
|
||||
@ -94,13 +95,13 @@ def __get_start(year: str, month: str | None, day: str | None) -> date:
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if day is not None:
|
||||
return date(int(year), int(month), int(day))
|
||||
return dt.date(int(year), int(month), int(day))
|
||||
if month is not None:
|
||||
return date(int(year), int(month), 1)
|
||||
return date(int(year), 1, 1)
|
||||
return dt.date(int(year), int(month), 1)
|
||||
return dt.date(int(year), 1, 1)
|
||||
|
||||
|
||||
def __get_end(year: str, month: str | None, day: str | None) -> date:
|
||||
def __get_end(year: str, month: str | None, day: str | None) -> dt.date:
|
||||
"""Returns the end of the period from the date representation.
|
||||
|
||||
:param year: The year.
|
||||
@ -110,10 +111,10 @@ def __get_end(year: str, month: str | None, day: str | None) -> date:
|
||||
:raise ValueError: When the date is invalid.
|
||||
"""
|
||||
if day is not None:
|
||||
return date(int(year), int(month), int(day))
|
||||
return dt.date(int(year), int(month), int(day))
|
||||
if month is not None:
|
||||
year_n: int = int(year)
|
||||
month_n: int = int(month)
|
||||
day_n: int = calendar.monthrange(year_n, month_n)[1]
|
||||
return date(year_n, month_n, day_n)
|
||||
return date(int(year), 12, 31)
|
||||
return dt.date(year_n, month_n, day_n)
|
||||
return dt.date(int(year), 12, 31)
|
||||
|
@ -20,8 +20,8 @@ This file is largely taken from the NanoParma ERP project, first written in
|
||||
2021/9/16 by imacat (imacat@nanoparma.com).
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from datetime import date, timedelta
|
||||
import datetime as dt
|
||||
from typing import Self
|
||||
|
||||
from .description import get_desc
|
||||
from .month_end import month_end
|
||||
@ -31,15 +31,15 @@ from .specification import get_spec
|
||||
class Period:
|
||||
"""A date period."""
|
||||
|
||||
def __init__(self, start: date | None, end: date | None):
|
||||
def __init__(self, start: dt.date | None, end: dt.date | None):
|
||||
"""Constructs a new date period.
|
||||
|
||||
:param start: The start date, or None from the very beginning.
|
||||
:param end: The end date, or None till no end.
|
||||
"""
|
||||
self.start: date | None = start
|
||||
self.start: dt.date | None = start
|
||||
"""The start of the period."""
|
||||
self.end: date | None = end
|
||||
self.end: dt.date | None = end
|
||||
"""The end of the period."""
|
||||
self.is_default: bool = False
|
||||
"""Whether the is the default period."""
|
||||
@ -95,8 +95,8 @@ class Period:
|
||||
self.is_a_month = self.start.day == 1 \
|
||||
and self.end == month_end(self.start)
|
||||
self.is_type_month = self.is_a_month
|
||||
self.is_a_year = self.start == date(self.start.year, 1, 1) \
|
||||
and self.end == date(self.start.year, 12, 31)
|
||||
self.is_a_year = self.start == dt.date(self.start.year, 1, 1) \
|
||||
and self.end == dt.date(self.start.year, 12, 31)
|
||||
self.is_a_day = self.start == self.end
|
||||
|
||||
def is_year(self, year: int) -> bool:
|
||||
@ -119,11 +119,11 @@ 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.
|
||||
"""
|
||||
if self.start is None:
|
||||
return None
|
||||
return Period(None, self.start - timedelta(days=1))
|
||||
return Period(None, self.start - dt.timedelta(days=1))
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The named shortcut periods.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
import datetime as dt
|
||||
|
||||
from accounting.locale import gettext
|
||||
from .month_end import month_end
|
||||
@ -27,8 +27,8 @@ from .period import Period
|
||||
class ThisMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
this_month_start: date = date(today.year, today.month, 1)
|
||||
today: dt.date = dt.date.today()
|
||||
this_month_start: dt.date = dt.date(today.year, today.month, 1)
|
||||
super().__init__(this_month_start, month_end(today))
|
||||
self.is_default = True
|
||||
self.is_this_month = True
|
||||
@ -43,13 +43,13 @@ class ThisMonth(Period):
|
||||
class LastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
today: dt.date = dt.date.today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
year = year - 1
|
||||
month = 12
|
||||
start: date = date(year, month, 1)
|
||||
start: dt.date = dt.date(year, month, 1)
|
||||
super().__init__(start, month_end(start))
|
||||
self.is_last_month = True
|
||||
|
||||
@ -63,13 +63,13 @@ class LastMonth(Period):
|
||||
class SinceLastMonth(Period):
|
||||
"""The period of this month."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
today: dt.date = dt.date.today()
|
||||
year: int = today.year
|
||||
month: int = today.month - 1
|
||||
if month < 1:
|
||||
year = year - 1
|
||||
month = 12
|
||||
start: date = date(year, month, 1)
|
||||
start: dt.date = dt.date(year, month, 1)
|
||||
super().__init__(start, None)
|
||||
self.is_since_last_month = True
|
||||
|
||||
@ -82,9 +82,9 @@ class SinceLastMonth(Period):
|
||||
class ThisYear(Period):
|
||||
"""The period of this year."""
|
||||
def __init__(self):
|
||||
year: int = date.today().year
|
||||
start: date = date(year, 1, 1)
|
||||
end: date = date(year, 12, 31)
|
||||
year: int = dt.date.today().year
|
||||
start: dt.date = dt.date(year, 1, 1)
|
||||
end: dt.date = dt.date(year, 12, 31)
|
||||
super().__init__(start, end)
|
||||
self.is_this_year = True
|
||||
|
||||
@ -97,9 +97,9 @@ class ThisYear(Period):
|
||||
class LastYear(Period):
|
||||
"""The period of last year."""
|
||||
def __init__(self):
|
||||
year: int = date.today().year
|
||||
start: date = date(year - 1, 1, 1)
|
||||
end: date = date(year - 1, 12, 31)
|
||||
year: int = dt.date.today().year
|
||||
start: dt.date = dt.date(year - 1, 1, 1)
|
||||
end: dt.date = dt.date(year - 1, 12, 31)
|
||||
super().__init__(start, end)
|
||||
self.is_last_year = True
|
||||
|
||||
@ -112,7 +112,7 @@ class LastYear(Period):
|
||||
class Today(Period):
|
||||
"""The period of today."""
|
||||
def __init__(self):
|
||||
today: date = date.today()
|
||||
today: dt.date = dt.date.today()
|
||||
super().__init__(today, today)
|
||||
self.is_today = True
|
||||
|
||||
@ -125,7 +125,7 @@ class Today(Period):
|
||||
class Yesterday(Period):
|
||||
"""The period of yesterday."""
|
||||
def __init__(self):
|
||||
yesterday: date = date.today() - timedelta(days=1)
|
||||
yesterday: dt.date = dt.date.today() - dt.timedelta(days=1)
|
||||
super().__init__(yesterday, yesterday)
|
||||
self.is_yesterday = True
|
||||
|
||||
@ -163,6 +163,6 @@ class YearPeriod(Period):
|
||||
|
||||
:param year: The year.
|
||||
"""
|
||||
start: date = date(year, 1, 1)
|
||||
end: date = date(year, 12, 31)
|
||||
start: dt.date = dt.date(year, 1, 1)
|
||||
end: dt.date = dt.date(year, 12, 31)
|
||||
super().__init__(start, end)
|
||||
|
@ -17,10 +17,10 @@
|
||||
"""The period specification composer.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
import datetime as dt
|
||||
|
||||
|
||||
def get_spec(start: date | None, end: date | None) -> str:
|
||||
def get_spec(start: dt.date | None, end: dt.date | None) -> str:
|
||||
"""Returns the period specification.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -44,7 +44,7 @@ def get_spec(start: date | None, end: date | None) -> str:
|
||||
return __get_day_spec(start, end)
|
||||
|
||||
|
||||
def __get_since_spec(start: date) -> str:
|
||||
def __get_since_spec(start: dt.date) -> str:
|
||||
"""Returns the period specification without the end day.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -57,7 +57,7 @@ def __get_since_spec(start: date) -> str:
|
||||
return start.strftime("%Y-%m-%d-")
|
||||
|
||||
|
||||
def __get_until_spec(end: date) -> str:
|
||||
def __get_until_spec(end: dt.date) -> str:
|
||||
"""Returns the period specification without the start day.
|
||||
|
||||
:param end: The end of the period.
|
||||
@ -65,12 +65,12 @@ def __get_until_spec(end: date) -> str:
|
||||
"""
|
||||
if end.month == 12 and end.day == 31:
|
||||
return end.strftime("-%Y")
|
||||
if (end + timedelta(days=1)).day == 1:
|
||||
if (end + dt.timedelta(days=1)).day == 1:
|
||||
return end.strftime("-%Y-%m")
|
||||
return end.strftime("-%Y-%m-%d")
|
||||
|
||||
|
||||
def __get_year_spec(start: date, end: date) -> str:
|
||||
def __get_year_spec(start: dt.date, end: dt.date) -> str:
|
||||
"""Returns the period specification as a year range.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -88,7 +88,7 @@ def __get_year_spec(start: date, end: date) -> str:
|
||||
return f"{start_spec}-{end_spec}"
|
||||
|
||||
|
||||
def __get_month_spec(start: date, end: date) -> str:
|
||||
def __get_month_spec(start: dt.date, end: dt.date) -> str:
|
||||
"""Returns the period specification as a month range.
|
||||
|
||||
:param start: The start of the period.
|
||||
@ -96,7 +96,7 @@ def __get_month_spec(start: date, end: date) -> str:
|
||||
:return: The period specification as a month range.
|
||||
:raise ValueError: The period is not a month range.
|
||||
"""
|
||||
if start.day != 1 or (end + timedelta(days=1)).day != 1:
|
||||
if start.day != 1 or (end + dt.timedelta(days=1)).day != 1:
|
||||
raise ValueError
|
||||
start_spec: str = start.strftime("%Y-%m")
|
||||
if start.year == end.year and start.month == end.month:
|
||||
@ -105,7 +105,7 @@ def __get_month_spec(start: date, end: date) -> str:
|
||||
return f"{start_spec}-{end_spec}"
|
||||
|
||||
|
||||
def __get_day_spec(start: date, end: date) -> str:
|
||||
def __get_day_spec(start: dt.date, end: dt.date) -> str:
|
||||
"""Returns the period specification as a day range.
|
||||
|
||||
:param start: The start of the period.
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The income and expenses log.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -53,7 +53,7 @@ class ReportLineItem:
|
||||
"""Whether this is the brought-forward line item."""
|
||||
self.is_total: bool = False
|
||||
"""Whether this is the total line item."""
|
||||
self.date: date | None = None
|
||||
self.date: dt.date | None = None
|
||||
"""The date."""
|
||||
self.account: Account | None = None
|
||||
"""The account."""
|
||||
@ -213,7 +213,7 @@ class LineItemCollector:
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: date | str | None,
|
||||
def __init__(self, date: dt.date | str | None,
|
||||
account: str | None,
|
||||
description: str | None,
|
||||
income: str | Decimal | None,
|
||||
@ -222,7 +222,7 @@ class CSVRow(BaseCSVRow):
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param journal_entry_date: The journal entry date.
|
||||
:param date: The journal entry date.
|
||||
:param account: The account.
|
||||
:param description: The description.
|
||||
:param income: The income.
|
||||
@ -230,7 +230,7 @@ class CSVRow(BaseCSVRow):
|
||||
:param balance: The balance.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: date | str | None = journal_entry_date
|
||||
self.date: dt.date | str | None = date
|
||||
"""The date."""
|
||||
self.account: str | None = account
|
||||
"""The account."""
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The journal.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -67,7 +67,7 @@ class ReportLineItem:
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: str | date,
|
||||
def __init__(self, journal_entry_date: str | dt.date,
|
||||
currency: str,
|
||||
account: str,
|
||||
description: str | None,
|
||||
@ -84,7 +84,7 @@ class CSVRow(BaseCSVRow):
|
||||
:param credit: The credit amount.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: str | date = journal_entry_date
|
||||
self.date: str | dt.date = journal_entry_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The ledger.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -52,7 +52,7 @@ class ReportLineItem:
|
||||
"""Whether this is the brought-forward line item."""
|
||||
self.is_total: bool = False
|
||||
"""Whether this is the total line item."""
|
||||
self.date: date | None = None
|
||||
self.date: dt.date | None = None
|
||||
"""The date."""
|
||||
self.description: str | None = None
|
||||
"""The description."""
|
||||
@ -196,7 +196,7 @@ class LineItemCollector:
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: date | str | None,
|
||||
def __init__(self, date: dt.date | str | None,
|
||||
description: str | None,
|
||||
debit: str | Decimal | None,
|
||||
credit: str | Decimal | None,
|
||||
@ -204,14 +204,14 @@ class CSVRow(BaseCSVRow):
|
||||
note: str | None):
|
||||
"""Constructs a row in the CSV.
|
||||
|
||||
:param journal_entry_date: The journal entry date.
|
||||
:param date: The journal entry date.
|
||||
:param description: The description.
|
||||
:param debit: The debit amount.
|
||||
:param credit: The credit amount.
|
||||
:param balance: The balance.
|
||||
:param note: The note.
|
||||
"""
|
||||
self.date: date | str | None = journal_entry_date
|
||||
self.date: dt.date | str | None = date
|
||||
"""The date."""
|
||||
self.description: str | None = description
|
||||
"""The description."""
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The search.
|
||||
|
||||
"""
|
||||
from datetime import datetime
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
@ -124,40 +124,33 @@ class LineItemCollector:
|
||||
"""
|
||||
conditions: list[sa.BinaryExpression] \
|
||||
= [JournalEntry.note.icontains(k)]
|
||||
journal_entry_date: datetime
|
||||
date: dt.datetime
|
||||
try:
|
||||
journal_entry_date = datetime.strptime(k, "%Y")
|
||||
conditions.append(sa.extract("year", JournalEntry.date)
|
||||
== journal_entry_date.year)
|
||||
date = dt.datetime.strptime(k, "%Y")
|
||||
conditions.append(
|
||||
sa.extract("year", JournalEntry.date) == date.year)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
journal_entry_date = datetime.strptime(k, "%Y/%m")
|
||||
date = dt.datetime.strptime(k, "%Y/%m")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("year", JournalEntry.date)
|
||||
== journal_entry_date.year,
|
||||
sa.extract("month", JournalEntry.date)
|
||||
== journal_entry_date.month))
|
||||
sa.extract("year", JournalEntry.date) == date.year,
|
||||
sa.extract("month", JournalEntry.date) == date.month))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
journal_entry_date = datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||
date = dt.datetime.strptime(f"2000/{k}", "%Y/%m/%d")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("month", JournalEntry.date)
|
||||
== journal_entry_date.month,
|
||||
sa.extract("day", JournalEntry.date)
|
||||
== journal_entry_date.day))
|
||||
sa.extract("month", JournalEntry.date) == date.month,
|
||||
sa.extract("day", JournalEntry.date) == date.day))
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
journal_entry_date = datetime.strptime(k, "%Y/%m/%d")
|
||||
date = dt.datetime.strptime(k, "%Y/%m/%d")
|
||||
conditions.append(sa.and_(
|
||||
sa.extract("year", JournalEntry.date)
|
||||
== journal_entry_date.year,
|
||||
sa.extract("month", JournalEntry.date)
|
||||
== journal_entry_date.month,
|
||||
sa.extract("day", JournalEntry.date)
|
||||
== journal_entry_date.day))
|
||||
sa.extract("year", JournalEntry.date) == date.year,
|
||||
sa.extract("month", JournalEntry.date) == date.month,
|
||||
sa.extract("day", JournalEntry.date) == date.day))
|
||||
except ValueError:
|
||||
pass
|
||||
return sa.select(JournalEntry.id).filter(sa.or_(*conditions))
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The unapplied original line items.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
@ -41,7 +41,7 @@ from accounting.utils.pagination import Pagination
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: str | date, currency: str,
|
||||
def __init__(self, journal_entry_date: str | dt.date, currency: str,
|
||||
description: str | None, amount: str | Decimal,
|
||||
net_balance: str | Decimal):
|
||||
"""Constructs a row in the CSV.
|
||||
@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
|
||||
:param amount: The amount.
|
||||
:param net_balance: The net balance.
|
||||
"""
|
||||
self.date: str | date = journal_entry_date
|
||||
self.date: str | dt.date = journal_entry_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
@ -64,7 +64,7 @@ class CSVRow(BaseCSVRow):
|
||||
"""The net balance."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The accounts with unapplied original line items.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
|
||||
"""The number of unapplied original line items."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
@ -143,7 +143,7 @@ class AccountsWithUnappliedOriginalLineItems(BaseReport):
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"unapplied-accounts.csv"
|
||||
filename: str = "unapplied-accounts.csv"
|
||||
return csv_download(filename, get_csv_rows(self.__accounts))
|
||||
|
||||
def html(self) -> str:
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The unmatched offsets.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
@ -40,7 +40,7 @@ from accounting.utils.pagination import Pagination
|
||||
class CSVRow(BaseCSVRow):
|
||||
"""A row in the CSV."""
|
||||
|
||||
def __init__(self, journal_entry_date: str | date, currency: str,
|
||||
def __init__(self, journal_entry_date: str | dt.date, currency: str,
|
||||
description: str | None, debit: str | Decimal,
|
||||
credit: str | Decimal, balance: str | Decimal):
|
||||
"""Constructs a row in the CSV.
|
||||
@ -52,7 +52,7 @@ class CSVRow(BaseCSVRow):
|
||||
:param credit: The credit amount.
|
||||
:param balance: The balance.
|
||||
"""
|
||||
self.date: str | date = journal_entry_date
|
||||
self.date: str | dt.date = journal_entry_date
|
||||
"""The date."""
|
||||
self.currency: str = currency
|
||||
"""The currency."""
|
||||
@ -66,7 +66,7 @@ class CSVRow(BaseCSVRow):
|
||||
"""The balance."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The accounts with unmatched offsets.
|
||||
|
||||
"""
|
||||
from datetime import date
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
|
||||
from flask import render_template, Response
|
||||
@ -49,7 +49,7 @@ class CSVRow(BaseCSVRow):
|
||||
"""The number of unapplied original line items."""
|
||||
|
||||
@property
|
||||
def values(self) -> list[str | date | Decimal | None]:
|
||||
def values(self) -> list[str | dt.date | Decimal | None]:
|
||||
"""Returns the values of the row.
|
||||
|
||||
:return: The values of the row.
|
||||
@ -144,7 +144,7 @@ class AccountsWithUnmatchedOffsets(BaseReport):
|
||||
|
||||
:return: The response of the report for download.
|
||||
"""
|
||||
filename: str = f"unapplied-accounts.csv"
|
||||
filename: str = "unmatched-accounts.csv"
|
||||
return csv_download(filename, get_csv_rows(self.__accounts))
|
||||
|
||||
def html(self) -> str:
|
||||
|
@ -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.
|
||||
|
||||
|
@ -18,8 +18,8 @@
|
||||
|
||||
"""
|
||||
import csv
|
||||
import datetime as dt
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import timedelta, date
|
||||
from decimal import Decimal
|
||||
from io import StringIO
|
||||
from urllib.parse import quote
|
||||
@ -77,7 +77,7 @@ def period_spec(period: Period) -> str:
|
||||
return f"{start}-{end}"
|
||||
|
||||
|
||||
def __get_start_str(start: date | None) -> str | None:
|
||||
def __get_start_str(start: dt.date | None) -> str | None:
|
||||
"""Returns the string representation of the start date.
|
||||
|
||||
:param start: The start date.
|
||||
@ -93,7 +93,7 @@ def __get_start_str(start: date | None) -> str | None:
|
||||
return start.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def __get_end_str(end: date | None) -> str | None:
|
||||
def __get_end_str(end: dt.date | None) -> str | None:
|
||||
"""Returns the string representation of the end date.
|
||||
|
||||
:param end: The end date.
|
||||
@ -104,6 +104,6 @@ def __get_end_str(end: date | None) -> str | None:
|
||||
return None
|
||||
if end.month == 12 and end.day == 31:
|
||||
return str(end.year)
|
||||
if (end + timedelta(days=1)).day == 1:
|
||||
if (end + dt.timedelta(days=1)).day == 1:
|
||||
return end.strftime("%Y%m")
|
||||
return end.strftime("%Y%m%d")
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
@ -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();
|
||||
|
@ -17,9 +17,9 @@
|
||||
"""The template filters.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from datetime import date, timedelta
|
||||
import datetime as dt
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from flask_babel import get_locale
|
||||
|
||||
@ -41,24 +41,24 @@ def format_amount(value: Decimal | None) -> str | None:
|
||||
return "{:,}".format(whole) + str(abs(frac))[1:]
|
||||
|
||||
|
||||
def format_date(value: date) -> str:
|
||||
def format_date(value: dt.date) -> str:
|
||||
"""Formats a date to be human-friendly.
|
||||
|
||||
:param value: The date.
|
||||
:return: The human-friendly date text.
|
||||
"""
|
||||
today: date = date.today()
|
||||
today: dt.date = dt.date.today()
|
||||
if value == today:
|
||||
return gettext("Today")
|
||||
if value == today - timedelta(days=1):
|
||||
if value == today - dt.timedelta(days=1):
|
||||
return gettext("Yesterday")
|
||||
if value == today + timedelta(days=1):
|
||||
if value == today + dt.timedelta(days=1):
|
||||
return gettext("Tomorrow")
|
||||
locale = str(get_locale())
|
||||
if locale == "zh" or locale.startswith("zh_"):
|
||||
if value == today - timedelta(days=2):
|
||||
if value == today - dt.timedelta(days=2):
|
||||
return gettext("The day before yesterday")
|
||||
if value == today + timedelta(days=2):
|
||||
if value == today + dt.timedelta(days=2):
|
||||
return gettext("The day after tomorrow")
|
||||
if locale == "zh" or locale.startswith("zh_"):
|
||||
weekdays = ["一", "二", "三", "四", "五", "六", "日"]
|
||||
@ -71,7 +71,7 @@ def format_date(value: 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.
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -36,7 +36,7 @@ First written: 2023/2/26
|
||||
{{ A_("Edit") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", journal_entry_date=obj.date)|accounting_append_next }}">
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.order", date=obj.date)|accounting_append_next }}">
|
||||
<i class="fa-solid fa-bars-staggered"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("Order") }}</span>
|
||||
</a>
|
||||
|
@ -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">
|
||||
|
@ -38,7 +38,7 @@ First written: 2023/2/26
|
||||
</div>
|
||||
|
||||
{% if list|length > 1 and accounting_can_edit() %}
|
||||
<form action="{{ url_for("accounting.journal-entry.sort", journal_entry_date=date) }}" method="post">
|
||||
<form action="{{ url_for("accounting.journal-entry.sort", date=date) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if request.args.next %}
|
||||
<input type="hidden" name="next" value="{{ request.args.next }}">
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
@ -14,18 +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
|
||||
|
||||
import sqlalchemy as sa
|
||||
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.
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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"))
|
||||
|
@ -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):
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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."""
|
||||
|
@ -17,12 +17,13 @@
|
||||
"""The test for the account management.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import unittest
|
||||
from datetime import timedelta, date
|
||||
|
||||
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)
|
||||
@ -461,7 +463,7 @@ class AccountTestCase(unittest.TestCase):
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertIsNotNone(account)
|
||||
account.created_at \
|
||||
= account.created_at - timedelta(seconds=5)
|
||||
= account.created_at - dt.timedelta(seconds=5)
|
||||
account.updated_at = account.created_at
|
||||
db.session.commit()
|
||||
|
||||
@ -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,8 +593,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
|
||||
add_journal_entry(self.client,
|
||||
form={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI,
|
||||
"date": date.today().isoformat(),
|
||||
"next": self.encoded_next_uri,
|
||||
"date": dt.date.today().isoformat(),
|
||||
"currency-1-code": "USD",
|
||||
"currency-1-credit-1-account_code": BANK.code,
|
||||
"currency-1-credit-1-amount": "20"})
|
||||
@ -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"})
|
||||
|
@ -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]
|
||||
|
@ -17,12 +17,13 @@
|
||||
"""The test for the currency management.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import unittest
|
||||
from datetime import timedelta, date
|
||||
|
||||
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
|
||||
@ -384,7 +385,7 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertIsNotNone(currency)
|
||||
currency.created_at \
|
||||
= currency.created_at - timedelta(seconds=5)
|
||||
= currency.created_at - dt.timedelta(seconds=5)
|
||||
currency.updated_at = currency.created_at
|
||||
db.session.commit()
|
||||
|
||||
@ -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,8 +536,8 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
|
||||
add_journal_entry(self.client,
|
||||
form={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI,
|
||||
"date": date.today().isoformat(),
|
||||
"next": encoded_next_uri,
|
||||
"date": dt.date.today().isoformat(),
|
||||
"currency-1-code": EUR.code,
|
||||
"currency-1-credit-1-account_code": "1111-001",
|
||||
"currency-1-credit-1-amount": "20"})
|
||||
|
@ -17,11 +17,12 @@
|
||||
"""The test for the description editor.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
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 = date.today().isoformat()
|
||||
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,
|
||||
|
@ -17,13 +17,14 @@
|
||||
"""The test for the journal entry management.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import unittest
|
||||
from datetime import date, timedelta
|
||||
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
|
||||
@ -500,7 +507,7 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
journal_entry = db.session.get(JournalEntry, journal_entry_id)
|
||||
self.assertIsNotNone(journal_entry)
|
||||
journal_entry.created_at \
|
||||
= journal_entry.created_at - timedelta(seconds=5)
|
||||
= journal_entry.created_at - dt.timedelta(seconds=5)
|
||||
journal_entry.updated_at = journal_entry.created_at
|
||||
db.session.commit()
|
||||
|
||||
@ -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,8 +584,8 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
add_journal_entry(
|
||||
self.client,
|
||||
form={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI,
|
||||
"date": date.today().isoformat(),
|
||||
"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,
|
||||
"currency-1-debit-1-account_code": line_item.account_code,
|
||||
@ -585,17 +594,18 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
|
||||
# Cannot delete the journal entry that is in use
|
||||
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
|
||||
@ -1112,7 +1130,7 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
|
||||
journal_entry = db.session.get(JournalEntry, journal_entry_id)
|
||||
self.assertIsNotNone(journal_entry)
|
||||
journal_entry.created_at \
|
||||
= journal_entry.created_at - timedelta(seconds=5)
|
||||
= journal_entry.created_at - dt.timedelta(seconds=5)
|
||||
journal_entry.updated_at = journal_entry.created_at
|
||||
db.session.commit()
|
||||
|
||||
@ -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
|
||||
@ -1773,7 +1801,7 @@ class TransferJournalEntryTestCase(unittest.TestCase):
|
||||
journal_entry = db.session.get(JournalEntry, journal_entry_id)
|
||||
self.assertIsNotNone(journal_entry)
|
||||
journal_entry.created_at \
|
||||
= journal_entry.created_at - timedelta(seconds=5)
|
||||
= journal_entry.created_at - dt.timedelta(seconds=5)
|
||||
journal_entry.updated_at = journal_entry.created_at
|
||||
db.session.commit()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -2124,9 +2158,9 @@ class JournalEntryReorderTestCase(unittest.TestCase):
|
||||
|
||||
with self.app.app_context():
|
||||
journal_entry_1: JournalEntry = db.session.get(JournalEntry, id_1)
|
||||
journal_entry_date_2: date = journal_entry_1.date
|
||||
journal_entry_date_1: date \
|
||||
= journal_entry_date_2 - timedelta(days=1)
|
||||
journal_entry_date_2: dt.date = journal_entry_1.date
|
||||
journal_entry_date_1: dt.date \
|
||||
= journal_entry_date_2 - dt.timedelta(days=1)
|
||||
journal_entry_1.date = journal_entry_date_1
|
||||
journal_entry_1.no = 3
|
||||
journal_entry_2: JournalEntry = db.session.get(JournalEntry, id_2)
|
||||
@ -2147,7 +2181,7 @@ class JournalEntryReorderTestCase(unittest.TestCase):
|
||||
response = self.client.post(f"{PREFIX}/{id_2}/update", data=form)
|
||||
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)
|
||||
@ -2176,19 +2210,19 @@ class JournalEntryReorderTestCase(unittest.TestCase):
|
||||
self.__get_add_disbursement_form())
|
||||
|
||||
with self.app.app_context():
|
||||
journal_entry_date: date = db.session.get(JournalEntry, id_1).date
|
||||
date: dt.date = db.session.get(JournalEntry, id_1).date
|
||||
|
||||
response = self.client.post(
|
||||
f"{PREFIX}/dates/{journal_entry_date.isoformat()}",
|
||||
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)
|
||||
@ -2207,14 +2241,14 @@ class JournalEntryReorderTestCase(unittest.TestCase):
|
||||
db.session.commit()
|
||||
|
||||
response = self.client.post(
|
||||
f"{PREFIX}/dates/{journal_entry_date.isoformat()}",
|
||||
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)
|
||||
|
@ -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
|
||||
|
@ -17,23 +17,18 @@
|
||||
"""The test for the options.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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,18 +286,21 @@ 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")
|
||||
self.assertIsNotNone(option)
|
||||
timestamp: datetime = option.created_at - timedelta(seconds=5)
|
||||
timestamp: dt.datetime \
|
||||
= option.created_at - dt.timedelta(seconds=5)
|
||||
option.created_at = timestamp
|
||||
option.updated_at = timestamp
|
||||
db.session.commit()
|
||||
@ -294,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")
|
||||
@ -310,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")
|
||||
@ -327,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)
|
||||
@ -347,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")
|
||||
@ -366,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",
|
||||
|
@ -17,8 +17,8 @@
|
||||
"""The test for the reports.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
from flask import Flask
|
||||
@ -446,15 +446,15 @@ class ReportTestData(BaseTestData):
|
||||
"""The report test data."""
|
||||
|
||||
def _init_data(self) -> None:
|
||||
today: date = date.today()
|
||||
today: dt.date = dt.date.today()
|
||||
year: int = today.year - 5
|
||||
month: int = today.month
|
||||
while True:
|
||||
j_date: date = date(year, month, 5)
|
||||
if j_date > today:
|
||||
date: dt.date = dt.date(year, month, 5)
|
||||
if date > today:
|
||||
break
|
||||
self._add_simple_journal_entry(
|
||||
(j_date - today).days, "USD",
|
||||
(date - today).days, "USD",
|
||||
"Salary薪水", "1200", Accounts.BANK, Accounts.SERVICE)
|
||||
month = month + 1
|
||||
if month > 12:
|
||||
|
@ -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)
|
||||
|
@ -17,24 +17,25 @@
|
||||
"""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
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from . import db
|
||||
|
||||
bp: Blueprint = Blueprint("auth", __name__, url_prefix="/")
|
||||
"""The authentication blueprint."""
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
"""A user."""
|
||||
__tablename__ = "users"
|
||||
"""The table name."""
|
||||
id = db.Column(db.Integer, nullable=False, primary_key=True,
|
||||
autoincrement=True)
|
||||
"""The ID"""
|
||||
username = db.Column(db.String, nullable=False, unique=True)
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
"""The ID."""
|
||||
username: Mapped[str] = mapped_column(unique=True)
|
||||
"""The username."""
|
||||
|
||||
def __str__(self) -> str:
|
||||
@ -95,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.
|
||||
|
@ -19,11 +19,11 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import typing as t
|
||||
import datetime as dt
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from secrets import randbelow
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import Flask
|
||||
@ -140,37 +140,39 @@ 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.
|
||||
"""
|
||||
journal_entry_date: date = date.today() - timedelta(days=self.days)
|
||||
date: dt.date = dt.date.today() - dt.timedelta(days=self.days)
|
||||
form: dict[str, str] = {"csrf_token": csrf_token,
|
||||
"next": next_uri,
|
||||
"date": journal_entry_date.isoformat()}
|
||||
"next": encoded_next_uri,
|
||||
"date": date.isoformat()}
|
||||
for i in range(len(self.currencies)):
|
||||
form.update(self.currencies[i].form(i + 1, is_update))
|
||||
if self.note is not None:
|
||||
@ -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
|
||||
@ -240,11 +242,12 @@ class BaseTestData(ABC):
|
||||
existing_j_id: set[int] = {x["id"] for x in self.__journal_entries}
|
||||
existing_l_id: set[int] = {x["id"] for x in self.__line_items}
|
||||
journal_entry_data.id = self.__new_id(existing_j_id)
|
||||
j_date: date = date.today() - timedelta(days=journal_entry_data.days)
|
||||
date: dt.date \
|
||||
= dt.date.today() - dt.timedelta(days=journal_entry_data.days)
|
||||
self.__journal_entries.append(
|
||||
{"id": journal_entry_data.id,
|
||||
"date": j_date,
|
||||
"no": self.__next_j_no(j_date),
|
||||
"date": date,
|
||||
"no": self.__next_j_no(date),
|
||||
"note": journal_entry_data.note,
|
||||
"created_by_id": self.__current_user_id,
|
||||
"updated_by_id": self.__current_user_id})
|
||||
@ -257,7 +260,7 @@ class BaseTestData(ABC):
|
||||
assert account is not None
|
||||
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,
|
||||
@ -276,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,
|
||||
@ -303,14 +306,14 @@ class BaseTestData(ABC):
|
||||
existing_id.add(obj_id)
|
||||
return obj_id
|
||||
|
||||
def __next_j_no(self, j_date: date) -> int:
|
||||
def __next_j_no(self, date: dt.date) -> int:
|
||||
"""Returns the next journal entry number in a day.
|
||||
|
||||
:param j_date: The journal entry date.
|
||||
:param date: The journal entry date.
|
||||
:return: The next journal entry number.
|
||||
"""
|
||||
existing: set[int] = {x["no"] for x in self.__journal_entries
|
||||
if x["date"] == j_date}
|
||||
if x["date"] == date}
|
||||
return 1 if len(existing) == 0 else max(existing) + 1
|
||||
|
||||
def _add_simple_journal_entry(
|
||||
|
@ -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]:
|
||||
|
@ -17,7 +17,7 @@
|
||||
"""The data reset for the Mia! Accounting demonstration website.
|
||||
|
||||
"""
|
||||
from datetime import date, timedelta
|
||||
import datetime as dt
|
||||
|
||||
from flask import Flask, Blueprint, url_for, flash, redirect, session, \
|
||||
render_template, current_app
|
||||
@ -116,15 +116,15 @@ class SampleData(BaseTestData):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
today: date = date.today()
|
||||
today: dt.date = dt.date.today()
|
||||
days: int
|
||||
year: int
|
||||
month: int
|
||||
|
||||
# Recurring in USD
|
||||
j_date: date = date(today.year - 5, today.month, today.day)
|
||||
j_date = j_date + timedelta(days=(4 - j_date.weekday()))
|
||||
days = (today - j_date).days
|
||||
date: dt.date = dt.date(today.year - 5, today.month, today.day)
|
||||
date = date + dt.timedelta(days=(4 - date.weekday()))
|
||||
days = (today - date).days
|
||||
while True:
|
||||
if days < 0:
|
||||
break
|
||||
@ -147,7 +147,7 @@ class SampleData(BaseTestData):
|
||||
if month > 12:
|
||||
year = year + 1
|
||||
month = 1
|
||||
days = (today - date(year, month, 1)).days
|
||||
days = (today - dt.date(year, month, 1)).days
|
||||
if days < 0:
|
||||
break
|
||||
self.__add_journal_entry(
|
||||
@ -159,12 +159,12 @@ class SampleData(BaseTestData):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
today: date = date.today()
|
||||
today: dt.date = dt.date.today()
|
||||
|
||||
year: int = today.year - 5
|
||||
month: int = today.month
|
||||
while True:
|
||||
days: int = (today - date(year, month, 5)).days
|
||||
days: int = (today - dt.date(year, month, 5)).days
|
||||
if days < 0:
|
||||
break
|
||||
self.__add_journal_entry(
|
||||
|
@ -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 }}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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))
|
||||
|
@ -17,8 +17,8 @@
|
||||
"""The common test libraries for the journal entry test cases.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import re
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from secrets import randbelow
|
||||
|
||||
@ -33,15 +33,16 @@ 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,
|
||||
"date": date.today().isoformat(),
|
||||
"next": encoded_next_uri,
|
||||
"date": dt.date.today().isoformat(),
|
||||
"currency-0-code": "USD",
|
||||
"currency-0-debit-0-no": "16",
|
||||
"currency-0-debit-0-account_code": Accounts.CASH,
|
||||
@ -102,13 +103,15 @@ def get_add_form(csrf_token: str) -> dict[str, str]:
|
||||
|
||||
|
||||
def get_unchanged_update_form(journal_entry_id: int, app: Flask,
|
||||
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")
|
||||
|
Reference in New Issue
Block a user