Compare commits
106 Commits
v1.5.0
...
99564c02d0
Author | SHA1 | Date | |
---|---|---|---|
99564c02d0 | |||
25d9904180 | |||
1cf83adf87 | |||
8e3d1f11b5 | |||
0ab14aa34d | |||
e0ed81ad1f | |||
ece7481e9e | |||
50d4526e0b | |||
3f0a0b4227 | |||
dcc9626b23 | |||
79eb077129 | |||
d5719ad223 | |||
eb3fa8f414 | |||
937908717b | |||
0104fa4c21 | |||
14365ca255 | |||
cd86651606 | |||
9147744ff7 | |||
1a212a5330 | |||
0614457b7b | |||
545f49043b | |||
cac0d66ca1 | |||
5ffd37c859 | |||
9ae8c1bce9 | |||
ec0ff3e2e6 | |||
40a8080751 | |||
736a4086ee | |||
6723077b72 | |||
0ae00bce79 | |||
356d2010cc | |||
501c4b1d22 | |||
64b9c8c11f | |||
9072de82d4 | |||
30fd9c2164 | |||
7cb01b4cee | |||
9a4e04c41f | |||
a9c4fa9de0 | |||
3a676e0b5a | |||
9cc7b64bb3 | |||
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 |
@ -38,3 +38,4 @@ python:
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
- requirements: docs/requirements.txt
|
||||
|
@ -59,7 +59,7 @@ Refer to the `change log`_.
|
||||
Copyright
|
||||
=========
|
||||
|
||||
Copyright (c) 2023 imacat.
|
||||
Copyright (c) 2023-2024 imacat.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
1
docs/requirements.txt
Normal file
1
docs/requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
sphinx_rtd_theme
|
@ -100,6 +100,14 @@ accounting.utils.strip\_text module
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.title\_case module
|
||||
-----------------------------------
|
||||
|
||||
.. automodule:: accounting.utils.title_case
|
||||
:members:
|
||||
:undoc-members:
|
||||
:show-inheritance:
|
||||
|
||||
accounting.utils.user module
|
||||
----------------------------
|
||||
|
||||
|
@ -1,5 +1,138 @@
|
||||
Changes
|
||||
=======
|
||||
Change Log
|
||||
==========
|
||||
|
||||
|
||||
Version 1.5.11
|
||||
--------------
|
||||
|
||||
Released 2023/12/26
|
||||
|
||||
Bug fix.
|
||||
|
||||
* Refined to enable the selection of the 3351-001 Accumulated Profit or Loss
|
||||
account.
|
||||
|
||||
|
||||
Version 1.5.10
|
||||
--------------
|
||||
|
||||
Released 2023/11/28
|
||||
|
||||
Bug fix.
|
||||
|
||||
* Fixed the form validator to enable the selection of Accumulated Profit or
|
||||
Loss accounts other than 3351-001.
|
||||
|
||||
|
||||
Version 1.5.9
|
||||
-------------
|
||||
|
||||
Released 2023/11/28
|
||||
|
||||
Bug fix.
|
||||
|
||||
* Refined to enable the selection of Accumulated Profit or Loss accounts other
|
||||
than 3351-001, facilitating the consolidation of existing balances.
|
||||
|
||||
|
||||
Version 1.5.8
|
||||
-------------
|
||||
|
||||
Released 2023/10/24
|
||||
|
||||
Bug fix.
|
||||
|
||||
* Fixed an icon in the detail of the cash receipt journal entry.
|
||||
|
||||
Released at Jaipur, India on vacation.
|
||||
|
||||
|
||||
Version 1.5.7
|
||||
-------------
|
||||
|
||||
Released 2023/7/29
|
||||
|
||||
Revised account title capitalization to capitalize account titles
|
||||
upon initialization of base accounts, rather than when displaying
|
||||
the accounts. This prevents the system from incorrectly
|
||||
capitalizing titles of user-added accounts.
|
||||
|
||||
For existing installation, run the ``accounting-titleize`` console
|
||||
command to capitalize the existing account titles that were already
|
||||
initialized.
|
||||
|
||||
Other fixes:
|
||||
|
||||
* Added missing documentation to the global variables, class
|
||||
properties, and object properties.
|
||||
* Various minor fixes.
|
||||
|
||||
|
||||
Version 1.5.6
|
||||
-------------
|
||||
|
||||
Released 2023/5/23
|
||||
|
||||
Bug fixes.
|
||||
|
||||
* Fixed the return URI of the creation forms to decode the next URI.
|
||||
* Fixed the unmatched offset list to use the encoded next URI.
|
||||
|
||||
|
||||
Version 1.5.5
|
||||
-------------
|
||||
|
||||
Released 2023/5/23
|
||||
|
||||
Security fixes.
|
||||
|
||||
* Revised the next URI utilities to encode and decode the next URI
|
||||
preventing tampering with the next URI.
|
||||
* Added the integrity value of the CDN stylesheet links.
|
||||
* Various fixes.
|
||||
|
||||
|
||||
Version 1.5.4
|
||||
-------------
|
||||
|
||||
Released 2023/5/18
|
||||
|
||||
Security fixes.
|
||||
|
||||
* Added safeguard to the next URI utilities, to prevent Cross-Site
|
||||
Scripting (XSS) attacks.
|
||||
* Applied the safe next URI utilities to the test site.
|
||||
* Added the ``SameSite`` and ``Secure`` flags to the session cookie
|
||||
of the test site.
|
||||
|
||||
|
||||
Version 1.5.3
|
||||
-------------
|
||||
|
||||
Released 2023/4/30
|
||||
|
||||
* Fixed the error of the net balance in the unmatched offset list.
|
||||
* Revised the original line item editor not to override the existing
|
||||
amount when the existing amount is less or equal to the net
|
||||
balance.
|
||||
|
||||
|
||||
Version 1.5.2
|
||||
-------------
|
||||
|
||||
Released 2023/4/30
|
||||
|
||||
* Fixed the error of the net balance in the unmatched offset list.
|
||||
|
||||
|
||||
Version 1.5.1
|
||||
-------------
|
||||
|
||||
Released 2023/4/30
|
||||
|
||||
* Fixed the error calling the old ``setEnableDescriptionAccount``
|
||||
method in the ``saveOriginalLineItem`` method of the JavaScript
|
||||
``JournalEntryLineItemEditor`` class.
|
||||
|
||||
|
||||
Version 1.5.0
|
||||
|
@ -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/
|
||||
|
@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/8/21
|
||||
|
||||
# Copyright (c) 2022-2023 imacat.
|
||||
# Copyright (c) 2022-2024 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -20,7 +20,7 @@ name = "mia-accounting"
|
||||
dynamic = ["version"]
|
||||
description = "A Flask accounting module."
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.11"
|
||||
requires-python = ">=3.12"
|
||||
authors = [
|
||||
{name = "imacat", email = "imacat@mail.imacat.idv.tw"},
|
||||
]
|
||||
|
@ -24,7 +24,7 @@ from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
from accounting.utils.user import UserUtilityInterface
|
||||
|
||||
VERSION: str = "1.5.0"
|
||||
VERSION: str = "1.5.11"
|
||||
"""The package version."""
|
||||
db: SQLAlchemy = SQLAlchemy()
|
||||
"""The database instance."""
|
||||
@ -63,8 +63,9 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
|
||||
bp.add_app_template_global(default_currency_code,
|
||||
"accounting_default_currency_code")
|
||||
|
||||
from .commands import init_db_command
|
||||
from .commands import init_db_command, titleize_command
|
||||
app.cli.add_command(init_db_command)
|
||||
app.cli.add_command(titleize_command)
|
||||
|
||||
from . import locale
|
||||
locale.init_app(app, bp)
|
||||
|
@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/30
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2024 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -17,17 +17,17 @@
|
||||
"""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]
|
||||
type AccountData = tuple[int, str, int, str, str, str, bool]
|
||||
"""The format of the account data, as a list of (ID, base account code, number,
|
||||
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
|
||||
|
||||
@ -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()
|
||||
|
@ -168,7 +168,9 @@ class AccountReorderForm:
|
||||
:param base: The base account.
|
||||
"""
|
||||
self.base: BaseAccount = base
|
||||
"""The base account."""
|
||||
self.is_modified: bool = False
|
||||
"""Whether the order is modified."""
|
||||
|
||||
def save_order(self) -> None:
|
||||
"""Saves the order of the account.
|
||||
|
@ -24,6 +24,7 @@ import sqlalchemy as sa
|
||||
from accounting import data_dir
|
||||
from accounting import db
|
||||
from accounting.models import BaseAccount, BaseAccountL10n
|
||||
from accounting.utils.title_case import title_case
|
||||
|
||||
|
||||
def init_base_accounts_command() -> None:
|
||||
@ -34,7 +35,7 @@ def init_base_accounts_command() -> None:
|
||||
with open(data_dir / "base_accounts.csv") as fp:
|
||||
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
|
||||
account_data: list[dict[str, str]] = [{"code": x["code"],
|
||||
"title_l10n": x["title"]}
|
||||
"title_l10n": title_case(x["title"])}
|
||||
for x in data]
|
||||
locales: list[str] = [x[5:] for x in data[0] if x.startswith("l10n-")]
|
||||
l10n_data: list[dict[str, str]] = [{"account_code": x["code"],
|
||||
|
@ -26,7 +26,10 @@ from accounting import db
|
||||
from accounting.account import init_accounts_command
|
||||
from accounting.base_account import init_base_accounts_command
|
||||
from accounting.currency import init_currencies_command
|
||||
from accounting.utils.user import has_user
|
||||
from accounting.models import BaseAccount, Account
|
||||
from accounting.utils.title_case import title_case
|
||||
from accounting.utils.user import has_user, get_user_pk
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
def __validate_username(ctx: click.core.Context, param: click.core.Option,
|
||||
@ -60,3 +63,32 @@ def init_db_command(username: str) -> None:
|
||||
init_currencies_command(username)
|
||||
db.session.commit()
|
||||
click.echo("Accounting database initialized.")
|
||||
|
||||
|
||||
@click.command("accounting-titleize")
|
||||
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
|
||||
help="The username.", callback=__validate_username,
|
||||
default=lambda: os.getlogin())
|
||||
@with_appcontext
|
||||
def titleize_command(username: str) -> None:
|
||||
"""Capitalize the account titles."""
|
||||
updater_pk: int = get_user_pk(username)
|
||||
updated: int = 0
|
||||
for base in BaseAccount.query:
|
||||
new_title: str = title_case(base.title_l10n)
|
||||
if base.title_l10n != new_title:
|
||||
base.title_l10n = new_title
|
||||
updated = updated + 1
|
||||
for account in Account.query:
|
||||
if account.title_l10n.lower() == account.base.title_l10n.lower():
|
||||
new_title: str = title_case(account.title_l10n)
|
||||
if account.title_l10n != new_title:
|
||||
account.title_l10n = new_title
|
||||
account.updated_at = sa.func.now()
|
||||
account.updated_by_id = updater_pk
|
||||
updated = updated + 1
|
||||
if updated == 0:
|
||||
click.echo("All account titles were already capitalized.")
|
||||
return
|
||||
db.session.commit()
|
||||
click.echo(f"{updated} account titles capitalized.")
|
||||
|
@ -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,
|
||||
|
@ -65,12 +65,12 @@ class IsDebitAccount:
|
||||
:param message: The error message.
|
||||
"""
|
||||
self.__message: str | LazyString = message
|
||||
"""The error message."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[1235689]|7[5678])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(self.__message)
|
||||
@ -85,12 +85,12 @@ class IsCreditAccount:
|
||||
:param message: The error message.
|
||||
"""
|
||||
self.__message: str | LazyString = message
|
||||
"""The error message."""
|
||||
|
||||
def __call__(self, form: FlaskForm, field: StringField) -> None:
|
||||
if field.data is None:
|
||||
return
|
||||
if re.match(r"^(?:[123489]|7[1234])", field.data) \
|
||||
and not field.data.startswith("3351-") \
|
||||
and not field.data.startswith("3353-"):
|
||||
return
|
||||
raise ValidationError(self.__message)
|
||||
|
@ -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.
|
||||
|
@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/18
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2024 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -18,8 +18,8 @@
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import 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."""
|
||||
@ -151,11 +151,10 @@ class JournalEntryForm(FlaskForm):
|
||||
is_new: bool = obj.id is None
|
||||
if is_new:
|
||||
obj.id = new_id(JournalEntry)
|
||||
self.date: DateField
|
||||
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 +308,7 @@ class JournalEntryForm(FlaskForm):
|
||||
return db.session.scalar(select)
|
||||
|
||||
|
||||
T = t.TypeVar("T", bound=JournalEntryForm)
|
||||
"""A journal entry form variant."""
|
||||
|
||||
|
||||
class LineItemCollector(t.Generic[T], ABC):
|
||||
class LineItemCollector[T: JournalEntryForm](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,13 +48,15 @@ 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
|
||||
"""The date."""
|
||||
self.is_modified: bool = False
|
||||
"""Whether the order is modified."""
|
||||
|
||||
def save_order(self) -> None:
|
||||
"""Saves the order of the account.
|
||||
|
@ -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."""
|
||||
@ -166,8 +166,11 @@ class DescriptionRecurring:
|
||||
:param account: The account.
|
||||
"""
|
||||
self.name: str = name
|
||||
"""The name."""
|
||||
self.account: DescriptionAccount = DescriptionAccount(account, 0)
|
||||
"""The account."""
|
||||
self.description_template: str = description_template
|
||||
"""The description template."""
|
||||
|
||||
@property
|
||||
def account_codes(self) -> list[str]:
|
||||
@ -181,12 +184,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 +197,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 +281,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")
|
||||
|
@ -25,8 +25,10 @@ from flask_babel import LazyString, Domain
|
||||
from flask_babel_js import JAVASCRIPT, c2js
|
||||
|
||||
translation_dir: Path = Path(__file__).parent / "translations"
|
||||
"""The directory of the translation files."""
|
||||
domain: Domain = Domain(translation_directories=[translation_dir],
|
||||
domain="accounting")
|
||||
"""The message domain."""
|
||||
|
||||
|
||||
def gettext(string, **variables) -> str:
|
||||
@ -120,6 +122,5 @@ def init_app(app: Flask, bp: Blueprint) -> None:
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
bp.add_url_rule("/_jstrans.js", "babel_catalog",
|
||||
__babel_js_catalog_view)
|
||||
bp.add_url_rule("/_jstrans.js", "babel_catalog", __babel_js_catalog_view)
|
||||
app.jinja_env.globals["A_"] = domain.gettext
|
||||
|
@ -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
|
||||
@ -40,7 +40,7 @@ class BaseAccount(db.Model):
|
||||
__tablename__ = "accounting_base_accounts"
|
||||
"""The table name."""
|
||||
code: Mapped[str] = mapped_column(primary_key=True)
|
||||
"""The code."""
|
||||
"""The account code."""
|
||||
title_l10n: Mapped[str] = mapped_column("title")
|
||||
"""The title."""
|
||||
l10n: Mapped[list[BaseAccountL10n]] \
|
||||
@ -54,7 +54,7 @@ class BaseAccount(db.Model):
|
||||
|
||||
:return: The string representation of the base account.
|
||||
"""
|
||||
return f"{self.code} {self.title.title()}"
|
||||
return f"{self.code} {self.title}"
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
@ -87,7 +87,7 @@ class BaseAccountL10n(db.Model):
|
||||
= mapped_column(db.ForeignKey(BaseAccount.code, onupdate="CASCADE",
|
||||
ondelete="CASCADE"),
|
||||
primary_key=True)
|
||||
"""The code of the account."""
|
||||
"""The account code."""
|
||||
account: Mapped[BaseAccount] = db.relationship(back_populates="l10n")
|
||||
"""The account."""
|
||||
locale: Mapped[str] = mapped_column(primary_key=True)
|
||||
@ -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."""
|
||||
@ -151,7 +151,7 @@ class Account(db.Model):
|
||||
|
||||
:return: The string representation of this account.
|
||||
"""
|
||||
return f"{self.base_code}-{self.no:03d} {self.title.title()}"
|
||||
return f"{self.base_code}-{self.no:03d} {self.title}"
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
@ -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.
|
||||
|
||||
@ -302,12 +304,11 @@ class Account(db.Model):
|
||||
cls.base_code.startswith("78"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3351",
|
||||
cls.base_code != "3353")\
|
||||
.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.
|
||||
|
||||
@ -324,12 +325,11 @@ class Account(db.Model):
|
||||
cls.base_code.startswith("74"),
|
||||
cls.base_code.startswith("8"),
|
||||
cls.base_code.startswith("9")),
|
||||
cls.base_code != "3351",
|
||||
cls.base_code != "3353")\
|
||||
.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 +337,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
|
||||
@ -367,28 +367,28 @@ class Currency(db.Model):
|
||||
__tablename__ = "accounting_currencies"
|
||||
"""The table name."""
|
||||
code: Mapped[str] = mapped_column(primary_key=True)
|
||||
"""The code."""
|
||||
"""The currency code."""
|
||||
name_l10n: Mapped[str] = mapped_column("name")
|
||||
"""The name."""
|
||||
"""The currency name."""
|
||||
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 +424,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 +469,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()
|
||||
|
||||
|
||||
@ -540,27 +542,27 @@ class JournalEntry(db.Model):
|
||||
date: Mapped[dt.date]
|
||||
"""The date."""
|
||||
no: Mapped[int] = mapped_column(default=text("1"))
|
||||
"""The account number under the date."""
|
||||
"""The journal entry number under the date."""
|
||||
note: Mapped[str | None]
|
||||
"""The note."""
|
||||
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 +737,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 +756,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 +777,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 +830,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 +849,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 +887,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,18 +31,18 @@ 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."""
|
||||
"""Whether this is the default period."""
|
||||
self.is_this_month: bool = False
|
||||
"""Whether the period is this month."""
|
||||
self.is_last_month: bool = False
|
||||
@ -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.
|
||||
|
@ -145,6 +145,7 @@ class AccountCollector:
|
||||
.filter(sa.or_(Account.id.in_({x.id for x in account_balances}),
|
||||
Account.base_code == "3351",
|
||||
Account.base_code == "3353")).all()
|
||||
"""The accounts."""
|
||||
account_by_id: dict[int, Account] \
|
||||
= {x.id: x for x in self.__all_accounts}
|
||||
self.accounts: list[ReportAccount] \
|
||||
@ -154,6 +155,7 @@ class AccountCollector:
|
||||
account_by_id[x.id],
|
||||
self.__period))
|
||||
for x in account_balances]
|
||||
"""The accounts on the balance sheet."""
|
||||
self.__add_accumulated()
|
||||
self.__add_current_period()
|
||||
self.accounts.sort(key=lambda x: (x.account.base_code, x.account.no))
|
||||
@ -452,11 +454,11 @@ class BalanceSheet(BaseReport):
|
||||
:return: The CSV rows for the section.
|
||||
"""
|
||||
rows: list[CSVHalfRow] \
|
||||
= [CSVHalfRow(section.title.title.title(), None)]
|
||||
= [CSVHalfRow(section.title.title, None)]
|
||||
for subsection in section.subsections:
|
||||
rows.append(CSVHalfRow(f" {subsection.title.title.title()}", None))
|
||||
rows.append(CSVHalfRow(f" {subsection.title.title}", None))
|
||||
for account in subsection.accounts:
|
||||
rows.append(CSVHalfRow(f" {str(account.account).title()}",
|
||||
rows.append(CSVHalfRow(f" {str(account.account)}",
|
||||
account.amount))
|
||||
return rows
|
||||
|
||||
|
@ -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."""
|
||||
@ -407,13 +407,13 @@ class IncomeExpenses(BaseReport):
|
||||
gettext("Note"))]
|
||||
if self.__brought_forward is not None:
|
||||
rows.append(CSVRow(self.__brought_forward.date,
|
||||
str(self.__brought_forward.account).title(),
|
||||
str(self.__brought_forward.account),
|
||||
self.__brought_forward.description,
|
||||
self.__brought_forward.income,
|
||||
self.__brought_forward.expense,
|
||||
self.__brought_forward.balance,
|
||||
None))
|
||||
rows.extend([CSVRow(x.date, str(x.account).title(), x.description,
|
||||
rows.extend([CSVRow(x.date, str(x.account), x.description,
|
||||
x.income, x.expense, x.balance, x.note)
|
||||
for x in self.__line_items])
|
||||
if self.__total is not None:
|
||||
|
@ -106,6 +106,7 @@ class Section:
|
||||
"""The subsections in the section."""
|
||||
self.accumulated: AccumulatedTotal \
|
||||
= AccumulatedTotal(accumulated_title)
|
||||
"""The accumulated total."""
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
@ -225,12 +226,12 @@ class IncomeStatement(BaseReport):
|
||||
for x in balances})).all()
|
||||
|
||||
total_titles: dict[str, str] \
|
||||
= {"4": gettext("total operating revenue"),
|
||||
"5": gettext("gross income"),
|
||||
"6": gettext("operating income"),
|
||||
"7": gettext("before tax income"),
|
||||
"8": gettext("after tax income"),
|
||||
"9": gettext("net income or loss for current period")}
|
||||
= {"4": gettext("Total Operating Revenue"),
|
||||
"5": gettext("Gross Income"),
|
||||
"6": gettext("Operating Income"),
|
||||
"7": gettext("Before Tax Income"),
|
||||
"8": gettext("After Tax Income"),
|
||||
"9": gettext("Net Income or Loss for Current Period")}
|
||||
|
||||
sections: dict[str, Section] \
|
||||
= {x.code: Section(x, total_titles[x.code]) for x in titles}
|
||||
@ -300,14 +301,14 @@ class IncomeStatement(BaseReport):
|
||||
total_str: str = gettext("Total")
|
||||
rows: list[CSVRow] = [CSVRow(None, gettext("Amount"))]
|
||||
for section in self.__sections:
|
||||
rows.append(CSVRow(str(section.title).title(), None))
|
||||
rows.append(CSVRow(str(section.title), None))
|
||||
for subsection in section.subsections:
|
||||
rows.append(CSVRow(f" {str(subsection.title).title()}", None))
|
||||
rows.append(CSVRow(f" {str(subsection.title)}", None))
|
||||
for account in subsection.accounts:
|
||||
rows.append(CSVRow(f" {str(account.account).title()}",
|
||||
rows.append(CSVRow(f" {str(account.account)}",
|
||||
account.amount))
|
||||
rows.append(CSVRow(f" {total_str}", subsection.total))
|
||||
rows.append(CSVRow(section.accumulated.title.title(),
|
||||
rows.append(CSVRow(section.accumulated.title,
|
||||
section.accumulated.amount))
|
||||
rows.append(CSVRow(None, None))
|
||||
rows = rows[:-1]
|
||||
|
@ -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."""
|
||||
@ -160,7 +160,7 @@ def get_csv_rows(line_items: list[JournalEntryLineItem]) -> list[CSVRow]:
|
||||
gettext("Debit"), gettext("Credit"),
|
||||
gettext("Note"))]
|
||||
rows.extend([CSVRow(x.journal_entry.date, x.currency.code,
|
||||
str(x.account).title(), x.description,
|
||||
str(x.account), x.description,
|
||||
x.debit, x.credit, x.journal_entry.note)
|
||||
for x in line_items])
|
||||
return rows
|
||||
|
@ -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))
|
||||
|
@ -224,7 +224,7 @@ class TrialBalance(BaseReport):
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Debit"),
|
||||
gettext("Credit"))]
|
||||
rows.extend([CSVRow(str(x.account).title(), x.debit, x.credit)
|
||||
rows.extend([CSVRow(str(x.account), x.debit, x.credit)
|
||||
for x in self.__accounts])
|
||||
rows.append(CSVRow(gettext("Total"), self.__total.debit,
|
||||
self.__total.credit))
|
||||
|
@ -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.
|
||||
@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
|
||||
rows.extend([CSVRow(str(x).title(), x.count)
|
||||
for x in accounts])
|
||||
rows.extend([CSVRow(str(x), x.count) for x in accounts])
|
||||
return rows
|
||||
|
||||
|
||||
@ -143,7 +142,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.
|
||||
@ -120,8 +120,7 @@ def get_csv_rows(accounts: list[Account]) -> list[CSVRow]:
|
||||
:return: The CSV rows.
|
||||
"""
|
||||
rows: list[CSVRow] = [CSVRow(gettext("Account"), gettext("Count"))]
|
||||
rows.extend([CSVRow(str(x).title(), x.count)
|
||||
for x in accounts])
|
||||
rows.extend([CSVRow(str(x), x.count) for x in accounts])
|
||||
return rows
|
||||
|
||||
|
||||
@ -144,7 +143,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.
|
||||
|
@ -23,6 +23,6 @@ First written: 2023/2/1
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Account") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.account.list") }}{% endblock %}
|
||||
{% block back_url %}{{ url_for("accounting.account.list")|accounting_or_next }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.account.store") }}{% endblock %}
|
||||
|
@ -90,7 +90,7 @@ First written: 2023/1/31
|
||||
{% endif %}
|
||||
|
||||
<div class="accounting-card col-sm-6">
|
||||
<div class="accounting-card-title">{{ obj.title|title }}</div>
|
||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||
{% if obj.is_need_offset %}
|
||||
<div>
|
||||
|
@ -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">
|
||||
|
@ -33,7 +33,7 @@ First written: 2023/2/1
|
||||
</div>
|
||||
|
||||
<div class="accounting-card col-sm-6">
|
||||
<div class="accounting-card-title">{{ obj.title|title }}</div>
|
||||
<div class="accounting-card-title">{{ obj.title }}</div>
|
||||
<div class="accounting-card-code">{{ obj.code }}</div>
|
||||
{% if obj.accounts %}
|
||||
<div>
|
||||
|
@ -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">
|
||||
|
@ -23,6 +23,6 @@ First written: 2023/2/6
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Currency") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting.currency.list") }}{% endblock %}
|
||||
{% block back_url %}{{ url_for("accounting.currency.list")|accounting_or_next }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.currency.store") }}{% endblock %}
|
||||
|
@ -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">
|
||||
|
@ -23,6 +23,6 @@ First written: 2023/2/25
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Cash Disbursement Journal Entry") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
|
||||
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
|
||||
|
@ -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">
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/3/14
|
||||
<div>
|
||||
<div class="small">
|
||||
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
|
||||
{{ line_item.account.title|title }}
|
||||
{{ line_item.account.title }}
|
||||
</div>
|
||||
{% if line_item.description is not none %}
|
||||
<div>{{ line_item.description }}</div>
|
||||
|
@ -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">
|
||||
|
@ -42,7 +42,7 @@ First written: 2023/2/25
|
||||
<div class="small">
|
||||
{{ line_item.journal_entry.date|accounting_format_date }}
|
||||
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
|
||||
{{ line_item.account.title|title }}
|
||||
{{ line_item.account.title }}
|
||||
</div>
|
||||
{{ line_item.description|accounting_default }}
|
||||
</div>
|
||||
|
@ -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 }}">
|
||||
|
@ -23,6 +23,6 @@ First written: 2023/2/25
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Cash Receipt Journal Entry") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
|
||||
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
|
||||
|
@ -23,7 +23,7 @@ First written: 2023/2/26
|
||||
|
||||
{% block as_trasfer %}
|
||||
<a class="btn btn-primary" role="button" href="{{ url_for("accounting.journal-entry.edit", journal_entry=obj)|accounting_journal_entry_to_transfer|accounting_inherit_next }}">
|
||||
<i class="fa-solid fa-bars-staggered"></i>
|
||||
<i class="fa-solid fa-table-columns"></i>
|
||||
<span class="d-none d-md-inline">{{ A_("As Transfer") }}</span>
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
@ -23,6 +23,6 @@ First written: 2023/2/25
|
||||
|
||||
{% block header %}{% block title %}{{ A_("Add a New Transfer Journal Entry") }}{% endblock %}{% endblock %}
|
||||
|
||||
{% block back_url %}{{ request.args.get("next") or url_for("accounting-report.default") }}{% endblock %}
|
||||
{% block back_url %}{{ url_for("accounting-report.default")|accounting_or_next }}{% endblock %}
|
||||
|
||||
{% block action_url %}{{ url_for("accounting.journal-entry.store", journal_entry_type=journal_entry_type) }}{% endblock %}
|
||||
|
@ -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">
|
||||
|
@ -20,21 +20,21 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
<div class="accounting-report-table-row accounting-balance-sheet-section">
|
||||
<div>{{ section.title.title|title }}</div>
|
||||
<div>{{ section.title.title }}</div>
|
||||
</div>
|
||||
<div class="accounting-report-table-body">
|
||||
{% for subsection in section.subsections %}
|
||||
<div class="accounting-report-table-row accounting-balance-sheet-subsection">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
|
||||
{{ subsection.title.title|title }}
|
||||
{{ subsection.title.title }}
|
||||
</div>
|
||||
</div>
|
||||
{% for account in subsection.accounts %}
|
||||
<a class="d-flex justify-content-between accounting-report-table-row accounting-balance-sheet-account" href="{{ account.url }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||
{{ account.account.title|title }}
|
||||
{{ account.account.title }}
|
||||
</div>
|
||||
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
|
||||
</a>
|
||||
|
@ -20,7 +20,7 @@ Author: imacat@mail.imacat.idv.tw (imacat)
|
||||
First written: 2023/3/8
|
||||
#}
|
||||
<div>{{ line_item.date|accounting_format_date }}</div>
|
||||
<div>{{ line_item.account.title|title }}</div>
|
||||
<div>{{ line_item.account.title }}</div>
|
||||
<div>{{ line_item.description|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ line_item.income|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ line_item.expense|accounting_format_amount|accounting_default }}</div>
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/3/5
|
||||
{{ line_item.date|accounting_format_date }}
|
||||
{% endif %}
|
||||
{% if line_item.account %}
|
||||
{{ line_item.account.title|title }}
|
||||
{{ line_item.account.title }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -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">
|
||||
|
@ -93,7 +93,7 @@ First written: 2023/3/8
|
||||
{% for account in report.account_options %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if account.is_active %} active {% endif %}" href="{{ account.url }}">
|
||||
{{ account.title|title }}
|
||||
{{ account.title }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
@ -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">
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/3/5
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Income and Expenses Log of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Income and Expenses Log of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
@ -66,21 +66,21 @@ First written: 2023/3/7
|
||||
<div class="accounting-report-table-row accounting-income-statement-section">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ section.title.code }}</span>
|
||||
{{ section.title.title|title }}
|
||||
{{ section.title.title }}
|
||||
</div>
|
||||
</div>
|
||||
{% for subsection in section.subsections %}
|
||||
<div class="accounting-report-table-row accounting-income-statement-subsection">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ subsection.title.code }}</span>
|
||||
{{ subsection.title.title|title }}
|
||||
{{ subsection.title.title }}
|
||||
</div>
|
||||
</div>
|
||||
{% for account in subsection.accounts %}
|
||||
<a class="accounting-report-table-row accounting-income-statement-account" href="{{ account.url }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||
{{ account.account.title|title }}
|
||||
{{ account.account.title }}
|
||||
</div>
|
||||
<div class="accounting-amount {% if account.amount < 0 %} text-danger {% endif %}">{{ account.amount|accounting_report_format_amount }}</div>
|
||||
</a>
|
||||
@ -91,7 +91,7 @@ First written: 2023/3/7
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="accounting-report-table-row accounting-income-statement-total">
|
||||
<div>{{ section.accumulated.title|title }}</div>
|
||||
<div>{{ section.accumulated.title }}</div>
|
||||
<div class="accounting-amount {% if section.accumulated.amount < 0 %} text-danger {% endif %}">{{ section.accumulated.amount|accounting_report_format_amount }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
@ -65,7 +65,7 @@ First written: 2023/3/4
|
||||
<div>{{ line_item.currency.name }}</div>
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
|
||||
{{ line_item.account.title|title }}
|
||||
{{ line_item.account.title }}
|
||||
</div>
|
||||
<div>{{ line_item.description|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
|
||||
@ -82,7 +82,7 @@ First written: 2023/3/4
|
||||
<div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||
<div class="text-muted small">
|
||||
{{ line_item.journal_entry.date|accounting_format_date }}
|
||||
{{ line_item.account.title|title }}
|
||||
{{ line_item.account.title }}
|
||||
{% if line_item.currency.code != accounting_default_currency_code() %}
|
||||
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
|
||||
{% endif %}
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/3/5
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title|title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title|title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Ledger of %(account)s %(period)s", account=report.account.title, period=report.period.desc|title) }}{% else %}{{ A_("Ledger of %(account)s in %(currency)s %(period)s", currency=report.currency.name|title, account=report.account.title, period=report.period.desc|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
@ -62,7 +62,7 @@ First written: 2023/3/8
|
||||
<div>{{ line_item.currency.name }}</div>
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ line_item.account.code }}</span>
|
||||
{{ line_item.account.title|title }}
|
||||
{{ line_item.account.title }}
|
||||
</div>
|
||||
<div>{{ line_item.description|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ line_item.debit|accounting_format_amount|accounting_default }}</div>
|
||||
@ -79,7 +79,7 @@ First written: 2023/3/8
|
||||
<div {% if not line_item.is_debit %} class="accounting-mobile-journal-credit" {% endif %}>
|
||||
<div class="text-muted small">
|
||||
{{ line_item.journal_entry.date|accounting_format_date }}
|
||||
{{ line_item.account.title|title }}
|
||||
{{ line_item.account.title }}
|
||||
{% if line_item.currency.code != accounting_default_currency_code() %}
|
||||
<span class="badge rounded-pill bg-info">{{ line_item.currency.code }}</span>
|
||||
{% endif %}
|
||||
|
@ -68,7 +68,7 @@ First written: 2023/3/5
|
||||
<a class="accounting-report-table-row" href="{{ account.url }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.account.code }}</span>
|
||||
{{ account.account.title|title }}
|
||||
{{ account.account.title }}
|
||||
</div>
|
||||
<div class="accounting-amount">{{ account.debit|accounting_format_amount|accounting_default }}</div>
|
||||
<div class="accounting-amount">{{ account.credit|accounting_format_amount|accounting_default }}</div>
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/4/8
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unapplied Items") }}{% else %}{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unapplied Items") }}{% else %}{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@ -46,9 +46,9 @@ First written: 2023/4/8
|
||||
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||
<h2 class="text-center">
|
||||
{% if report.currency.code == accounting_default_currency_code() %}
|
||||
{{ A_("Accounts with Unapplied Items") }}
|
||||
{{ A_("Accounts With Unapplied Items") }}
|
||||
{% else %}
|
||||
{{ A_("Accounts with Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
|
||||
{{ A_("Accounts With Unapplied Items in %(currency)s", currency=report.currency.name|title) }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
@ -64,7 +64,7 @@ First written: 2023/4/8
|
||||
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unapplied", currency=report.currency, account=account, period=report.period) }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.code }}</span>
|
||||
{{ account.title|title }}
|
||||
{{ account.title }}
|
||||
</div>
|
||||
<div class="accounting-amount">{{ account.count }}</div>
|
||||
</a>
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/4/7
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unapplied Items of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unapplied Items of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/4/17
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts with Unmatched Offsets") }}{% else %}{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Accounts With Unmatched Offsets") }}{% else %}{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@ -46,9 +46,9 @@ First written: 2023/4/17
|
||||
<div class="d-none d-sm-flex justify-content-center mb-3">
|
||||
<h2 class="text-center">
|
||||
{% if report.currency.code == accounting_default_currency_code() %}
|
||||
{{ A_("Accounts with Unmatched Offsets") }}
|
||||
{{ A_("Accounts With Unmatched Offsets") }}
|
||||
{% else %}
|
||||
{{ A_("Accounts with Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
|
||||
{{ A_("Accounts With Unmatched Offsets in %(currency)s", currency=report.currency.name|title) }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
</div>
|
||||
@ -64,7 +64,7 @@ First written: 2023/4/17
|
||||
<a class="accounting-report-table-row" href="{{ url_for("accounting-report.unmatched", currency=report.currency, account=account, period=report.period) }}">
|
||||
<div>
|
||||
<span class="d-none d-md-inline">{{ account.code }}</span>
|
||||
{{ account.title|title }}
|
||||
{{ account.title }}
|
||||
</div>
|
||||
<div class="accounting-amount">{{ account.count }}</div>
|
||||
</a>
|
||||
|
@ -26,7 +26,7 @@ First written: 2023/4/17
|
||||
<script src="{{ url_for("accounting.static", filename="js/period-chooser.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title|title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title|title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
{% block header %}{% block title %}{% if report.currency.code == accounting_default_currency_code() %}{{ A_("Unmatched Offsets of %(account)s", account=report.account.title) }}{% else %}{{ A_("Unmatched Offsets of %(account)s in %(currency)s", currency=report.currency.name|title, account=report.account.title) }}{% endif %}{% endblock %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@ -49,7 +49,7 @@ First written: 2023/4/17
|
||||
|
||||
<form action="{{ url_for("accounting-report.match-offsets", currency=report.currency, account=report.account) }}" method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ request.full_path if request.query_string else request.path }}">
|
||||
<input type="hidden" name="next" value="{{ accounting_as_next() }}">
|
||||
<div class="modal fade" id="accounting-match-modal" tabindex="-1" aria-labelledby="accounting-match-modal-label" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
|
@ -8,8 +8,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: mia-accounting 1.4.0\n"
|
||||
"Report-Msgid-Bugs-To: imacat@mail.imacat.idv.tw\n"
|
||||
"POT-Creation-Date: 2023-04-18 09:32+0800\n"
|
||||
"PO-Revision-Date: 2023-04-18 09:32+0800\n"
|
||||
"POT-Creation-Date: 2023-07-29 08:55+0800\n"
|
||||
"PO-Revision-Date: 2023-07-29 08:56+0800\n"
|
||||
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
|
||||
"Language: zh_Hant\n"
|
||||
"Language-Team: zh_Hant <imacat@mail.imacat.idv.tw>\n"
|
||||
@ -21,7 +21,7 @@ msgstr ""
|
||||
|
||||
#: src/accounting/forms.py:33
|
||||
#: src/accounting/static/js/journal-entry-form.js:1080
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:411
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:415
|
||||
#: src/accounting/static/js/option-form.js:537
|
||||
#: src/accounting/static/js/option-form.js:803
|
||||
msgid "Please select the account."
|
||||
@ -35,22 +35,22 @@ msgstr "沒有這個貨幣。"
|
||||
msgid "The account does not exist."
|
||||
msgstr "沒有這個科目。"
|
||||
|
||||
#: src/accounting/models.py:581
|
||||
#: src/accounting/models.py:578
|
||||
#, python-format
|
||||
msgid "Cash Disbursement Journal Entry#%(id)s"
|
||||
msgstr "現金支出傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:584
|
||||
#: src/accounting/models.py:581
|
||||
#, python-format
|
||||
msgid "Cash Receipt Journal Entry#%(id)s"
|
||||
msgstr "現金收入傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:585
|
||||
#: src/accounting/models.py:582
|
||||
#, python-format
|
||||
msgid "Transfer Journal Entry#%(id)s"
|
||||
msgstr "轉帳傳票#%(id)s"
|
||||
|
||||
#: src/accounting/models.py:714
|
||||
#: src/accounting/models.py:706
|
||||
#, python-format
|
||||
msgid "%(date)s %(description)s %(amount)s"
|
||||
msgstr "%(date)s %(description)s %(amount)s"
|
||||
@ -101,7 +101,7 @@ msgid "Please fill in the title"
|
||||
msgstr "請填上標題。"
|
||||
|
||||
#: src/accounting/account/queries.py:50
|
||||
#: src/accounting/report/reports/search.py:101
|
||||
#: src/accounting/report/reports/search.py:100
|
||||
#: src/accounting/templates/accounting/account/detail.html:97
|
||||
#: src/accounting/templates/accounting/account/list.html:62
|
||||
msgid "Needs Offset"
|
||||
@ -205,24 +205,24 @@ msgstr "傳票不可刪除。"
|
||||
msgid "The journal entry is deleted successfully."
|
||||
msgstr "傳票刪掉了"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:39
|
||||
#: src/accounting/journal_entry/forms/currency.py:38
|
||||
msgid "Please select the currency."
|
||||
msgstr "請選擇貨幣。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:62
|
||||
#: src/accounting/journal_entry/forms/currency.py:61
|
||||
msgid "The currency must be the same as the original line item."
|
||||
msgstr "貨幣需和原始分錄相同。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:89
|
||||
#: src/accounting/journal_entry/forms/currency.py:88
|
||||
msgid "The currency must not be changed when there is offset."
|
||||
msgstr "抵銷過不可變更貨幣。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:98
|
||||
#: src/accounting/journal_entry/forms/currency.py:97
|
||||
#: src/accounting/static/js/journal-entry-form.js:773
|
||||
msgid "Please add some line items."
|
||||
msgstr "請加上分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/currency.py:111
|
||||
#: src/accounting/journal_entry/forms/currency.py:110
|
||||
#: src/accounting/static/js/journal-entry-form.js:522
|
||||
msgid "The totals of the debit and credit amounts do not match."
|
||||
msgstr "借方貸方合計不符。 "
|
||||
@ -251,62 +251,62 @@ msgstr "請加上貨幣。"
|
||||
msgid "Line items with offset cannot be deleted."
|
||||
msgstr "無法刪除抵銷過的分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:49
|
||||
#: src/accounting/journal_entry/forms/line_item.py:48
|
||||
msgid "The original line item does not exist."
|
||||
msgstr "沒有這筆原始分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:70
|
||||
#: src/accounting/journal_entry/forms/line_item.py:69
|
||||
msgid "The original line item is on the same debit or credit."
|
||||
msgstr "原始分錄在借貸同一邊。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:85
|
||||
#: src/accounting/journal_entry/forms/line_item.py:84
|
||||
msgid "The original line item does not need offset."
|
||||
msgstr "這筆原始分錄不需抵銷。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:101
|
||||
#: src/accounting/journal_entry/forms/line_item.py:100
|
||||
msgid "The original line item cannot be an offset item."
|
||||
msgstr "原始分錄不可以是抵銷分錄。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:119
|
||||
#: src/accounting/journal_entry/forms/line_item.py:118
|
||||
msgid "The account must be the same as the original line item."
|
||||
msgstr "科目需和原始分錄相同。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:135
|
||||
#: src/accounting/journal_entry/forms/line_item.py:134
|
||||
msgid "The account must not be changed when there is offset."
|
||||
msgstr "抵銷過不可變更科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:151
|
||||
#: src/accounting/journal_entry/forms/line_item.py:150
|
||||
msgid "A payable line item cannot start from debit."
|
||||
msgstr "不可由借方新建應付款。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:167
|
||||
#: src/accounting/journal_entry/forms/line_item.py:166
|
||||
msgid "A receivable line item cannot start from credit."
|
||||
msgstr "不可由貸方新建應收款。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:178
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:436
|
||||
#: src/accounting/journal_entry/forms/line_item.py:177
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:440
|
||||
msgid "Please fill in a positive amount."
|
||||
msgstr "金額請填正數。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:220
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:442
|
||||
#: src/accounting/journal_entry/forms/line_item.py:219
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:446
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The amount must not exceed the net balance %(balance)s of the original "
|
||||
"line item."
|
||||
msgstr "金額不可超過原始分錄凈額 %(balance)s 。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:241
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:450
|
||||
#: src/accounting/journal_entry/forms/line_item.py:239
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:454
|
||||
#, python-format
|
||||
msgid "The amount must not be less than the offset total %(total)s."
|
||||
msgstr "金額不可低於抵銷總額 %(total)s 。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:426
|
||||
#: src/accounting/journal_entry/forms/line_item.py:424
|
||||
msgid "This account is not for debit line items."
|
||||
msgstr "科目不是借方科目。"
|
||||
|
||||
#: src/accounting/journal_entry/forms/line_item.py:478
|
||||
#: src/accounting/journal_entry/forms/line_item.py:476
|
||||
msgid "This account is not for credit line items."
|
||||
msgstr "科目不是貸方科目。"
|
||||
|
||||
@ -417,15 +417,15 @@ msgstr "去年"
|
||||
msgid "All"
|
||||
msgstr "全部"
|
||||
|
||||
#: src/accounting/report/reports/balance_sheet.py:423
|
||||
#: src/accounting/report/reports/balance_sheet.py:427
|
||||
#: src/accounting/report/reports/balance_sheet.py:439
|
||||
#: src/accounting/report/reports/balance_sheet.py:425
|
||||
#: src/accounting/report/reports/balance_sheet.py:429
|
||||
#: src/accounting/report/reports/balance_sheet.py:441
|
||||
#: src/accounting/report/reports/income_expenses.py:189
|
||||
#: src/accounting/report/reports/income_expenses.py:423
|
||||
#: src/accounting/report/reports/income_statement.py:300
|
||||
#: src/accounting/report/reports/ledger.py:171
|
||||
#: src/accounting/report/reports/ledger.py:380
|
||||
#: src/accounting/report/reports/balance_sheet.py:443
|
||||
#: src/accounting/report/reports/income_expenses.py:187
|
||||
#: src/accounting/report/reports/income_expenses.py:420
|
||||
#: src/accounting/report/reports/income_statement.py:301
|
||||
#: src/accounting/report/reports/ledger.py:168
|
||||
#: src/accounting/report/reports/ledger.py:376
|
||||
#: src/accounting/report/reports/trial_balance.py:229
|
||||
#: src/accounting/templates/accounting/journal-entry/disbursement/detail.html:43
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form-debit-credit.html:38
|
||||
@ -445,14 +445,14 @@ msgstr "全部"
|
||||
msgid "Total"
|
||||
msgstr "合計"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:136
|
||||
#: src/accounting/report/reports/ledger.py:132
|
||||
#: src/accounting/report/reports/income_expenses.py:134
|
||||
#: src/accounting/report/reports/ledger.py:129
|
||||
msgid "Brought forward"
|
||||
msgstr "前期轉入"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:407
|
||||
#: src/accounting/report/reports/income_expenses.py:404
|
||||
#: src/accounting/report/reports/journal.py:158
|
||||
#: src/accounting/report/reports/ledger.py:366
|
||||
#: src/accounting/report/reports/ledger.py:362
|
||||
#: src/accounting/report/reports/unapplied.py:148
|
||||
#: src/accounting/report/reports/unmatched.py:158
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:50
|
||||
@ -466,13 +466,13 @@ msgstr "前期轉入"
|
||||
msgid "Date"
|
||||
msgstr "日期"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:407
|
||||
#: src/accounting/report/reports/income_expenses.py:404
|
||||
#: src/accounting/report/reports/journal.py:159
|
||||
#: src/accounting/report/reports/trial_balance.py:225
|
||||
#: src/accounting/report/reports/unapplied_accounts.py:122
|
||||
#: src/accounting/report/reports/unmatched_accounts.py:122
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:57
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:39
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:58
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:40
|
||||
#: src/accounting/templates/accounting/report/include/toolbar-buttons.html:90
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:56
|
||||
#: src/accounting/templates/accounting/report/journal.html:55
|
||||
@ -481,13 +481,13 @@ msgstr "日期"
|
||||
msgid "Account"
|
||||
msgstr "科目"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:408
|
||||
#: src/accounting/report/reports/income_expenses.py:405
|
||||
#: src/accounting/report/reports/journal.py:159
|
||||
#: src/accounting/report/reports/ledger.py:366
|
||||
#: src/accounting/report/reports/ledger.py:362
|
||||
#: src/accounting/report/reports/unapplied.py:149
|
||||
#: src/accounting/report/reports/unmatched.py:159
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:28
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:49
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:29
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:50
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:57
|
||||
#: src/accounting/templates/accounting/report/journal.html:56
|
||||
#: src/accounting/templates/accounting/report/ledger.html:56
|
||||
@ -497,18 +497,18 @@ msgstr "科目"
|
||||
msgid "Description"
|
||||
msgstr "摘要"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:408
|
||||
#: src/accounting/report/reports/income_expenses.py:405
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:58
|
||||
msgid "Income"
|
||||
msgstr "收入"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:409
|
||||
#: src/accounting/report/reports/income_expenses.py:406
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:59
|
||||
msgid "Expense"
|
||||
msgstr "支出"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:409
|
||||
#: src/accounting/report/reports/ledger.py:368
|
||||
#: src/accounting/report/reports/income_expenses.py:406
|
||||
#: src/accounting/report/reports/ledger.py:364
|
||||
#: src/accounting/report/reports/unmatched.py:160
|
||||
#: src/accounting/templates/accounting/report/income-expenses.html:60
|
||||
#: src/accounting/templates/accounting/report/ledger.html:60
|
||||
@ -516,41 +516,41 @@ msgstr "支出"
|
||||
msgid "Balance"
|
||||
msgstr "餘額"
|
||||
|
||||
#: src/accounting/report/reports/income_expenses.py:410
|
||||
#: src/accounting/report/reports/income_expenses.py:407
|
||||
#: src/accounting/report/reports/journal.py:161
|
||||
#: src/accounting/report/reports/ledger.py:368
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:178
|
||||
#: src/accounting/report/reports/ledger.py:364
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:179
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:73
|
||||
msgid "Note"
|
||||
msgstr "備註"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:228
|
||||
msgid "total operating revenue"
|
||||
#: src/accounting/report/reports/income_statement.py:229
|
||||
msgid "Total Operating Revenue"
|
||||
msgstr "營業收入總額"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:229
|
||||
msgid "gross income"
|
||||
#: src/accounting/report/reports/income_statement.py:230
|
||||
msgid "Gross Income"
|
||||
msgstr "營業毛利"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:230
|
||||
msgid "operating income"
|
||||
#: src/accounting/report/reports/income_statement.py:231
|
||||
msgid "Operating Income"
|
||||
msgstr "營業淨利"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:231
|
||||
msgid "before tax income"
|
||||
#: src/accounting/report/reports/income_statement.py:232
|
||||
msgid "Before Tax Income"
|
||||
msgstr "稅前淨利"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:232
|
||||
msgid "after tax income"
|
||||
#: src/accounting/report/reports/income_statement.py:233
|
||||
msgid "After Tax Income"
|
||||
msgstr "稅後淨利"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:233
|
||||
msgid "net income or loss for current period"
|
||||
#: src/accounting/report/reports/income_statement.py:234
|
||||
msgid "Net Income or Loss for Current Period"
|
||||
msgstr "本期損益"
|
||||
|
||||
#: src/accounting/report/reports/income_statement.py:301
|
||||
#: src/accounting/report/reports/income_statement.py:302
|
||||
#: src/accounting/report/reports/unapplied.py:149
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:65
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:66
|
||||
#: src/accounting/templates/accounting/report/income-statement.html:61
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:54
|
||||
msgid "Amount"
|
||||
@ -567,7 +567,7 @@ msgid "Currency"
|
||||
msgstr "貨幣"
|
||||
|
||||
#: src/accounting/report/reports/journal.py:160
|
||||
#: src/accounting/report/reports/ledger.py:367
|
||||
#: src/accounting/report/reports/ledger.py:363
|
||||
#: src/accounting/report/reports/trial_balance.py:225
|
||||
#: src/accounting/report/reports/unmatched.py:159
|
||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:33
|
||||
@ -581,7 +581,7 @@ msgid "Debit"
|
||||
msgstr "借方"
|
||||
|
||||
#: src/accounting/report/reports/journal.py:160
|
||||
#: src/accounting/report/reports/ledger.py:367
|
||||
#: src/accounting/report/reports/ledger.py:363
|
||||
#: src/accounting/report/reports/trial_balance.py:226
|
||||
#: src/accounting/report/reports/unmatched.py:160
|
||||
#: src/accounting/templates/accounting/journal-entry/transfer/detail.html:49
|
||||
@ -614,16 +614,16 @@ msgstr "淨額"
|
||||
msgid "Count"
|
||||
msgstr "數量"
|
||||
|
||||
#: src/accounting/report/utils/offset_matcher.py:163
|
||||
#: src/accounting/report/utils/offset_matcher.py:161
|
||||
msgid "There is no unmatched offset."
|
||||
msgstr "沒有遺漏的抵銷分錄"
|
||||
|
||||
#: src/accounting/report/utils/offset_matcher.py:167
|
||||
#: src/accounting/report/utils/offset_matcher.py:165
|
||||
#, python-format
|
||||
msgid "%(total)s unmatched offsets without original items."
|
||||
msgstr "%(total)s 筆遺漏的抵銷分錄無法自動抵銷。"
|
||||
|
||||
#: src/accounting/report/utils/offset_matcher.py:172
|
||||
#: src/accounting/report/utils/offset_matcher.py:170
|
||||
#, python-format
|
||||
msgid ""
|
||||
"%(matches)s unmatched offsets out of %(total)s can match with their "
|
||||
@ -752,7 +752,7 @@ msgid "December"
|
||||
msgstr "十二月"
|
||||
|
||||
#: src/accounting/static/js/journal-entry-form.js:1085
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:430
|
||||
#: src/accounting/static/js/journal-entry-line-item-editor.js:434
|
||||
msgid "Please fill in the amount."
|
||||
msgstr "請填上金額。"
|
||||
|
||||
@ -833,12 +833,12 @@ msgstr "確認刪除科目"
|
||||
#: src/accounting/templates/accounting/account/include/form.html:91
|
||||
#: src/accounting/templates/accounting/currency/detail.html:73
|
||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:27
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:30
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:31
|
||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:78
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:29
|
||||
#: src/accounting/templates/accounting/journal-entry/include/original-line-item-selector-modal.html:27
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:27
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:28
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:29
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:27
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:28
|
||||
#: src/accounting/templates/accounting/report/unmatched.html:58
|
||||
@ -853,11 +853,11 @@ msgstr "你確定要刪掉這個科目嗎?"
|
||||
#: src/accounting/templates/accounting/account/include/form.html:112
|
||||
#: src/accounting/templates/accounting/currency/detail.html:79
|
||||
#: src/accounting/templates/accounting/journal-entry/include/account-selector-modal.html:49
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:194
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:195
|
||||
#: src/accounting/templates/accounting/journal-entry/include/detail.html:84
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:70
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
|
||||
#: src/accounting/templates/accounting/option/include/recurring-account-selector-modal.html:48
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:65
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
|
||||
#: src/accounting/templates/accounting/report/include/search-modal.html:37
|
||||
#: src/accounting/templates/accounting/report/unmatched.html:74
|
||||
msgid "Cancel"
|
||||
@ -942,12 +942,12 @@ msgstr "%(base)s下的科目"
|
||||
#: src/accounting/templates/accounting/account/include/form.html:75
|
||||
#: src/accounting/templates/accounting/account/order.html:62
|
||||
#: src/accounting/templates/accounting/currency/include/form.html:57
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:195
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:196
|
||||
#: src/accounting/templates/accounting/journal-entry/include/form.html:80
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:71
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:72
|
||||
#: src/accounting/templates/accounting/journal-entry/order.html:61
|
||||
#: src/accounting/templates/accounting/option/form.html:80
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:66
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:67
|
||||
msgid "Save"
|
||||
msgstr "儲存"
|
||||
|
||||
@ -1008,7 +1008,7 @@ msgid "Code"
|
||||
msgstr "代碼"
|
||||
|
||||
#: src/accounting/templates/accounting/currency/include/form.html:50
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:33
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:34
|
||||
msgid "Name"
|
||||
msgstr "名稱"
|
||||
|
||||
@ -1077,53 +1077,53 @@ msgstr "選擇科目"
|
||||
msgid "More…"
|
||||
msgstr "更多…"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:36
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:37
|
||||
msgid "Offset..."
|
||||
msgstr "抵銷…"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:44
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:45
|
||||
msgid "General"
|
||||
msgstr "一般"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:49
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:50
|
||||
msgid "Travel"
|
||||
msgstr "差旅"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:54
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:55
|
||||
msgid "Bus"
|
||||
msgstr "公車"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:59
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:60
|
||||
msgid "Recurring"
|
||||
msgstr "常用"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:64
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:65
|
||||
msgid "Annotation"
|
||||
msgstr "註記"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:73
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:90
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:125
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:74
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:91
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:126
|
||||
msgid "Tag"
|
||||
msgstr "標籤"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:105
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:146
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:106
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:147
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:129
|
||||
msgid "From"
|
||||
msgstr "從"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:114
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:151
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:115
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:152
|
||||
#: src/accounting/templates/accounting/report/include/period-chooser.html:135
|
||||
msgid "To"
|
||||
msgstr "至"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:130
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:131
|
||||
msgid "Route"
|
||||
msgstr "路線"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:172
|
||||
#: src/accounting/templates/accounting/journal-entry/include/description-editor-modal.html:173
|
||||
msgid "The Number of Items"
|
||||
msgstr "數量"
|
||||
|
||||
@ -1155,11 +1155,11 @@ msgstr "確認刪除傳票"
|
||||
msgid "Do you really want to delete this journal entry?"
|
||||
msgstr "你確定要刪掉這張傳票嗎?"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:27
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:28
|
||||
msgid "Line Item Content"
|
||||
msgstr "分錄內容"
|
||||
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:34
|
||||
#: src/accounting/templates/accounting/journal-entry/include/journal-entry-line-item-editor-modal.html:35
|
||||
msgid "Original Line Item"
|
||||
msgstr "原始分錄"
|
||||
|
||||
@ -1215,43 +1215,43 @@ msgstr "常用支出"
|
||||
msgid "Recurring Income"
|
||||
msgstr "常用收入"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:47
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:48
|
||||
msgid "Description Template"
|
||||
msgstr "摘要範本"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:52
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:53
|
||||
msgid "Available template variables:"
|
||||
msgstr "範本變數說明:"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:54
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
|
||||
msgid "This month, as a number."
|
||||
msgstr "這個月的數字。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:55
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
|
||||
msgid "This month, in its name."
|
||||
msgstr "這個月的名稱。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:56
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
|
||||
msgid "Last month, as a number."
|
||||
msgstr "上個月的數字。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:57
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
|
||||
msgid "Last month, in its name."
|
||||
msgstr "上個月的名稱。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:58
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
|
||||
msgid "The previous bimonthly period, as numbers."
|
||||
msgstr "前個雙月期的數字。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:59
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:60
|
||||
msgid "The previous bimonthly period, as their names."
|
||||
msgstr "前個雙月期的名稱。"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
|
||||
msgid "Example:"
|
||||
msgstr "範例:"
|
||||
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:61
|
||||
#: src/accounting/templates/accounting/option/include/recurring-item-editor-modal.html:62
|
||||
msgid "Water bill for {last_bimonthly_name}"
|
||||
msgstr "水費{last_bimonthly_number}月"
|
||||
|
||||
@ -1318,13 +1318,13 @@ msgstr "%(period)s%(currency)s試算表"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:49
|
||||
msgid "Accounts with Unapplied Items"
|
||||
msgid "Accounts With Unapplied Items"
|
||||
msgstr "含未抵銷項目的科目"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:29
|
||||
#: src/accounting/templates/accounting/report/unapplied-accounts.html:51
|
||||
#, python-format
|
||||
msgid "Accounts with Unapplied Items in %(currency)s"
|
||||
msgid "Accounts With Unapplied Items in %(currency)s"
|
||||
msgstr "%(currency)s含未抵銷項目的科目"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unapplied.html:29
|
||||
@ -1339,13 +1339,13 @@ msgstr "%(currency)s%(account)s未抵銷項目"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
|
||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:49
|
||||
msgid "Accounts with Unmatched Offsets"
|
||||
msgid "Accounts With Unmatched Offsets"
|
||||
msgstr "含遺漏抵銷項目的科目"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:29
|
||||
#: src/accounting/templates/accounting/report/unmatched-accounts.html:51
|
||||
#, python-format
|
||||
msgid "Accounts with Unmatched Offsets in %(currency)s"
|
||||
msgid "Accounts With Unmatched Offsets in %(currency)s"
|
||||
msgstr "%(currency)s含遺漏抵銷項目的科目"
|
||||
|
||||
#: src/accounting/templates/accounting/report/unmatched.html:29
|
||||
@ -1415,12 +1415,12 @@ msgstr "下載"
|
||||
msgid "current assets and liabilities"
|
||||
msgstr "流動資產與負債"
|
||||
|
||||
#: src/accounting/utils/pagination.py:206
|
||||
#: src/accounting/utils/pagination.py:207
|
||||
msgctxt "Pagination|"
|
||||
msgid "Previous"
|
||||
msgstr "上一頁"
|
||||
|
||||
#: src/accounting/utils/pagination.py:255
|
||||
#: src/accounting/utils/pagination.py:256
|
||||
msgctxt "Pagination|"
|
||||
msgid "Next"
|
||||
msgstr "下一頁"
|
||||
|
@ -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,17 @@ This module should not import any other module from the application.
|
||||
from urllib.parse import urlparse, parse_qsl, ParseResult, urlencode, \
|
||||
urlunparse
|
||||
|
||||
from flask import request, Blueprint
|
||||
from flask import request, Blueprint, current_app
|
||||
from itsdangerous import URLSafeSerializer, BadData
|
||||
|
||||
|
||||
def __as_next() -> str:
|
||||
"""Encodes the current request URI as value for the next URI.
|
||||
|
||||
:return: The current request URI as value for the next URI.
|
||||
"""
|
||||
return encode_next(
|
||||
request.full_path if request.query_string else request.path)
|
||||
|
||||
|
||||
def append_next(uri: str) -> str:
|
||||
@ -41,11 +51,8 @@ def inherit_next(uri: str) -> str:
|
||||
:param uri: The URI.
|
||||
:return: The URI with the current next URI added at the query argument.
|
||||
"""
|
||||
next_uri: str | None = request.form.get("next") \
|
||||
if request.method == "POST" else request.args.get("next")
|
||||
if next_uri is None:
|
||||
return uri
|
||||
return __set_next(uri, next_uri)
|
||||
next_uri: str | None = __get_next()
|
||||
return uri if next_uri is None else __set_next(uri, next_uri)
|
||||
|
||||
|
||||
def or_next(uri: str) -> str:
|
||||
@ -54,9 +61,23 @@ 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 decode_next(next_uri)
|
||||
except BadData:
|
||||
return None
|
||||
|
||||
|
||||
def __set_next(uri: str, next_uri: str) -> str:
|
||||
@ -69,18 +90,39 @@ 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 decode_next(uri: str) -> str:
|
||||
"""Decodes the encoded next URI.
|
||||
|
||||
:param uri: The encoded next URI.
|
||||
:return: The next URI.
|
||||
"""
|
||||
return URLSafeSerializer(current_app.config["SECRET_KEY"])\
|
||||
.loads(uri, "next")
|
||||
|
||||
|
||||
def init_app(bp: Blueprint) -> None:
|
||||
"""Initializes the application.
|
||||
|
||||
:param bp: The blueprint of the accounting application.
|
||||
:return: None.
|
||||
"""
|
||||
bp.add_app_template_global(__as_next, "accounting_as_next")
|
||||
bp.add_app_template_filter(append_next, "accounting_append_next")
|
||||
bp.add_app_template_filter(inherit_next, "accounting_inherit_next")
|
||||
bp.add_app_template_filter(or_next, "accounting_or_next")
|
||||
|
@ -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"))
|
||||
|
@ -39,8 +39,11 @@ class RecurringItem:
|
||||
:param description_template: The description template.
|
||||
"""
|
||||
self.name: str = name
|
||||
"""The name."""
|
||||
self.account_code: str = account_code
|
||||
"""The account code."""
|
||||
self.description_template: str = description_template
|
||||
"""The description template."""
|
||||
|
||||
@property
|
||||
def account_text(self) -> str:
|
||||
@ -61,8 +64,10 @@ class Recurring:
|
||||
"""
|
||||
self.expenses: list[RecurringItem] \
|
||||
= [RecurringItem(x[0], x[1], x[2]) for x in data["expense"]]
|
||||
"""The recurring expenses."""
|
||||
self.incomes: list[RecurringItem] \
|
||||
= [RecurringItem(x[0], x[1], x[2]) for x in data["income"]]
|
||||
"""The recurring incomes."""
|
||||
|
||||
@property
|
||||
def codes(self) -> set[str]:
|
||||
|
@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/1/25
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2024 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -19,7 +19,6 @@
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, \
|
||||
ParseResult
|
||||
|
||||
@ -62,10 +61,8 @@ class Redirection(RequestRedirect):
|
||||
DEFAULT_PAGE_SIZE: int = 10
|
||||
"""The default page size."""
|
||||
|
||||
T = t.TypeVar("T")
|
||||
|
||||
|
||||
class Pagination(t.Generic[T]):
|
||||
class Pagination[T]:
|
||||
"""The pagination utility."""
|
||||
|
||||
def __init__(self, items: list[T], is_reversed: bool = False):
|
||||
@ -91,7 +88,7 @@ class Pagination(t.Generic[T]):
|
||||
"""The options to the number of items in a page."""
|
||||
|
||||
|
||||
class AbstractPagination(t.Generic[T]):
|
||||
class AbstractPagination[T]:
|
||||
"""An abstract pagination."""
|
||||
|
||||
def __init__(self):
|
||||
@ -108,12 +105,12 @@ class AbstractPagination(t.Generic[T]):
|
||||
"""The options to the number of items in a page."""
|
||||
|
||||
|
||||
class EmptyPagination(AbstractPagination[T]):
|
||||
class EmptyPagination[T](AbstractPagination[T]):
|
||||
"""The pagination from empty data."""
|
||||
pass
|
||||
|
||||
|
||||
class NonEmptyPagination(AbstractPagination[T]):
|
||||
class NonEmptyPagination[T](AbstractPagination[T]):
|
||||
"""The pagination with real data."""
|
||||
PAGE_SIZE_OPTION_VALUES: list[int] = [10, 100, 200]
|
||||
"""The page size options."""
|
||||
|
@ -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)
|
||||
|
59
src/accounting/utils/title_case.py
Normal file
59
src/accounting/utils/title_case.py
Normal file
@ -0,0 +1,59 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/7/29
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# 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 title case capitalization for the base account titles.
|
||||
This follows the APA style title case capitalization. See
|
||||
https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case .
|
||||
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import re
|
||||
|
||||
CONJUNCTIONS: set[str] = {"and", "as", "but", "for", "if", "nor", "or", "so",
|
||||
"yet"}
|
||||
"""Short conjunctions."""
|
||||
ARTICLES: set[str] = {"a", "an", "the"}
|
||||
"""Articles."""
|
||||
PREPOSITIONS: set[str] = {"as", "at", "by", "for", "in", "of", "on", "per",
|
||||
"to", "up", "via"}
|
||||
"""Short prepositions."""
|
||||
MINOR_WORDS: set[str] \
|
||||
= CONJUNCTIONS.copy().union(ARTICLES).union(PREPOSITIONS)
|
||||
"""Minor words that should be in lowercase."""
|
||||
# Excludes "by" as in "1223 by-products"
|
||||
MINOR_WORDS.remove("by")
|
||||
|
||||
|
||||
def title_case(s: str) -> str:
|
||||
"""Capitalize a title string for the base account titles. Do not use it
|
||||
in other places. This excludes "by" as in "1223 by-products".
|
||||
|
||||
:param s: The title string.
|
||||
:return: The capitalized title string.
|
||||
"""
|
||||
return re.sub(r"\w+", __cap_word, s)
|
||||
|
||||
|
||||
def __cap_word(m: re.Match) -> str:
|
||||
"""Capitalize a matched title word.
|
||||
|
||||
:param m: The matched title word.
|
||||
:return: The capitalized title word.
|
||||
"""
|
||||
if m.group(0).lower() in MINOR_WORDS:
|
||||
return m.group(0)
|
||||
return m.group(0).title()
|
@ -1,7 +1,7 @@
|
||||
# The Mia! Accounting Project.
|
||||
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/2/1
|
||||
|
||||
# Copyright (c) 2023 imacat.
|
||||
# Copyright (c) 2023-2024 imacat.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@ -19,17 +19,15 @@
|
||||
This module should not import any other module from the application.
|
||||
|
||||
"""
|
||||
import typing as t
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Type
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import g, Response
|
||||
from flask_sqlalchemy.model import Model
|
||||
|
||||
T = t.TypeVar("T", bound=Model)
|
||||
|
||||
|
||||
class UserUtilityInterface(t.Generic[T], ABC):
|
||||
class UserUtilityInterface[T: Model](ABC):
|
||||
"""The interface for the user utilities."""
|
||||
|
||||
@abstractmethod
|
||||
@ -72,7 +70,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 +110,7 @@ class UserUtilityInterface(t.Generic[T], ABC):
|
||||
|
||||
__user_utils: UserUtilityInterface
|
||||
"""The user utilities."""
|
||||
user_cls: t.Type[Model] = Model
|
||||
type user_cls = Model
|
||||
"""The user class."""
|
||||
user_pk_column: sa.Column = sa.Column(sa.Integer)
|
||||
"""The primary key column of the user class."""
|
||||
|
@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
|
||||
from opencc import OpenCC
|
||||
|
||||
root_dir: Path = Path(__file__).parent.parent
|
||||
"""The project root directory."""
|
||||
translation_dir: Path = root_dir / "tests" / "test_site" / "translations"
|
||||
"""The directory of the translation files."""
|
||||
domain: str = "messages"
|
||||
"""The message domain."""
|
||||
|
||||
|
||||
@click.group()
|
||||
|
@ -28,8 +28,11 @@ from babel.messages.frontend import CommandLineInterface
|
||||
from opencc import OpenCC
|
||||
|
||||
root_dir: Path = Path(__file__).parent.parent
|
||||
"""The project root directory."""
|
||||
translation_dir: Path = root_dir / "src" / "accounting" / "translations"
|
||||
"""The directory of the translation files."""
|
||||
domain: str = "accounting"
|
||||
"""The message domain."""
|
||||
|
||||
|
||||
@click.group()
|
||||
|
@ -17,15 +17,16 @@
|
||||
"""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
|
||||
from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
|
||||
set_locale, add_journal_entry
|
||||
|
||||
|
||||
class AccountData:
|
||||
@ -71,29 +72,35 @@ class AccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_test_app()
|
||||
self.__app: Flask = create_test_app()
|
||||
"""The Flask application."""
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
from accounting.models import Account, AccountL10n
|
||||
AccountL10n.query.delete()
|
||||
Account.query.delete()
|
||||
db.session.commit()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
self.client, self.csrf_token = get_client(self.app, "editor")
|
||||
self.__client: httpx.Client = get_client(self.__app, "editor")
|
||||
"""The user client."""
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": CASH.title})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": CASH.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{CASH.code}")
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": BANK.base_code,
|
||||
"title": BANK.title})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": BANK.base_code,
|
||||
"title": BANK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{BANK.code}")
|
||||
@ -104,7 +111,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
client, csrf_token = get_client(self.app, "nobody")
|
||||
client: httpx.Client = get_client(self.__app, "nobody")
|
||||
csrf_token: str = get_csrf_token(client)
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(PREFIX)
|
||||
@ -138,12 +146,12 @@ class AccountTestCase(unittest.TestCase):
|
||||
response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
cash_id: int = Account.find_by_code(CASH.code).id
|
||||
|
||||
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)
|
||||
|
||||
@ -153,7 +161,8 @@ class AccountTestCase(unittest.TestCase):
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import Account
|
||||
client, csrf_token = get_client(self.app, "viewer")
|
||||
client: httpx.Client = get_client(self.__app, "viewer")
|
||||
csrf_token: str = get_csrf_token(client)
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(PREFIX)
|
||||
@ -187,12 +196,12 @@ class AccountTestCase(unittest.TestCase):
|
||||
response = client.get(f"{PREFIX}/bases/{CASH.base_code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
cash_id: int = Account.find_by_code(CASH.code).id
|
||||
|
||||
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)
|
||||
|
||||
@ -204,48 +213,48 @@ class AccountTestCase(unittest.TestCase):
|
||||
from accounting.models import Account
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get(PREFIX)
|
||||
response = self.__client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/{CASH.code}")
|
||||
response = self.__client.get(f"{PREFIX}/{CASH.code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/create")
|
||||
response = self.__client.get(f"{PREFIX}/create")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{STOCK.code}")
|
||||
|
||||
response = self.client.get(f"{PREFIX}/{CASH.code}/edit")
|
||||
response = self.__client.get(f"{PREFIX}/{CASH.code}/edit")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(f"{PREFIX}/{CASH.code}/update",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-2"})
|
||||
response = self.__client.post(f"{PREFIX}/{CASH.code}/update",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
|
||||
|
||||
response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(f"{PREFIX}/{BANK.code}/delete",
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], PREFIX)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/bases/{CASH.base_code}")
|
||||
response = self.__client.get(f"{PREFIX}/bases/{CASH.base_code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
cash_id: int = Account.find_by_code(CASH.code).id
|
||||
|
||||
response = self.client.post(f"{PREFIX}/bases/{CASH.base_code}",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI,
|
||||
f"{cash_id}-no": "5"})
|
||||
response = self.__client.post(f"{PREFIX}/bases/{CASH.base_code}",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"next": self.__encoded_next_uri,
|
||||
f"{cash_id}-no": "5"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], NEXT_URI)
|
||||
|
||||
@ -260,96 +269,97 @@ class AccountTestCase(unittest.TestCase):
|
||||
detail_uri: str = f"{PREFIX}/{STOCK.code}"
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
{CASH.code, BANK.code})
|
||||
|
||||
# Missing CSRF token
|
||||
response = self.client.post(store_uri,
|
||||
data={"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# CSRF token mismatch
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": f"{self.csrf_token}-2",
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token":
|
||||
f"{self.__csrf_token}-2",
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Empty base account code
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": " ",
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": " ",
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Non-existing base account
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "9999",
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "9999",
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Unavailable base account
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1",
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "1",
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Empty name
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": " "})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": " "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# A nominal account that needs offset
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "6172",
|
||||
"title": STOCK.title,
|
||||
"is_need_offset": "yes"})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "6172",
|
||||
"title": STOCK.title,
|
||||
"is_need_offset": "yes"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Success, with spaces to be stripped
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": f" {STOCK.base_code} ",
|
||||
"title": f" {STOCK.title} "})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": f" {STOCK.base_code} ",
|
||||
"title": f" {STOCK.title} "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
# Success under the same base
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{STOCK.base_code}-002")
|
||||
|
||||
# Success under the same base, with order in a mess.
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
stock_2: Account = Account.find_by_code(f"{STOCK.base_code}-002")
|
||||
stock_2.no = 66
|
||||
db.session.commit()
|
||||
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/{STOCK.base_code}-003")
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
{CASH.code, BANK.code, STOCK.code,
|
||||
f"{STOCK.base_code}-002",
|
||||
@ -372,71 +382,71 @@ class AccountTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
# Success, with spaces to be stripped
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": f" {CASH.base_code} ",
|
||||
"title": f" {CASH.title}-1 "})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": f" {CASH.base_code} ",
|
||||
"title": f" {CASH.title}-1 "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account: Account = Account.find_by_code(CASH.code)
|
||||
self.assertEqual(account.base_code, CASH.base_code)
|
||||
self.assertEqual(account.title_l10n, f"{CASH.title}-1")
|
||||
|
||||
# Empty base account code
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": " ",
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": " ",
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Non-existing base account
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "9999",
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "9999",
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Unavailable base account
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1",
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "1",
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Empty name
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": " "})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": " "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# A nominal account that needs offset
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "6172",
|
||||
"title": STOCK.title,
|
||||
"is_need_offset": "yes"})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "6172",
|
||||
"title": STOCK.title,
|
||||
"is_need_offset": "yes"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Change the base account
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": STOCK.base_code,
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_c_uri)
|
||||
|
||||
response = self.client.get(detail_uri)
|
||||
response = self.__client.get(detail_uri)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
response = self.client.get(detail_c_uri)
|
||||
response = self.__client.get(detail_c_uri)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_update_not_modified(self) -> None:
|
||||
@ -450,29 +460,29 @@ class AccountTestCase(unittest.TestCase):
|
||||
account: Account
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": f" {CASH.base_code} ",
|
||||
"title": f" {CASH.title} "})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": f" {CASH.base_code} ",
|
||||
"title": f" {CASH.title} "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
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()
|
||||
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": STOCK.title})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": STOCK.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertIsNotNone(account)
|
||||
self.assertLess(account.created_at,
|
||||
@ -485,13 +495,14 @@ class AccountTestCase(unittest.TestCase):
|
||||
"""
|
||||
from accounting.models import Account
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
client: httpx.Client = get_client(self.__app, admin_username)
|
||||
csrf_token: str = get_csrf_token(client)
|
||||
detail_uri: str = f"{PREFIX}/{CASH.code}"
|
||||
update_uri: str = f"{PREFIX}/{CASH.code}/update"
|
||||
account: Account
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertEqual(account.created_by.username, editor_username)
|
||||
self.assertEqual(account.updated_by.username, editor_username)
|
||||
@ -503,7 +514,7 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertEqual(account.created_by.username,
|
||||
editor_username)
|
||||
@ -521,51 +532,51 @@ class AccountTestCase(unittest.TestCase):
|
||||
account: Account
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
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,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-zh_Hant"})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-zh_Hant"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertEqual(account.title_l10n, CASH.title)
|
||||
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,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-2"})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertEqual(account.title_l10n, f"{CASH.title}-2")
|
||||
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,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-zh_Hant-2"})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": CASH.base_code,
|
||||
"title": f"{CASH.title}-zh_Hant-2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account = Account.find_by_code(CASH.code)
|
||||
self.assertEqual(account.title_l10n, f"{CASH.title}-2")
|
||||
self.assertEqual({(x.locale, x.title) for x in account.l10n},
|
||||
@ -582,53 +593,53 @@ class AccountTestCase(unittest.TestCase):
|
||||
list_uri: str = PREFIX
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": PETTY.base_code,
|
||||
"title": PETTY.title})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": PETTY.base_code,
|
||||
"title": PETTY.title})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
add_journal_entry(self.client,
|
||||
form={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI,
|
||||
"date": date.today().isoformat(),
|
||||
add_journal_entry(self.__client,
|
||||
form={"csrf_token": self.__csrf_token,
|
||||
"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"})
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
{CASH.code, PETTY.code, BANK.code})
|
||||
|
||||
# Cannot delete the cash account
|
||||
response = self.client.post(f"{PREFIX}/{CASH.code}/delete",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(f"{PREFIX}/{CASH.code}/delete",
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{CASH.code}")
|
||||
|
||||
# Cannot delete the account that is in use
|
||||
response = self.client.post(f"{PREFIX}/{BANK.code}/delete",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(f"{PREFIX}/{BANK.code}/delete",
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{BANK.code}")
|
||||
|
||||
# Success
|
||||
response = self.client.get(detail_uri)
|
||||
response = self.__client.get(detail_uri)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.post(delete_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(delete_uri,
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Account.query.all()},
|
||||
{CASH.code, BANK.code})
|
||||
|
||||
response = self.client.get(detail_uri)
|
||||
response = self.__client.get(detail_uri)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
response = self.client.post(delete_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(delete_uri,
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_change_base_code(self) -> None:
|
||||
@ -640,15 +651,15 @@ class AccountTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
for i in range(2, 6):
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title"})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/1111-00{i}")
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
account_1: Account = Account.find_by_code("1111-001")
|
||||
id_1: int = account_1.id
|
||||
account_2: Account = Account.find_by_code("1111-002")
|
||||
@ -668,14 +679,14 @@ class AccountTestCase(unittest.TestCase):
|
||||
account_5.no = 6
|
||||
db.session.commit()
|
||||
|
||||
response = self.client.post(f"{PREFIX}/1111-005/update",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "Title"})
|
||||
response = self.__client.post(f"{PREFIX}/1111-005/update",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "1112",
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/1112-003")
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).no, 1)
|
||||
self.assertEqual(db.session.get(Account, id_2).no, 3)
|
||||
self.assertEqual(db.session.get(Account, id_3).no, 2)
|
||||
@ -691,34 +702,34 @@ class AccountTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
for i in range(2, 6):
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title"})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"base_code": "1111",
|
||||
"title": "Title"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"],
|
||||
f"{PREFIX}/1111-00{i}")
|
||||
|
||||
# Normal reorder
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
id_1: int = Account.find_by_code("1111-001").id
|
||||
id_2: int = Account.find_by_code("1111-002").id
|
||||
id_3: int = Account.find_by_code("1111-003").id
|
||||
id_4: int = Account.find_by_code("1111-004").id
|
||||
id_5: int = Account.find_by_code("1111-005").id
|
||||
|
||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": 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"})
|
||||
response = self.__client.post(f"{PREFIX}/bases/1111",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"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"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-004")
|
||||
self.assertEqual(db.session.get(Account, id_2).code, "1111-001")
|
||||
self.assertEqual(db.session.get(Account, id_3).code, "1111-005")
|
||||
@ -726,7 +737,7 @@ class AccountTestCase(unittest.TestCase):
|
||||
self.assertEqual(db.session.get(Account, id_5).code, "1111-003")
|
||||
|
||||
# Malformed orders
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
db.session.get(Account, id_1).no = 3
|
||||
db.session.get(Account, id_2).no = 4
|
||||
db.session.get(Account, id_3).no = 6
|
||||
@ -734,16 +745,16 @@ class AccountTestCase(unittest.TestCase):
|
||||
db.session.get(Account, id_5).no = 9
|
||||
db.session.commit()
|
||||
|
||||
response = self.client.post(f"{PREFIX}/bases/1111",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI,
|
||||
f"{id_2}-no": "3a",
|
||||
f"{id_3}-no": "5",
|
||||
f"{id_4}-no": "2"})
|
||||
response = self.__client.post(f"{PREFIX}/bases/1111",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"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"], NEXT_URI)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual(db.session.get(Account, id_1).code, "1111-003")
|
||||
self.assertEqual(db.session.get(Account, id_2).code, "1111-004")
|
||||
self.assertEqual(db.session.get(Account, id_3).code, "1111-002")
|
||||
|
@ -39,14 +39,15 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_test_app()
|
||||
self.__app: Flask = create_test_app()
|
||||
"""The Flask application."""
|
||||
|
||||
def test_nobody(self) -> None:
|
||||
"""Test the permission as nobody.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "nobody")
|
||||
client: httpx.Client = get_client(self.__app, "nobody")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(LIST_URI)
|
||||
@ -60,7 +61,7 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "viewer")
|
||||
client: httpx.Client = get_client(self.__app, "viewer")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(LIST_URI)
|
||||
@ -74,7 +75,7 @@ class BaseAccountTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "editor")
|
||||
client: httpx.Client = get_client(self.__app, "editor")
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(LIST_URI)
|
||||
|
@ -18,8 +18,10 @@
|
||||
|
||||
"""
|
||||
import csv
|
||||
import typing as t
|
||||
import datetime as dt
|
||||
import re
|
||||
import unittest
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from click.testing import Result
|
||||
@ -40,11 +42,17 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_test_app()
|
||||
self.__app: Flask = create_test_app()
|
||||
"""The Flask application."""
|
||||
|
||||
with self.app.app_context():
|
||||
# Drop every accounting table, to see if accounting-init recreates
|
||||
# them correctly.
|
||||
def test_init_db(self) -> None:
|
||||
"""Tests the "accounting-init-db" console command.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
with self.__app.app_context():
|
||||
# Drop every accounting table, to see if accounting-init-db
|
||||
# recreates them correctly.
|
||||
tables: list[sa.Table] \
|
||||
= [db.metadata.tables[x] for x in db.metadata.tables
|
||||
if x.startswith("accounting_")]
|
||||
@ -56,13 +64,8 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
if x.startswith("accounting_")}),
|
||||
0)
|
||||
|
||||
def test_init(self) -> None:
|
||||
"""Tests the "accounting-init" console command.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
runner: FlaskCliRunner = self.app.test_cli_runner()
|
||||
with self.app.app_context():
|
||||
runner: FlaskCliRunner = self.__app.test_cli_runner()
|
||||
with self.__app.app_context():
|
||||
result: Result = runner.invoke(
|
||||
args=["accounting-init-db", "-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0,
|
||||
@ -80,20 +83,23 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
from accounting.models import BaseAccount
|
||||
|
||||
with open(data_dir / "base_accounts.csv") as fp:
|
||||
data: dict[dict[str, t.Any]] \
|
||||
= {x["code"]: {"code": x["code"],
|
||||
"title": x["title"],
|
||||
"l10n": {y[5:]: x[y]
|
||||
for y in x if y.startswith("l10n-")}}
|
||||
for x in csv.DictReader(fp)}
|
||||
rows: list[dict[str, str]] = list(csv.DictReader(fp))
|
||||
data: dict[dict[str, Any]] \
|
||||
= {x["code"]: {"code": x["code"],
|
||||
"title": x["title"],
|
||||
"l10n": {y[5:]: x[y]
|
||||
for y in x if y.startswith("l10n-")}}
|
||||
for x in rows}
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
accounts: list[BaseAccount] = BaseAccount.query.all()
|
||||
|
||||
self.assertEqual(len(accounts), len(data))
|
||||
for account in accounts:
|
||||
self.assertIn(account.code, data)
|
||||
self.assertEqual(account.title_l10n, data[account.code]["title"])
|
||||
self.assertEqual(account.title_l10n.lower(),
|
||||
data[account.code]["title"].lower())
|
||||
self.__test_title_case(account.title_l10n)
|
||||
l10n: dict[str, str] = {x.locale: x.title for x in account.l10n}
|
||||
self.assertEqual(len(l10n), len(data[account.code]["l10n"]))
|
||||
for locale in l10n:
|
||||
@ -101,6 +107,23 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
self.assertEqual(l10n[locale],
|
||||
data[account.code]["l10n"][locale])
|
||||
|
||||
def __test_title_case(self, s: str) -> None:
|
||||
"""Tests the case of a base account title.
|
||||
|
||||
:param s: The base account title.
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.utils.title_case import MINOR_WORDS
|
||||
|
||||
self.assertTrue(s[0].isupper(), s)
|
||||
for word in re.findall(r"\w+", s):
|
||||
if len(word) >= 4:
|
||||
self.assertTrue(word.istitle(), s)
|
||||
elif word in MINOR_WORDS:
|
||||
self.assertTrue(word.islower(), s)
|
||||
else:
|
||||
self.assertTrue(word.istitle(), s)
|
||||
|
||||
def __test_account_data(self) -> None:
|
||||
"""Tests the account data.
|
||||
|
||||
@ -108,7 +131,7 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
"""
|
||||
from accounting.models import BaseAccount, Account, AccountL10n
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
bases: list[BaseAccount] = BaseAccount.query\
|
||||
.filter(sa.func.char_length(BaseAccount.code) == 4).all()
|
||||
accounts: list[Account] = Account.query.all()
|
||||
@ -135,14 +158,14 @@ 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]
|
||||
for y in x if y.startswith("l10n-")}}
|
||||
for x in csv.DictReader(fp)}
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currencies: list[Currency] = Currency.query.all()
|
||||
|
||||
self.assertEqual(len(currencies), len(data))
|
||||
@ -155,3 +178,69 @@ class ConsoleCommandTestCase(unittest.TestCase):
|
||||
self.assertIn(locale, data[currency.code]["l10n"])
|
||||
self.assertEqual(l10n[locale],
|
||||
data[currency.code]["l10n"][locale])
|
||||
|
||||
def test_titleize(self) -> None:
|
||||
"""Tests the "accounting-titleize" console command.
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
from accounting.models import BaseAccount, Account
|
||||
from accounting.utils.random_id import new_id
|
||||
from accounting.utils.user import get_user_pk
|
||||
runner: FlaskCliRunner = self.__app.test_cli_runner()
|
||||
|
||||
with self.__app.app_context():
|
||||
# Resets the accounts.
|
||||
tables: list[sa.Table] \
|
||||
= [db.metadata.tables[x] for x in db.metadata.tables
|
||||
if x.startswith("accounting_")]
|
||||
for table in tables:
|
||||
db.session.execute(DropTable(table))
|
||||
db.session.commit()
|
||||
inspector: sa.Inspector = sa.inspect(db.session.connection())
|
||||
self.assertEqual(len({x for x in inspector.get_table_names()
|
||||
if x.startswith("accounting_")}),
|
||||
0)
|
||||
result: Result = runner.invoke(
|
||||
args=["accounting-init-db", "-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0,
|
||||
result.output + str(result.exception))
|
||||
|
||||
# Turns the titles into lowercase.
|
||||
for base in BaseAccount.query:
|
||||
base.title_l10n = base.title_l10n.lower()
|
||||
for account in Account.query:
|
||||
account.title_l10n = account.title_l10n.lower()
|
||||
account.created_at \
|
||||
= account.created_at - dt.timedelta(seconds=5)
|
||||
account.updated_at = account.created_at
|
||||
|
||||
# Adds a custom account.
|
||||
custom_title = "MBK Bank"
|
||||
creator_pk: int = get_user_pk("editor")
|
||||
new_account: Account = Account(
|
||||
id=new_id(Account),
|
||||
base_code="1112",
|
||||
no="2",
|
||||
title_l10n=custom_title,
|
||||
is_need_offset=False,
|
||||
created_by_id=creator_pk,
|
||||
updated_by_id=creator_pk)
|
||||
db.session.add(new_account)
|
||||
db.session.commit()
|
||||
|
||||
result: Result = runner.invoke(
|
||||
args=["accounting-titleize", "-u", "editor"])
|
||||
self.assertEqual(result.exit_code, 0,
|
||||
result.output + str(result.exception))
|
||||
for base in BaseAccount.query:
|
||||
self.__test_title_case(base.title_l10n)
|
||||
for account in Account.query:
|
||||
if account.id != new_account.id:
|
||||
self.__test_title_case(account.title_l10n)
|
||||
self.assertNotEqual(account.created_at, account.updated_at)
|
||||
else:
|
||||
self.assertEqual(account.title_l10n, custom_title)
|
||||
|
||||
db.session.delete(new_account)
|
||||
db.session.commit()
|
||||
|
@ -17,15 +17,16 @@
|
||||
"""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
|
||||
from testlib import NEXT_URI, create_test_app, get_client, get_csrf_token, \
|
||||
set_locale, add_journal_entry
|
||||
|
||||
|
||||
class CurrencyData:
|
||||
@ -64,28 +65,32 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_test_app()
|
||||
self.__app: Flask = create_test_app()
|
||||
"""The Flask application."""
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
from accounting.models import Currency, CurrencyL10n
|
||||
CurrencyL10n.query.delete()
|
||||
Currency.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
self.client, self.csrf_token = get_client(self.app, "editor")
|
||||
self.__client: httpx.Client = get_client(self.__app, "editor")
|
||||
"""The user client."""
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": USD.code,
|
||||
"name": USD.name})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": USD.code,
|
||||
"name": USD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": EUR.code,
|
||||
"name": EUR.name})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": EUR.code,
|
||||
"name": EUR.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
|
||||
|
||||
@ -94,7 +99,8 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "nobody")
|
||||
client: httpx.Client = get_client(self.__app, "nobody")
|
||||
csrf_token: str = get_csrf_token(client)
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(PREFIX)
|
||||
@ -130,7 +136,8 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
client, csrf_token = get_client(self.app, "viewer")
|
||||
client: httpx.Client = get_client(self.__app, "viewer")
|
||||
csrf_token: str = get_csrf_token(client)
|
||||
response: httpx.Response
|
||||
|
||||
response = client.get(PREFIX)
|
||||
@ -168,34 +175,34 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
"""
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get(PREFIX)
|
||||
response = self.__client.get(PREFIX)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/{USD.code}")
|
||||
response = self.__client.get(f"{PREFIX}/{USD.code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.get(f"{PREFIX}/create")
|
||||
response = self.__client.get(f"{PREFIX}/create")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{TWD.code}")
|
||||
|
||||
response = self.client.get(f"{PREFIX}/{USD.code}/edit")
|
||||
response = self.__client.get(f"{PREFIX}/{USD.code}/edit")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(f"{PREFIX}/{USD.code}/update",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": JPY.code,
|
||||
"name": JPY.name})
|
||||
response = self.__client.post(f"{PREFIX}/{USD.code}/update",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": JPY.code,
|
||||
"name": JPY.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{JPY.code}")
|
||||
|
||||
response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(f"{PREFIX}/{EUR.code}/delete",
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], PREFIX)
|
||||
|
||||
@ -210,72 +217,73 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
detail_uri: str = f"{PREFIX}/{TWD.code}"
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
{USD.code, EUR.code})
|
||||
|
||||
# Missing CSRF token
|
||||
response = self.client.post(store_uri,
|
||||
data={"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# CSRF token mismatch
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": f"{self.csrf_token}-2",
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token":
|
||||
f"{self.__csrf_token}-2",
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# Empty code
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": " ",
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": " ",
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Blocked code, with spaces to be stripped
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": " create ",
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": " create ",
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Bad code
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": " zzc ",
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": " zzc ",
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Empty name
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": " "})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": " "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
# Success, with spaces to be stripped
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": f" {TWD.code} ",
|
||||
"name": f" {TWD.name} "})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": f" {TWD.code} ",
|
||||
"name": f" {TWD.name} "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
# Duplicated code
|
||||
response = self.client.post(store_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(store_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], create_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
{USD.code, EUR.code, TWD.code})
|
||||
|
||||
@ -296,70 +304,70 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
response: httpx.Response
|
||||
|
||||
# Success, with spaces to be stripped
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": f" {USD.code} ",
|
||||
"name": f" {USD.name}-1 "})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": f" {USD.code} ",
|
||||
"name": f" {USD.name}-1 "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency: Currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.code, USD.code)
|
||||
self.assertEqual(currency.name_l10n, f"{USD.name}-1")
|
||||
|
||||
# Empty code
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": " ",
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": " ",
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Blocked code, with spaces to be stripped
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": " create ",
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": " create ",
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Bad code
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": "abc/def",
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": "abc/def",
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Empty name
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": " "})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": " "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Duplicated code
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": EUR.code,
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": EUR.code,
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], edit_uri)
|
||||
|
||||
# Change code
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": TWD.code,
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_c_uri)
|
||||
|
||||
response = self.client.get(detail_uri)
|
||||
response = self.__client.get(detail_uri)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
response = self.client.get(detail_c_uri)
|
||||
response = self.__client.get(detail_c_uri)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_update_not_modified(self) -> None:
|
||||
@ -373,29 +381,29 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
currency: Currency | None
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": f" {USD.code} ",
|
||||
"name": f" {USD.name} "})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": f" {USD.code} ",
|
||||
"name": f" {USD.name} "})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
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()
|
||||
|
||||
response = self.client.post(update_uri,
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": USD.code,
|
||||
"name": TWD.name})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": USD.code,
|
||||
"name": TWD.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertIsNotNone(currency)
|
||||
self.assertLess(currency.created_at,
|
||||
@ -408,13 +416,14 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
"""
|
||||
from accounting.models import Currency
|
||||
editor_username, admin_username = "editor", "admin"
|
||||
client, csrf_token = get_client(self.app, admin_username)
|
||||
client: httpx.Client = get_client(self.__app, admin_username)
|
||||
csrf_token: str = get_csrf_token(client)
|
||||
detail_uri: str = f"{PREFIX}/{USD.code}"
|
||||
update_uri: str = f"{PREFIX}/{USD.code}/update"
|
||||
currency: Currency
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.created_by.username, editor_username)
|
||||
self.assertEqual(currency.updated_by.username, editor_username)
|
||||
@ -426,7 +435,7 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.created_by.username, editor_username)
|
||||
self.assertEqual(currency.updated_by.username, admin_username)
|
||||
@ -438,14 +447,14 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
"""
|
||||
response: httpx.Response
|
||||
|
||||
response = self.client.get(
|
||||
response = self.__client.get(
|
||||
f"/accounting/api/currencies/exists-code?q={USD.code}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(set(data.keys()), {"exists"})
|
||||
self.assertTrue(data["exists"])
|
||||
|
||||
response = self.client.get(
|
||||
response = self.__client.get(
|
||||
f"/accounting/api/currencies/exists-code?q={USD.code}-1")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
@ -463,51 +472,51 @@ class CurrencyTestCase(unittest.TestCase):
|
||||
currency: Currency
|
||||
response: httpx.Response
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
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,
|
||||
"code": USD.code,
|
||||
"name": f"{USD.name}-zh_Hant"})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": USD.code,
|
||||
"name": f"{USD.name}-zh_Hant"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.name_l10n, USD.name)
|
||||
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,
|
||||
"code": USD.code,
|
||||
"name": f"{USD.name}-2"})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": USD.code,
|
||||
"name": f"{USD.name}-2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.name_l10n, f"{USD.name}-2")
|
||||
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,
|
||||
"code": USD.code,
|
||||
"name": f"{USD.name}-zh_Hant-2"})
|
||||
response = self.__client.post(update_uri,
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": USD.code,
|
||||
"name": f"{USD.name}-zh_Hant-2"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
currency = db.session.get(Currency, USD.code)
|
||||
self.assertEqual(currency.name_l10n, f"{USD.name}-2")
|
||||
self.assertEqual({(x.locale, x.name) for x in currency.l10n},
|
||||
@ -521,54 +530,56 @@ 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
|
||||
|
||||
response = self.client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.csrf_token,
|
||||
"code": JPY.code,
|
||||
"name": JPY.name})
|
||||
response = self.__client.post(f"{PREFIX}/store",
|
||||
data={"csrf_token": self.__csrf_token,
|
||||
"code": JPY.code,
|
||||
"name": JPY.name})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], detail_uri)
|
||||
|
||||
add_journal_entry(self.client,
|
||||
form={"csrf_token": self.csrf_token,
|
||||
"next": NEXT_URI,
|
||||
"date": date.today().isoformat(),
|
||||
add_journal_entry(self.__client,
|
||||
form={"csrf_token": self.__csrf_token,
|
||||
"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"})
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
{USD.code, EUR.code, JPY.code})
|
||||
|
||||
# Cannot delete the default currency
|
||||
response = self.client.post(f"{PREFIX}/{USD.code}/delete",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(f"{PREFIX}/{USD.code}/delete",
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{USD.code}")
|
||||
|
||||
# Cannot delete the account that is in use
|
||||
response = self.client.post(f"{PREFIX}/{EUR.code}/delete",
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(f"{PREFIX}/{EUR.code}/delete",
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], f"{PREFIX}/{EUR.code}")
|
||||
|
||||
# Success
|
||||
response = self.client.get(detail_uri)
|
||||
response = self.__client.get(detail_uri)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
response = self.client.post(delete_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(delete_uri,
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["Location"], list_uri)
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
self.assertEqual({x.code for x in Currency.query.all()},
|
||||
{USD.code, EUR.code})
|
||||
|
||||
response = self.client.get(detail_uri)
|
||||
response = self.__client.get(detail_uri)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
response = self.client.post(delete_uri,
|
||||
data={"csrf_token": self.csrf_token})
|
||||
response = self.__client.post(delete_uri,
|
||||
data={"csrf_token": self.__csrf_token})
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
@ -17,13 +17,15 @@
|
||||
"""The test for the description editor.
|
||||
|
||||
"""
|
||||
import datetime as dt
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
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
|
||||
get_csrf_token, add_journal_entry
|
||||
|
||||
|
||||
class DescriptionEditorTestCase(unittest.TestCase):
|
||||
@ -35,14 +37,20 @@ class DescriptionEditorTestCase(unittest.TestCase):
|
||||
|
||||
:return: None.
|
||||
"""
|
||||
self.app: Flask = create_test_app()
|
||||
self.__app: Flask = create_test_app()
|
||||
"""The Flask application."""
|
||||
|
||||
with self.app.app_context():
|
||||
with self.__app.app_context():
|
||||
from accounting.models import JournalEntry, JournalEntryLineItem
|
||||
JournalEntry.query.delete()
|
||||
JournalEntryLineItem.query.delete()
|
||||
self.__encoded_next_uri: str = encode_next(NEXT_URI)
|
||||
"""The encoded next URI."""
|
||||
|
||||
self.client, self.csrf_token = get_client(self.app, "editor")
|
||||
self.__client: httpx.Client = get_client(self.__app, "editor")
|
||||
"""The user client."""
|
||||
self.__csrf_token: str = get_csrf_token(self.__client)
|
||||
"""The CSRF token."""
|
||||
|
||||
def test_description_editor(self) -> None:
|
||||
"""Test the description editor.
|
||||
@ -51,9 +59,9 @@ class DescriptionEditorTestCase(unittest.TestCase):
|
||||
"""
|
||||
from accounting.journal_entry.utils.description_editor import \
|
||||
DescriptionEditor
|
||||
for form in get_form_data(self.csrf_token):
|
||||
add_journal_entry(self.client, form)
|
||||
with self.app.app_context():
|
||||
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()
|
||||
|
||||
# Debit-General
|
||||
@ -143,22 +151,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 +190,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 +206,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 +222,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 +257,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 +303,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,
|
||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user