Compare commits

..

No commits in common. "4be1ead6b53c6068733303f6cf0f5dd7dfc0b2ca" and "1e56403b357e0d758c5bb461e0f85efa0be5eeac" have entirely different histories.

21 changed files with 517 additions and 291 deletions

View File

@ -47,8 +47,7 @@ Prerequisites
============= =============
You need a running Flask application with database user login. You need a running Flask application with database user login.
The primary key of the user data model must be integer. You also The primary key of the user data model must be integer.
need at least one user.
The following front-end JavaScript libraries must be loaded. You may The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_. download it locally or use CDN_.
@ -123,13 +122,24 @@ The following is an example configuration for *Mia! Accounting*.
Database Initialization Database Initialization
======================= =======================
After the configuration, run the ``accounting-init-db`` console After the configuration, you need to run
command to initialize the accounting database. You need to specify `flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
the username of a user as the data creator. database tables that *Mia! Accounting* uses.
*Mia! Accounting* adds three console commands:
* ``accounting-init-base``
* ``accounting-init-accounts``
* ``accounting-init-currencies``
After database tables are created, run
``accounting-init-base`` first, and then the other two commands.
:: ::
% flask --app myapp accounting-init-db -u username % flask --app myapp accounting-init-base
% flask --app myapp accounting-init-accounts
% flask --app myapp accounting-init-currencies
Navigation Menu Navigation Menu

View File

@ -42,8 +42,7 @@ Prerequisites
------------- -------------
You need a running Flask application with database user login. You need a running Flask application with database user login.
The primary key of the user data model must be integer. You also The primary key of the user data model must be integer.
need at least one user.
The following front-end JavaScript libraries must be loaded. You may The following front-end JavaScript libraries must be loaded. You may
download it locally or use CDN_. download it locally or use CDN_.
@ -68,13 +67,24 @@ See an example in :ref:`example-userutils`.
Database Initialization Database Initialization
----------------------- -----------------------
After the configuration, run the ``accounting-init-db`` console After the configuration, you need to run
command to initialize the accounting database. You need to specify `flask_sqlalchemy.SQLAlchemy.create_all`_ to create the
the username of a user as the data creator. database tables that *Mia! Accounting* uses.
*Mia! Accounting* adds three console commands:
* ``accounting-init-base``
* ``accounting-init-accounts``
* ``accounting-init-currencies``
After database tables are created, run
``accounting-init-base`` first, and then the other two commands.
:: ::
% flask --app myapp accounting-init-db -u username % flask --app myapp accounting-init-base
% flask --app myapp accounting-init-accounts
% flask --app myapp accounting-init-currencies
Navigation Menu Navigation Menu

View File

@ -61,9 +61,6 @@ def init_app(app: Flask, user_utils: UserUtilityInterface,
bp.add_app_template_global(default_currency_code, bp.add_app_template_global(default_currency_code,
"accounting_default_currency_code") "accounting_default_currency_code")
from .commands import init_db_command
app.cli.add_command(init_db_command)
from . import locale from . import locale
locale.init_app(app, bp) locale.init_app(app, bp)

View File

@ -19,8 +19,6 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -34,3 +32,6 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as account_bp from .views import bp as account_bp
bp.register_blueprint(account_bp, url_prefix="/accounts") bp.register_blueprint(account_bp, url_prefix="/accounts")
from .commands import init_accounts_command
app.cli.add_command(init_accounts_command)

View File

@ -17,21 +17,44 @@
"""The console commands for the account management. """The console commands for the account management.
""" """
import typing as t import os
from secrets import randbelow from secrets import randbelow
import click import click
from flask.cli import with_appcontext
from accounting import db from accounting import db
from accounting.models import BaseAccount, Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
from accounting.utils.user import get_user_pk from accounting.utils.user import has_user, get_user_pk
import sqlalchemy as sa
AccountData = tuple[int, str, int, str, str, str, bool] AccountData = tuple[int, str, int, str, str, str, bool]
"""The format of the account data, as a list of (ID, base account code, number, """The format of the account data, as a list of (ID, base account code, number,
English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples.""" English, Traditional Chinese, Simplified Chinese, is-need-offset) tuples."""
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-accounts")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_accounts_command(username: str) -> None: def init_accounts_command(username: str) -> None:
"""Initializes the accounts.""" """Initializes the accounts."""
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
@ -40,6 +63,8 @@ def init_accounts_command(username: str) -> None:
.filter(db.func.length(BaseAccount.code) == 4)\ .filter(db.func.length(BaseAccount.code) == 4)\
.order_by(BaseAccount.code).all() .order_by(BaseAccount.code).all()
if len(bases) == 0: if len(bases) == 0:
click.echo("Please initialize the base accounts with "
"\"flask accounting-init-base\" first.")
raise click.Abort raise click.Abort
existing: list[Account] = Account.query.all() existing: list[Account] = Account.query.all()
@ -48,6 +73,7 @@ def init_accounts_command(username: str) -> None:
bases_to_add: list[BaseAccount] = [x for x in bases bases_to_add: list[BaseAccount] = [x for x in bases
if x.code not in existing_base_code] if x.code not in existing_base_code]
if len(bases_to_add) == 0: if len(bases_to_add) == 0:
click.echo("No more account to import.")
return return
existing_id: set[int] = {x.id for x in existing} existing_id: set[int] = {x.id for x in existing}
@ -63,24 +89,14 @@ def init_accounts_command(username: str) -> None:
existing_id.add(new_id) existing_id.add(new_id)
return new_id return new_id
data: list[dict[str, t.Any]] = [] data: list[AccountData] = []
l10n_data: list[dict[str, t.Any]] = []
for base in bases_to_add: for base in bases_to_add:
l10n: dict[str, str] = {x.locale: x.title for x in base.l10n} l10n: dict[str, str] = {x.locale: x.title for x in base.l10n}
account_id: int = get_new_id() is_need_offset: bool = __is_need_offset(base.code)
data.append({"id": account_id, data.append((get_new_id(), base.code, 1, base.title_l10n,
"base_code": base.code, l10n["zh_Hant"], l10n["zh_Hans"], is_need_offset))
"no": 1, __add_accounting_accounts(data, creator_pk)
"title_l10n": base.title_l10n, click.echo(F"{len(data)} added. Accounting accounts initialized.")
"is_need_offset": __is_need_offset(base.code),
"created_by_id": creator_pk,
"updated_by_id": creator_pk})
for locale in {"zh_Hant", "zh_Hans"}:
l10n_data.append({"account_id": account_id,
"locale": locale,
"title": l10n[locale]})
db.session.execute(sa.insert(Account), data)
db.session.execute(sa.insert(AccountL10n), l10n_data)
def __is_need_offset(base_code: str) -> bool: def __is_need_offset(base_code: str) -> bool:
@ -105,3 +121,29 @@ def __is_need_offset(base_code: str) -> bool:
return True return True
# Only assets and liabilities need offset # Only assets and liabilities need offset
return False return False
def __add_accounting_accounts(data: list[AccountData], creator_pk: int)\
-> None:
"""Adds the accounts.
:param data: A list of (base code, number, title) tuples.
:param creator_pk: The primary key of the creator.
:return: None.
"""
accounts: list[Account] = [Account(id=x[0],
base_code=x[1],
no=x[2],
title_l10n=x[3],
is_need_offset=x[6],
created_by_id=creator_pk,
updated_by_id=creator_pk)
for x in data]
l10n: list[AccountL10n] = [AccountL10n(account_id=x[0],
locale=y[0],
title=y[1])
for x in data
for y in (("zh_Hant", x[4]), ("zh_Hans", x[5]))]
db.session.bulk_save_objects(accounts)
db.session.bulk_save_objects(l10n)
db.session.commit()

View File

@ -19,8 +19,6 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_base_accounts_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -34,3 +32,6 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as base_account_bp from .views import bp as base_account_bp
bp.register_blueprint(base_account_bp, url_prefix="/base-accounts") bp.register_blueprint(base_account_bp, url_prefix="/base-accounts")
from .commands import init_base_accounts_command
app.cli.add_command(init_base_accounts_command)

View File

@ -19,17 +19,21 @@
""" """
import csv import csv
import sqlalchemy as sa import click
from flask.cli import with_appcontext
from accounting import data_dir from accounting import data_dir
from accounting import db from accounting import db
from accounting.models import BaseAccount, BaseAccountL10n from accounting.models import BaseAccount, BaseAccountL10n
@click.command("accounting-init-base")
@with_appcontext
def init_base_accounts_command() -> None: def init_base_accounts_command() -> None:
"""Initializes the base accounts.""" """Initializes the base accounts."""
if BaseAccount.query.first() is not None: if BaseAccount.query.first() is not None:
return click.echo("Base accounts already exist.")
raise click.Abort
with open(data_dir / "base_accounts.csv") as fp: with open(data_dir / "base_accounts.csv") as fp:
data: list[dict[str, str]] = [x for x in csv.DictReader(fp)] data: list[dict[str, str]] = [x for x in csv.DictReader(fp)]
@ -41,5 +45,7 @@ def init_base_accounts_command() -> None:
"locale": y, "locale": y,
"title": x[f"l10n-{y}"]} "title": x[f"l10n-{y}"]}
for x in data for y in locales] for x in data for y in locales]
db.session.execute(sa.insert(BaseAccount), account_data) db.session.bulk_insert_mappings(BaseAccount, account_data)
db.session.execute(sa.insert(BaseAccountL10n), l10n_data) db.session.bulk_insert_mappings(BaseAccountL10n, l10n_data)
db.session.commit()
click.echo("Base accounts initialized.")

View File

@ -1,62 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# 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 console commands.
"""
import os
import click
from flask.cli import with_appcontext
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
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-db")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_db_command(username: str) -> None:
"""Initializes the accounting database."""
db.create_all()
init_base_accounts_command()
init_accounts_command(username)
init_currencies_command(username)
db.session.commit()
click.echo("Accounting database initialized.")

View File

@ -19,8 +19,6 @@
""" """
from flask import Flask, Blueprint from flask import Flask, Blueprint
from .commands import init_currencies_command
def init_app(app: Flask, bp: Blueprint) -> None: def init_app(app: Flask, bp: Blueprint) -> None:
"""Initialize the application. """Initialize the application.
@ -35,3 +33,6 @@ def init_app(app: Flask, bp: Blueprint) -> None:
from .views import bp as currency_bp, api_bp as currency_api_bp from .views import bp as currency_bp, api_bp as currency_api_bp
bp.register_blueprint(currency_bp, url_prefix="/currencies") bp.register_blueprint(currency_bp, url_prefix="/currencies")
bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies") bp.register_blueprint(currency_api_bp, url_prefix="/api/currencies")
from .commands import init_currencies_command
app.cli.add_command(init_currencies_command)

View File

@ -18,15 +18,42 @@
""" """
import csv import csv
import os
import typing as t import typing as t
import sqlalchemy as sa import click
from flask.cli import with_appcontext
from accounting import db, data_dir from accounting import db, data_dir
from accounting.models import Currency, CurrencyL10n from accounting.models import Currency, CurrencyL10n
from accounting.utils.user import get_user_pk from accounting.utils.user import has_user, get_user_pk
CurrencyData = tuple[str, str, str, str]
def __validate_username(ctx: click.core.Context, param: click.core.Option,
value: str) -> str:
"""Validates the username for the click console command.
:param ctx: The console command context.
:param param: The console command option.
:param value: The username.
:raise click.BadParameter: When validation fails.
:return: The username.
"""
value = value.strip()
if value == "":
raise click.BadParameter("Username empty.")
if not has_user(value):
raise click.BadParameter(f"User {value} does not exist.")
return value
@click.command("accounting-init-currencies")
@click.option("-u", "--username", metavar="USERNAME", prompt=True,
help="The username.", callback=__validate_username,
default=lambda: os.getlogin())
@with_appcontext
def init_currencies_command(username: str) -> None: def init_currencies_command(username: str) -> None:
"""Initializes the currencies.""" """Initializes the currencies."""
existing_codes: set[str] = {x.code for x in Currency.query.all()} existing_codes: set[str] = {x.code for x in Currency.query.all()}
@ -36,6 +63,7 @@ def init_currencies_command(username: str) -> None:
to_add: list[dict[str, str]] = [x for x in data to_add: list[dict[str, str]] = [x for x in data
if x["code"] not in existing_codes] if x["code"] not in existing_codes]
if len(to_add) == 0: if len(to_add) == 0:
click.echo("No more currency to add.")
return return
creator_pk: int = get_user_pk(username) creator_pk: int = get_user_pk(username)
@ -49,5 +77,8 @@ def init_currencies_command(username: str) -> None:
"locale": y, "locale": y,
"name": x[f"l10n-{y}"]} "name": x[f"l10n-{y}"]}
for x in to_add for y in locales] for x in to_add for y in locales]
db.session.execute(sa.insert(Currency), currency_data) db.session.bulk_insert_mappings(Currency, currency_data)
db.session.execute(sa.insert(CurrencyL10n), l10n_data) db.session.bulk_insert_mappings(CurrencyL10n, l10n_data)
db.session.commit()
click.echo(F"{len(to_add)} added. Currencies initialized.")

View File

@ -21,7 +21,10 @@ import unittest
from datetime import timedelta, date from datetime import timedelta, date
import httpx import httpx
import sqlalchemy as sa
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \ from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
@ -62,6 +65,59 @@ PREFIX: str = "/accounting/accounts"
"""The URL prefix for the account management.""" """The URL prefix for the account management."""
class AccountCommandTestCase(unittest.TestCase):
"""The account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount, Account, AccountL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
AccountL10n.query.delete()
Account.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-account" console command.
:return: None.
"""
from accounting.models import BaseAccount, Account, AccountL10n
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
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()
l10n: list[AccountL10n] = AccountL10n.query.all()
self.assertEqual({x.code for x in bases},
{x.base_code for x in accounts})
self.assertEqual(len(accounts), len(bases))
self.assertEqual(len(l10n), len(bases) * 2)
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
for account in accounts:
base: BaseAccount = base_dict[account.base_code]
self.assertEqual(account.no, 1)
self.assertEqual(account.title_l10n, base.title_l10n)
self.assertEqual({x.locale: x.title for x in account.l10n},
{x.locale: x.title for x in base.l10n})
class AccountTestCase(unittest.TestCase): class AccountTestCase(unittest.TestCase):
"""The account test case.""" """The account test case."""
@ -73,8 +129,15 @@ class AccountTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import Account, AccountL10n from accounting.models import BaseAccount, Account, AccountL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
AccountL10n.query.delete() AccountL10n.query.delete()
Account.query.delete() Account.query.delete()
db.session.commit() db.session.commit()
@ -589,6 +652,14 @@ class AccountTestCase(unittest.TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], detail_uri) self.assertEqual(response.headers["Location"], detail_uri)
response = self.client.post("/accounting/currencies/store",
data={"csrf_token": self.csrf_token,
"code": "USD",
"name": "US Dollars"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/currencies/USD")
add_journal_entry(self.client, add_journal_entry(self.client,
form={"csrf_token": self.csrf_token, form={"csrf_token": self.csrf_token,
"next": NEXT_URI, "next": NEXT_URI,

View File

@ -17,10 +17,14 @@
"""The test for the base account management. """The test for the base account management.
""" """
import csv
import typing as t
import unittest import unittest
import httpx import httpx
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import create_test_app, get_client from testlib import create_test_app, get_client
@ -30,6 +34,59 @@ DETAIL_URI: str = "/accounting/base-accounts/1111"
"""The detail URI.""" """The detail URI."""
class BaseAccountCommandTestCase(unittest.TestCase):
"""The base account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
from accounting.models import BaseAccount, BaseAccountL10n
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
BaseAccountL10n.query.delete()
BaseAccount.query.delete()
def test_init(self) -> None:
"""Tests the "accounting-init-base" console command.
:return: None.
"""
from accounting import data_dir
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)}
runner: FlaskCliRunner = self.app.test_cli_runner()
result: Result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
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"])
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:
self.assertIn(locale, data[account.code]["l10n"])
self.assertEqual(l10n[locale],
data[account.code]["l10n"][locale])
class BaseAccountTestCase(unittest.TestCase): class BaseAccountTestCase(unittest.TestCase):
"""The base account test case.""" """The base account test case."""
@ -39,8 +96,17 @@ class BaseAccountTestCase(unittest.TestCase):
:return: None. :return: None.
""" """
from accounting.models import BaseAccount
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
def test_nobody(self) -> None: def test_nobody(self) -> None:
"""Test the permission as nobody. """Test the permission as nobody.

View File

@ -1,157 +0,0 @@
# The Mia! Accounting Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2023/4/10
# 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 test for the console commands.
"""
import csv
import typing as t
import unittest
import sqlalchemy as sa
from click.testing import Result
from flask import Flask
from flask.testing import FlaskCliRunner
from sqlalchemy.sql.ddl import DropTable
from test_site import db
from testlib import create_test_app
class ConsoleCommandTestCase(unittest.TestCase):
"""The console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
with self.app.app_context():
# Drop every accounting table, to see if accounting-init recreates
# them correctly.
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)
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():
result: Result = runner.invoke(
args=["accounting-init-db", "-u", "editor"])
self.assertEqual(result.exit_code, 0,
result.output + str(result.exception))
self.__test_base_account_data()
self.__test_account_data()
self.__test_currency_data()
def __test_base_account_data(self) -> None:
"""Tests the base account data.
:return: None.
"""
from accounting import data_dir
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)}
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"])
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:
self.assertIn(locale, data[account.code]["l10n"])
self.assertEqual(l10n[locale],
data[account.code]["l10n"][locale])
def __test_account_data(self) -> None:
"""Tests the account data.
:return: None.
"""
from accounting.models import BaseAccount, Account, AccountL10n
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()
l10n: list[AccountL10n] = AccountL10n.query.all()
self.assertEqual({x.code for x in bases},
{x.base_code for x in accounts})
self.assertEqual(len(accounts), len(bases))
self.assertEqual(len(l10n), len(bases) * 2)
base_dict: dict[str, BaseAccount] = {x.code: x for x in bases}
for account in accounts:
base: BaseAccount = base_dict[account.base_code]
self.assertEqual(account.no, 1)
self.assertEqual(account.title_l10n, base.title_l10n)
self.assertEqual({x.locale: x.title for x in account.l10n},
{x.locale: x.title for x in base.l10n})
def __test_currency_data(self) -> None:
"""Tests the currency data.
:return: None.
"""
from accounting import data_dir
from accounting.models import Currency
with open(data_dir / "currencies.csv") as fp:
data: dict[dict[str, t.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():
currencies: list[Currency] = Currency.query.all()
self.assertEqual(len(currencies), len(data))
for currency in currencies:
self.assertIn(currency.code, data)
self.assertEqual(currency.name_l10n, data[currency.code]["name"])
l10n: dict[str, str] = {x.locale: x.name for x in currency.l10n}
self.assertEqual(len(l10n), len(data[currency.code]["l10n"]))
for locale in l10n:
self.assertIn(locale, data[currency.code]["l10n"])
self.assertEqual(l10n[locale],
data[currency.code]["l10n"][locale])

View File

@ -17,11 +17,15 @@
"""The test for the currency management. """The test for the currency management.
""" """
import csv
import typing as t
import unittest import unittest
from datetime import timedelta, date from datetime import timedelta, date
import httpx import httpx
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import NEXT_URI, create_test_app, get_client, set_locale, \ from testlib import NEXT_URI, create_test_app, get_client, set_locale, \
@ -55,6 +59,62 @@ PREFIX: str = "/accounting/currencies"
"""The URL prefix for the currency management.""" """The URL prefix for the currency management."""
class CurrencyCommandTestCase(unittest.TestCase):
"""The account console command test case."""
def setUp(self) -> None:
"""Sets up the test.
This is run once per test.
:return: None.
"""
self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete()
Currency.query.delete()
db.session.commit()
def test_init(self) -> None:
"""Tests the "accounting-init-currencies" console command.
:return: None.
"""
from accounting import data_dir
from accounting.models import Currency
with open(data_dir / "currencies.csv") as fp:
data: dict[dict[str, t.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)}
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
result: Result = runner.invoke(
args=["accounting-init-currencies", "-u", "editor"])
self.assertEqual(result.exit_code, 0)
currencies: list[Currency] = Currency.query.all()
self.assertEqual(len(currencies), len(data))
for currency in currencies:
self.assertIn(currency.code, data)
self.assertEqual(currency.name_l10n, data[currency.code]["name"])
l10n: dict[str, str] = {x.locale: x.name for x in currency.l10n}
self.assertEqual(len(l10n), len(data[currency.code]["l10n"]))
for locale in l10n:
self.assertIn(locale, data[currency.code]["l10n"])
self.assertEqual(l10n[locale],
data[currency.code]["l10n"][locale])
class CurrencyTestCase(unittest.TestCase): class CurrencyTestCase(unittest.TestCase):
"""The currency test case.""" """The currency test case."""
@ -66,8 +126,12 @@ class CurrencyTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import Currency, CurrencyL10n from accounting.models import Currency, CurrencyL10n
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
CurrencyL10n.query.delete() CurrencyL10n.query.delete()
Currency.query.delete() Currency.query.delete()
db.session.commit() db.session.commit()
@ -524,6 +588,21 @@ class CurrencyTestCase(unittest.TestCase):
list_uri: str = PREFIX list_uri: str = PREFIX
response: httpx.Response response: httpx.Response
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context():
from accounting.models import BaseAccount
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
response = self.client.post("/accounting/accounts/store",
data={"csrf_token": self.csrf_token,
"base_code": "1111",
"title": "Cash"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"],
"/accounting/accounts/1111-001")
response = self.client.post(f"{PREFIX}/store", response = self.client.post(f"{PREFIX}/store",
data={"csrf_token": self.csrf_token, data={"csrf_token": self.csrf_token,
"code": JPY.code, "code": JPY.code,

View File

@ -20,7 +20,9 @@
import unittest import unittest
from datetime import date from datetime import date
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \ from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
add_journal_entry add_journal_entry
@ -37,8 +39,22 @@ class DescriptionEditorTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()

View File

@ -22,7 +22,9 @@ from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
import httpx import httpx
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client, \ from testlib import NEXT_URI, Accounts, create_test_app, get_client, \
@ -49,8 +51,22 @@ class CashReceiptJournalEntryTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
@ -654,8 +670,22 @@ class CashDisbursementJournalEntryTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
@ -1235,9 +1265,22 @@ class TransferJournalEntryTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, \ from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()
@ -2096,8 +2139,22 @@ class JournalEntryReorderTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()

View File

@ -23,7 +23,9 @@ import unittest
from decimal import Decimal from decimal import Decimal
import httpx import httpx
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import Accounts, create_test_app, get_client, \ from testlib import Accounts, create_test_app, get_client, \
@ -45,8 +47,22 @@ class OffsetTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()

View File

@ -21,7 +21,9 @@ import unittest
from datetime import datetime, timedelta from datetime import datetime, timedelta
import httpx import httpx
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import NEXT_URI, Accounts, create_test_app, get_client from testlib import NEXT_URI, Accounts, create_test_app, get_client
@ -47,8 +49,21 @@ class OptionTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import Option from accounting.models import BaseAccount, Option
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
Option.query.delete() Option.query.delete()
self.client, self.csrf_token = get_client(self.app, "admin") self.client, self.csrf_token = get_client(self.app, "admin")

View File

@ -21,7 +21,9 @@ import unittest
from datetime import date from datetime import date
import httpx import httpx
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from testlib import create_test_app, get_client, Accounts, BaseTestData from testlib import create_test_app, get_client, Accounts, BaseTestData
@ -42,8 +44,22 @@ class ReportTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()

View File

@ -21,9 +21,9 @@ import os
import typing as t import typing as t
from secrets import token_urlsafe from secrets import token_urlsafe
from click.testing import Result import click
from flask import Flask, Blueprint, render_template, redirect, Response from flask import Flask, Blueprint, render_template, redirect, Response
from flask.testing import FlaskCliRunner from flask.cli import with_appcontext
from flask_babel_js import BabelJS from flask_babel_js import BabelJS
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from flask_wtf import CSRFProtect from flask_wtf import CSRFProtect
@ -63,6 +63,7 @@ def create_app(is_testing: bool = False) -> Flask:
db.init_app(app) db.init_app(app)
app.register_blueprint(bp, url_prefix="/") app.register_blueprint(bp, url_prefix="/")
app.cli.add_command(init_db_command)
from . import locale from . import locale
locale.init_app(app) locale.init_app(app)
@ -109,27 +110,20 @@ def create_app(is_testing: bool = False) -> Flask:
accounting.init_app(app, user_utils=UserUtilities()) accounting.init_app(app, user_utils=UserUtilities())
with app.app_context():
init_db(app)
return app return app
def init_db(app: Flask) -> None: @click.command("init-db")
"""Initializes the database. @with_appcontext
def init_db_command() -> None:
:param app: The Flask application. """Initializes the database."""
:return: None.
"""
db.create_all() db.create_all()
from .auth import User from .auth import User
for username in ["viewer", "editor", "admin", "nobody"]: for username in ["viewer", "editor", "admin", "nobody"]:
if User.query.filter(User.username == username).first() is None: if User.query.filter(User.username == username).first() is None:
db.session.add(User(username=username)) db.session.add(User(username=username))
db.session.commit() db.session.commit()
runner: FlaskCliRunner = app.test_cli_runner() click.echo("Database initialized successfully.")
result: Result = runner.invoke(args=["accounting-init-db", "-u", "editor"])
assert result.exit_code == 0, result.output + str(result.exception)
@bp.get("/", endpoint="home") @bp.get("/", endpoint="home")

View File

@ -20,7 +20,9 @@
import unittest import unittest
import httpx import httpx
from click.testing import Result
from flask import Flask from flask import Flask
from flask.testing import FlaskCliRunner
from test_site import db from test_site import db
from testlib import create_test_app, get_client, Accounts, \ from testlib import create_test_app, get_client, Accounts, \
@ -41,8 +43,22 @@ class UnmatchedOffsetTestCase(unittest.TestCase):
""" """
self.app: Flask = create_test_app() self.app: Flask = create_test_app()
runner: FlaskCliRunner = self.app.test_cli_runner()
with self.app.app_context(): with self.app.app_context():
from accounting.models import JournalEntry, JournalEntryLineItem from accounting.models import BaseAccount, JournalEntry, \
JournalEntryLineItem
result: Result
result = runner.invoke(args="init-db")
self.assertEqual(result.exit_code, 0)
if BaseAccount.query.first() is None:
result = runner.invoke(args="accounting-init-base")
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-currencies",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
result = runner.invoke(args=["accounting-init-accounts",
"-u", "editor"])
self.assertEqual(result.exit_code, 0)
JournalEntry.query.delete() JournalEntry.query.delete()
JournalEntryLineItem.query.delete() JournalEntryLineItem.query.delete()