44 Commits

Author SHA1 Message Date
999b593c64 Advanced to version 0.1.0. 2022-12-05 19:41:52 +08:00
f6f83fe323 Replaced "random.randint" with "secrets.randbelow" in the "new_pk" function. 2022-12-05 19:40:12 +08:00
607b5be9c0 Removed an unused variable in mia_core.models. 2022-12-05 19:36:06 +08:00
3792524022 Replaced the deprecated JavaScript String.substr with String.substring. 2022-12-05 19:32:43 +08:00
1c44d51e92 Renamed the "order" property to "ord" in the TransactionSortForm.Order class. 2022-12-05 19:27:53 +08:00
3d4d20614c Removed a redundant return in the _validate_code_parent_exists method of the AccountForm form. 2022-12-05 19:23:48 +08:00
4784100084 Removed an empty CSS rule in report.css. 2022-12-05 19:22:19 +08:00
56a9d565c1 Replaced "[0-9]" with \d in the regular expressions. 2022-12-05 19:21:37 +08:00
1967359142 Revised the variable in the "accounting_sample" management command. 2022-12-05 18:58:28 +08:00
e80aceb8ff Revised the regular expression in the JavaScript summary helper. 2022-12-05 18:57:34 +08:00
649f76d9db Replaced "random.randint" with "secrets.randbelow" in the "accounting_sample" management command. 2022-12-05 18:49:58 +08:00
30b00b1a67 Added the SonarQube files to .gitignore. 2022-12-05 18:45:15 +08:00
b3e06e3e5b Removed a redundant test in the RunTestCase test case. 2022-12-05 17:43:33 +08:00
8c3d6fd962 Added the simple RunTestCase test case. 2022-12-05 17:32:41 +08:00
d83a72bb8c Fixed the header of pyproject.toml. 2022-12-05 14:29:43 +08:00
bcb27594ad Added MANIFEST.in and pyproject.toml, and replaced setup.py with setup.cfg. 2022-12-05 08:52:13 +08:00
3cef0d7009 Moved the source files to the "src" subdirectory. 2022-12-05 08:46:20 +08:00
24c3b868e0 Fixed the README filename from README.md to README.rst in setup.py. 2022-12-05 08:33:59 +08:00
f57162a93c Fixed the save method of the LocalizedModel data model to find the l10n records only for existing models, to work with Django 4.1. 2022-12-04 23:01:02 +08:00
4afd072cc5 Fixed the records pseudo property of the Transaction data model to find the records only for existing transactions, to work with Django 4.1. 2022-08-13 20:48:26 +08:00
86b84bef7a Fixed the save() method of the Transaction data model to find the records to delete only for existing transactions, to work with Django 4.1. 2022-08-11 11:48:01 +08:00
a0382ad179 Fixed the JavaScript form validation logic from isValid && validateXxx() to validateXxx() && isValid, in order to correctly validate multiple fields at once. 2021-08-27 07:37:34 +08:00
62b59e7380 Renamed JavaScript variables isValidated to isValid in the forms. 2021-08-27 07:35:03 +08:00
dec53c09f3 Replaced JavaScript XMLHttpRequest.onreadystatechange with XMLHttpRequest.onload. 2021-08-27 07:27:42 +08:00
91d22d72cb Moved the cancel button to the start and the confirm button to the end in the bootstrap modals. 2021-08-27 07:18:35 +08:00
bbcedfd366 Added fade effect to the bootstrap modals. 2021-08-27 07:07:14 +08:00
bc863869c0 Merge branch 'main' of github.com:imacat/mia-accounting into main 2021-08-24 07:37:19 +08:00
874cbca320 Added the output_field when calculating the sum and coalesce in the views. 2021-08-24 07:34:56 +08:00
df6c961dc3 Added the output_field when calculating the balance in the cash report. 2021-08-24 07:10:47 +08:00
fc6f1fd18a Revised README to be more readable. 2021-02-03 14:21:48 +08:00
9af204322a Added the management commands to README. 2021-02-03 11:20:33 +08:00
112201ee8b Revised README. 2021-02-03 11:20:13 +08:00
1cc9b1732b Replaced the Markdown README with its reStructuredText version. 2021-02-03 10:57:24 +08:00
ea8af57fcb Removed the mirror script from .gitignore. It is handled with the deployment function of the IDE now. 2021-02-03 10:56:25 +08:00
b4d9a250db Revised the documentation of the make_trans management command. 2021-02-03 10:05:07 +08:00
500467432a Fixed the cash report to display income first then expense in a transaction, to prevent negative cash balance. 2021-01-31 00:51:09 +08:00
fd47f7bd94 Revised the smart_date filter in the Mia core application. 2021-01-17 00:37:34 +08:00
bb93f601ad Replaced urllib.parse.unquote() with urllib.parse.unquote_plus() in UrlBuilder. 2021-01-16 20:24:10 +08:00
21281da3ed Fixed the line length in the data models for PEP8. 2020-11-05 22:49:54 +08:00
a554974ea0 Simplified the stored post forms in a way that only store one form for use at once, like PHP Laravel, and merge it to the Mia core utilities in the Mia core application. 2020-10-20 22:51:00 +08:00
54d18ca8b3 Added the short_value template filter that strips the excess trailing decimal zeros in the accounting form in the accounting application. 2020-10-20 22:01:56 +08:00
2db018f18b Revised documentation and type hints of the template filters to format the numbers. 2020-10-20 21:54:10 +08:00
eb162c95df Added the record order to the returning records of the _get_records() method in SearchListView in the accounting application. 2020-09-10 21:06:12 +08:00
7358b3ed9d Added the database initialization to README. 2020-09-09 09:17:11 +08:00
80 changed files with 1048 additions and 575 deletions

4
.gitignore vendored
View File

@ -6,6 +6,8 @@ migrations
*.mo
.idea
venv
mirror
excludes
zh_Hans
.scannerwork
sonar-project.properties

20
MANIFEST.in Normal file
View File

@ -0,0 +1,20 @@
# The Flask HTTP Digest Authentication Project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/23
# Copyright (c) 2022 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.
include tests/*
include tests/test_site/*
include tests/test_site/*/*

211
README.md
View File

@ -1,211 +0,0 @@
# mia-accounting
The Django Accounting application.
## Description
`mia-accounting` is a Django accounting application. It was a re-write of my
own private accounting application written in Perl for `mod_perl` in 2007. The
revision aims to be mobile-friendly with Bootstrap, with a modern back-end
framework and front-end technology like jQuery. The first revision was in
Perl / Mojolicious in 2019. This is the second revision in Python / Django
in 2020.
`mia-accounting` comes with two parts:
* The `accounting` application contains the main accounting application.
* The `mia_core` application contains core shared libraries that are used by the
accounting application and my other applications.
You may try it in live demonstration at
https://accounting.imacat.idv.tw/accounting .
* Username: `admin`
* Password: `12345`
## Installation
### Install
`mia-accounting` requires Python 3.6 or above to work.
Install `mia-accounting` with `pip`.
```
pip install mia-accounting
```
### `settings.py`
Add these two applications in the `INSTALL_APPS` section of your `settings.py`.
```
INSTALLED_APPS = [
'mia_core.apps.MiaCoreConfig',
'accounting.apps.AccountingConfig',
...
]
```
Make sure the locale middleware is in the `MIDDLEWARE` section of your
`settings.py`, and add it if it is not added yet.
```
MIDDLEWARE = [
...
'django.middleware.locale.LocaleMiddleware',
...
]
```
### `urls.py`
Add the `accounting` application in the `urlpatterns` of your `urls.py`.
```
urlpatterns = [
...
path('accounting/', decorator_include(login_required, 'accounting.urls')),
...
]
```
Make sure `i18n` and `jsi18n` are also in the `urlpatterns` of your `urls.py`,
and add them if they are not added yet.
```
urlpatterns = [
...
path('i18n/', include("django.conf.urls.i18n")),
path('jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
...
]
```
### `base.html`
Add the following to the very beginning of your base template
`base.html`, before your first real HTML tag.
```
{% load mia_core %}
{% init_libs %}
{% block settings %}{% endblock %}
```
Add the CSS and JavaScripts in the `<head>...</head>` section of your
base template `base.html`.
```
{% for css in libs.css %}
<link rel="stylesheet" type="text/css" href="{% if css|is_static_url %}{% static css %}{% else %}{{ css }}{% endif %}" />
{% endfor %}
{% for js in libs.js %}
<script src="{% if js|is_static_url %}{% static js %}{% else %}{{ js }}{% endif %}"></script>
{% endfor %}
```
### Restart Your Web Project
## Advanced Settings
The following advanced settings are available in `settings.py`.
```
# Settings for the accounting application
ACCOUNTING = {
# The default cash acount, for ex., "0" (current assets and liabilities),
# "1111" (cash on hand), "1113" (cash in banks) or any
"DEFAULT_CASH_ACCOUNT": "1111",
# The shortcut cash accounts
"CASH_SHORTCUT_ACCOUNTS": ["0", "1111"],
# The default ledger account
"DEFAULT_LEDGER_ACCOUNT": "1111",
# The payable accounts to track
"PAYABLE_ACCOUNTS": ["2141"],
# The asset accounts to track
"EQUIPMENT_ACCOUNTS": ["1441"],
}
# The local static CSS and JavaScript libraries
# The default is to use the libraries from CDN. You may set them to use the
# local static copies of these libraries
STATIC_LIBS = {
"jquery": {"css": [], "js": ["jquery/jquery-3.5.1.min.js"]},
"bootstrap4": {"css": ["bootstrap4/css/bootstrap.min.css"],
"js": ["bootstrap4/js/bootstrap.bundle.min.js"]},
"font-awesome-5": {"css": ["font-awesome-5/css/all.min.css"],
"js": []},
"bootstrap4-datatables": {
"css": ["datatables/css/jquery.dataTables.min.css",
"edatatables/css/dataTables.bootstrap4.min.css"],
"js": ["datatables/js/jquery.dataTables.min.js",
"datatables/js/dataTables.bootstrap4.min.js"]},
"jquery-ui": {"css": ["jquery-ui/jquery-ui.min.css"],
"js": ["jquery-ui/jquery-ui.min.js"]},
"bootstrap4-tempusdominus": {
"css": [("tempusdominus-bootstrap-4/css/"
"tempusdominus-bootstrap-4.min.css")],
"js": ["moment/moment-with-locales.min.js",
("tempusdominus-bootstrap-4/js/"
"tempusdominus-bootstrap-4.min.js")]},
"decimal.js": {"css": [], "js": ["decimal/decimal.min.js"]},
}
# The default static stylesheets to include. Default is none.
DEFAULT_CSS = ["css/app.css"]
# The default static JavaScript to include. Default is none.
DEFAULT_JS = ["js/app.js"]
# The regular accounts in the summary helper. They should be lists of tuples
# of (generic title, title format, account code).
#
# The following variables are available. Variables are surrounded in brackets.
#
# month_no: The numeric month of the current date
# month_name: The month name of the current date
# last_month_no: The numeric previous month of the current date
# last_month_name: The previous month name of the current date
# last_bimonthly_from_no: The first month number of the last bimonthly period
# last_bimonthly_from_name: The first month name of the last bimonthly period
# last_bimonthly_to_no: The second month number of the last bimonthly period
# last_bimonthly_to_name: The second month name of the last bimonthly period
#
REGULAR_ACCOUNTS = {
"debit": [
("Rent", "Rent for (month_name)", "6252"),
("Gas bill",
"Gas bill for (last_bimonthly_from_name)-(last_bimonthly_to_name)",
"6261"),
],
"credit": [
("Payroll", "Payroll for (last_month_name)", "46116"),
],
}
```
## Bugs and Supports
The `mia-accounting` project is hosted on GitHub.
https://github.com/imacat/mia-accounting
Address all bugs and support requests to imacat@mail.imacat.idv.tw.
## Copyright
```
Copyright (c) 2020 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.
```

308
README.rst Normal file
View File

@ -0,0 +1,308 @@
======================================
The Mia! Django Accounting Application
======================================
Description
===========
*mia-accounting* is a Django_ accounting application. It was
rewritten from my own private accounting application in Perl/mod_perl_
in 2007. The revision aims to be mobile-friendly with Bootstrap, with
a modern back-end framework and front-end technology like jQuery. The
first revision was in Perl/Mojolicious_ in 2019. This is the second
revision in Python/Django in 2020.
The Mia! Django accounting application comes with two parts:
- The ``accounting`` application contains the main accounting
application.
- The ``mia_core`` application contains core shared libraries that are
used by the ``accounting`` application and my other applications.
You may try it in live demonstration at:
- URL: https://accounting.imacat.idv.tw/accounting
- Username: ``admin``
- Password: ``12345``
.. _Django: https://www.djangoproject.com
.. _mod_perl: https://perl.apache.org
.. _Mojolicious: https://mojolicious.org
Installation
============
Install
-------
The Mia! Django accounting application requires Python 3.7 and Django
3.1.
Install ``mia-accounting`` with ``pip``.
.. code::
pip install mia-accounting
``settings.py``
---------------
Add these two applications in the ``INSTALL_APPS`` section of your
``settings.py``.
.. code::
INSTALLED_APPS = [
'mia_core.apps.MiaCoreConfig',
'accounting.apps.AccountingConfig',
...
]
Make sure the locale middleware is in the ``MIDDLEWARE`` section of
your ``settings.py``, and add it if it is not added yet.
.. code::
MIDDLEWARE = [
...
'django.middleware.locale.LocaleMiddleware',
...
]
``urls.py``
-----------
Add the ``accounting`` application in the ``urlpatterns`` in your
``urls.py``.
.. code::
urlpatterns = [
...
path('accounting/', decorator_include(login_required, 'accounting.urls')),
...
]
Make sure ``i18n`` and ``jsi18n`` are also in the ``urlpatterns`` of
your ``urls.py``, and add them if they are not added yet.
.. code::
urlpatterns = [
...
path('i18n/', include("django.conf.urls.i18n")),
path('jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
...
]
``base.html``
-------------
Add the following to the very beginning of your base template
``base.html``, before your first real HTML tag.
.. code::
{% load mia_core %}
{% init_libs %}
{% block settings %}{% endblock %}
Add the CSS and JavaScripts in the ``<head>...</head>`` section of your
base template ``base.html``.
.. code::
{% for css in libs.css %}
<link rel="stylesheet" type="text/css" href="{% if css|is_static_url %}{% static css %}{% else %}{{ css }}{% endif %}" />
{% endfor %}
{% for js in libs.js %}
<script src="{% if js|is_static_url %}{% static js %}{% else %}{{ js }}{% endif %}"></script>
{% endfor %}
Database Initialization
-----------------------
Run the management commands to initialize the database.
.. code::
./manage.py migrate accounting
./manage.py accounting_accounts
Optionally you can populate the database with some sample data.
.. code::
./manage.py accounting_sample
Restart Your Web Server
-----------------------
And you are done.
Management Commands
===================
The following management commands are added by *the Mia! accounting
application* to ``manage.py``:
``accounting_accounts``
-----------------------
.. code::
% ./manage.py accounting_accounts [--user USER]
Fills the database with the accounting accounts.
- ``--user`` *USER*
An optional user to specify which user these initial accounts
belongs to. When omitted, the first user found in the system will
be used.
``accounting_sample``
---------------------
.. code::
% ./manage.py accounting_sample [--user USER]
Fills the database with sample accounting data.
- ``--user`` *USER*
An optional user to specify which user these initial accounts
belongs to. When omitted, the first user found in the system will
be used.
``make_trans``
--------------
.. code::
% ./manage.py make_trans --domain DOMAIN APP_DIR1 [APP_DIR2 ...]
Updates the revision date, converts the Traditional Chinese
translation into Simplified Chinese, and then calls the
``compilemessages`` command.
- ``--domain`` *DOMAIN*
The message domain, either ``django`` or ``djangojs``.
- *APP_DIR1* [*APP_DIR2* ...]
One or more application directories that contains their ``locale``
subdirectories.
Advanced Settings
=================
The following advanced settings are available in ``settings.py``.
.. code::
# Settings for the accounting application
ACCOUNTING = {
# The default cash account, for ex., "0" (current assets and liabilities),
# "1111" (cash on hand), "1113" (cash in banks) or any
"DEFAULT_CASH_ACCOUNT": "1111",
# The shortcut cash accounts
"CASH_SHORTCUT_ACCOUNTS": ["0", "1111"],
# The default ledger account
"DEFAULT_LEDGER_ACCOUNT": "1111",
# The payable accounts to track
"PAYABLE_ACCOUNTS": ["2141"],
# The asset accounts to track
"EQUIPMENT_ACCOUNTS": ["1441"],
}
# The local static CSS and JavaScript libraries
# The default is to use the libraries from CDN. You may set them to use the
# local static copies of these libraries
STATIC_LIBS = {
"jquery": {"css": [], "js": ["jquery/jquery-3.5.1.min.js"]},
"bootstrap4": {"css": ["bootstrap4/css/bootstrap.min.css"],
"js": ["bootstrap4/js/bootstrap.bundle.min.js"]},
"font-awesome-5": {"css": ["font-awesome-5/css/all.min.css"],
"js": []},
"bootstrap4-datatables": {
"css": ["datatables/css/jquery.dataTables.min.css",
"datatables/css/dataTables.bootstrap4.min.css"],
"js": ["datatables/js/jquery.dataTables.min.js",
"datatables/js/dataTables.bootstrap4.min.js"]},
"jquery-ui": {"css": ["jquery-ui/jquery-ui.min.css"],
"js": ["jquery-ui/jquery-ui.min.js"]},
"bootstrap4-tempusdominus": {
"css": [("tempusdominus-bootstrap-4/css/"
"tempusdominus-bootstrap-4.min.css")],
"js": ["moment/moment-with-locales.min.js",
("tempusdominus-bootstrap-4/js/"
"tempusdominus-bootstrap-4.min.js")]},
"decimal.js": {"css": [], "js": ["decimal/decimal.min.js"]},
}
# The default static stylesheets to include. Default is none.
DEFAULT_CSS = ["css/app.css"]
# The default static JavaScript to include. Default is none.
DEFAULT_JS = ["js/app.js"]
# The regular accounts in the summary helper. They should be lists of tuples
# of (generic title, summary format, account code).
#
# The following variables are available. Variables are surrounded in brackets.
#
# month_no: The numeric month of the current date
# month_name: The month name of the current date
# last_month_no: The numeric previous month of the current date
# last_month_name: The previous month name of the current date
# last_bimonthly_from_no: The first month number of the last bimonthly period
# last_bimonthly_from_name: The first month name of the last bimonthly period
# last_bimonthly_to_no: The second month number of the last bimonthly period
# last_bimonthly_to_name: The second month name of the last bimonthly period
#
REGULAR_ACCOUNTS = {
"debit": [
("Rent", "Rent for (month_name)", "6252"),
("Gas bill",
"Gas bill for (last_bimonthly_from_name)-(last_bimonthly_to_name)",
"6261"),
],
"credit": [
("Payroll", "Payroll for (last_month_name)", "46116"),
],
}
Bugs and Supports
=================
The Mia! Django accounting application is hosted on GitHub.
https://github.com/imacat/mia-accounting
Address all bugs and support requests to imacat@mail.imacat.idv.tw.
Copyright
=========
Copyright (c) 2020-2021 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.

View File

@ -1,121 +0,0 @@
# The core application of the Mia project.
# by imacat <imacat@mail.imacat.idv.tw>, 2020/7/24
# Copyright (c) 2020 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 session-based POST data storage management of the Mia core application.
"""
import random
from typing import Dict, Mapping, Any, Optional
from django.http import HttpResponseRedirect, HttpRequest
from django.shortcuts import redirect
from .utils import UrlBuilder
STORAGE_KEY: str = "stored_post"
def error_redirect(request: HttpRequest, url: str,
post: Dict[str, str]) -> HttpResponseRedirect:
"""Redirects to a specific URL on error, with the POST data ID appended
as the query parameter "s". The POST data can be loaded with the
get_previous_post() utility.
Args:
request (HttpRequest): The request.
url (str): The destination URL.
post (dict[str]): The POST data.
Returns:
HttpResponseRedirect: The redirect response.
"""
post_id = _store(request, post)
return redirect(str(UrlBuilder(url).query(s=post_id)))
def get_previous_post(request: HttpRequest) -> Optional[Dict[str, str]]:
"""Retrieves the previously-stored POST data.
Args:
request (HttpRequest): The request.
Returns:
dict: The previously-stored POST data.
"""
if "s" not in request.GET:
return None
return _retrieve(request, request.GET["s"])
def _store(request: HttpRequest, post: Dict[str, str]) -> str:
"""Stores the POST data into the session, and returns the POST data ID that
can be used to retrieve it later with _retrieve().
Args:
request: The request.
post: The POST data.
Returns:
The POST data ID
"""
if STORAGE_KEY not in request.session:
request.session[STORAGE_KEY] = {}
post_id = _new_post_id(request.session[STORAGE_KEY])
request.session[STORAGE_KEY][post_id] = post
return post_id
def _retrieve(request: HttpRequest, post_id: str) -> Optional[Dict[str, str]]:
"""Retrieves the POST data from the storage.
Args:
request: The request.
post_id: The POST data ID.
Returns:
The POST data, or None if the corresponding data does not exist.
"""
if STORAGE_KEY not in request.session:
return None
if post_id not in request.session[STORAGE_KEY]:
return None
return request.session[STORAGE_KEY][post_id]
def _new_post_id(post_store: Mapping[int, Any]) -> str:
"""Generates and returns a new POST ID that does not exist yet.
Args:
post_store (dict): The POST storage.
Returns:
str: The newly-generated POST ID.
"""
while True:
post_id = ""
while len(post_id) < 16:
n = random.randint(1, 64)
if n < 26:
post_id = post_id + chr(ord("a") + n)
elif n < 52:
post_id = post_id + chr(ord("a") + (n - 26))
elif n < 62:
post_id = post_id + chr(ord("0") + (n - 52))
else:
post_id = post_id + "-_."[n - 62]
if post_id not in post_store:
return post_id

20
pyproject.toml Normal file
View File

@ -0,0 +1,20 @@
# The accounting application of the Mia project.
# Author: imacat@mail.imacat.idv.tw (imacat), 2022/11/23
# Copyright (c) 2022 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.
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"

51
setup.cfg Normal file
View File

@ -0,0 +1,51 @@
# The setup.cfg
# by imacat <imacat@mail.imacat.idv.tw>, 2020/9/7
# Copyright (c) 2020 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.
[metadata]
name = mia-accounting
version = 0.1.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = A Django accounting application.
long_description = file: README.rst
long_description_content_type = text/x-rst
url = https://github.com/imacat/mia-accounting
project_urls =
Bug Tracker = https://github.com/imacat/mia-accounting/issues
classifiers =
Programming Language :: Python :: 3
License :: OSI Approved :: Apache Software License
Operating System :: OS Independent
Framework :: Django
Topic :: Office/Business :: Financial :: Accounting
Intended Audience :: End Users/Desktop
[options]
package_dir =
= src
packages = find:
python_requires = >=3.6
install_requires =
django
django-dirtyfields
titlecase
django-decorator-include
tests_require =
unittest
[options.packages.find]
where = src

View File

@ -1,42 +0,0 @@
# The setup.py
# by imacat <imacat@mail.imacat.idv.tw>, 2020/9/7
# Copyright (c) 2020 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.
import setuptools
with open("README.md", "r") as fh:
long_description = fh.read()
setuptools.setup(
name="mia-accounting",
version="0.0.2",
author="imacat",
author_email="imacat@mail.imacat.idv.tw",
description="A Django accounting application",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/imacat/mia-accounting",
packages=setuptools.find_packages(),
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: Apache Software License",
"Framework :: Django :: 3.0",
"Topic :: Office/Business :: Financial :: Accounting",
"Operating System :: OS Independent",
],
python_requires=">=3.6",
install_requires=["django", "django-dirtyfields", "titlecase",
"django-decorator-include"],
)

View File

@ -40,9 +40,9 @@ class TransactionTypeConverter:
class PeriodConverter:
"""The path converter for the period."""
regex = ("([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)|"
"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?-"
"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?")
regex = (r"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)|"
r"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?-"
r"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?")
def to_python(self, value):
"""Returns the period by the period specification.
@ -90,7 +90,7 @@ class DateConverter:
Returns:
datetime.date: The date.
"""
m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$", value)
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", value)
year = int(m.group(1))
month = int(m.group(2))
day = int(m.group(3))

View File

@ -205,8 +205,8 @@ class TransactionForm(forms.Form):
by_rec_id = {}
for key in args[0].keys():
m = re.match(
("^((debit|credit)-([1-9][0-9]*))-"
"(id|ord|account|summary|amount)$"),
(r"^((debit|credit)-([1-9]\d*))-"
r"(id|ord|account|summary|amount)$"),
key)
if m is None:
continue
@ -271,7 +271,7 @@ class TransactionForm(forms.Form):
}
for key in post.keys():
m = re.match(
"^(debit|credit)-([1-9][0-9]*)-(id|ord|account|summary|amount)",
r"^(debit|credit)-([1-9]\d*)-(id|ord|account|summary|amount)",
key)
if m is None:
continue
@ -303,7 +303,7 @@ class TransactionForm(forms.Form):
= post[F"{record_type}-{old_no}-{attr}"]
# Purges the old form and fills it with the new form
for x in [x for x in post.keys() if re.match(
"^(debit|credit)-([1-9][0-9]*)-(id|ord|account|summary|amount)",
r"^(debit|credit)-([1-9]\d*)-(id|ord|account|summary|amount)",
x)]:
del post[x]
for key in new_post.keys():
@ -475,11 +475,11 @@ class TransactionSortForm(forms.Form):
key = F"transaction-{txn.pk}-ord"
if key not in post:
post_orders.append(form.Order(txn, 9999))
elif not re.match("^[0-9]+$", post[key]):
elif not re.match(r"^\d+$", post[key]):
post_orders.append(form.Order(txn, 9999))
else:
post_orders.append(form.Order(txn, int(post[key])))
post_orders.sort(key=lambda x: (x.order, x.txn.ord))
post_orders.sort(key=lambda x: (x.ord, x.txn.ord))
form.txn_orders = []
for i in range(len(post_orders)):
form.txn_orders.append(form.Order(post_orders[i].txn, i + 1))
@ -488,9 +488,9 @@ class TransactionSortForm(forms.Form):
class Order:
"""A transaction order"""
def __init__(self, txn: Transaction, order: int):
def __init__(self, txn: Transaction, ord: int):
self.txn = txn
self.order = order
self.ord = ord
class AccountForm(forms.Form):
@ -603,7 +603,6 @@ class AccountForm(forms.Form):
code="code_unique")
self.add_error("code", error)
raise error
return
def _validate_code_descendant_code_size(self) -> None:
"""Validates whether the codes of the descendants will be too long.

View File

@ -20,7 +20,7 @@
"""
import datetime
import getpass
import random
from secrets import randbelow
from typing import Optional
from django.contrib.auth import get_user_model
@ -98,20 +98,20 @@ class Command(BaseCommand):
self._filler.add_expense_transaction(
-2,
[(6272, _("Lunch—Spaghetti"), random.randint(40, 200)),
(6272, _("Drink—Tea"), random.randint(40, 200))])
[(6272, _("Lunch—Spaghetti"), 40 + randbelow(160)),
(6272, _("Drink—Tea"), 40 + randbelow(160))])
self._filler.add_expense_transaction(
-1,
([(6272, _("Lunch—Pizza"), random.randint(40, 200)),
(6272, _("Drink—Tea"), random.randint(40, 200))]))
([(6272, _("Lunch—Pizza"), 40 + randbelow(160)),
(6272, _("Drink—Tea"), 40 + randbelow(160))]))
self._filler.add_expense_transaction(
-1,
[(6272, _("Lunch—Spaghetti"), random.randint(40, 200)),
(6272, _("Drink—Soda"), random.randint(40, 200))])
[(6272, _("Lunch—Spaghetti"), 40 + randbelow(160)),
(6272, _("Drink—Soda"), 40 + randbelow(160))])
self._filler.add_expense_transaction(
0,
[(6272, _("Lunch—Salad"), random.randint(40, 200)),
(6272, _("Drink—Coffee"), random.randint(40, 200))])
[(6272, _("Lunch—Salad"), 40 + randbelow(160)),
(6272, _("Drink—Coffee"), 40 + randbelow(160))])
@staticmethod
def get_user(username_option):
@ -155,7 +155,7 @@ class Command(BaseCommand):
payday = today.replace(day=5)
if payday > today:
payday = self.previous_month(payday)
for i in range(months):
for _ in range(months):
self.add_payroll(payday)
payday = self.previous_month(payday)
@ -181,7 +181,7 @@ class Command(BaseCommand):
Args:
payday: The payday.
"""
income = random.randint(40000, 50000)
income = 40000 + randbelow(10000)
pension = 882 if income <= 40100\
else 924 if income <= 42000\
else 966 if income <= 43900\

View File

@ -173,7 +173,8 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
self.ord = 1 if max_ord is None else max_ord + 1
# Collects the records to be deleted
to_keep = [x.pk for x in self.records if x.pk is not None]
to_delete = [x for x in self.record_set.all() if x.pk not in to_keep]
to_delete = [] if self.pk is None \
else [x for x in self.record_set.all() if x.pk not in to_keep]
to_save = [x for x in self.records
if x.is_dirty(check_relationship=True)]
for record in to_save:
@ -215,7 +216,7 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
txn_type: The transaction type.
"""
self.old_date = self.date
m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$", post["date"])
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", post["date"])
self.date = datetime.date(
int(m.group(1)),
int(m.group(2)),
@ -228,7 +229,8 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
for i in range(max_no[record_type]):
no = i + 1
if F"{record_type}-{no}-id" in post:
record = Record.objects.get(pk=post[F"{record_type}-{no}-id"])
record = Record.objects.get(
pk=post[F"{record_type}-{no}-id"])
else:
record = Record(
is_credit=(record_type == "credit"),
@ -267,12 +269,11 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
"""Finds the max debit and record numbers from the POSTed form.
Args:
txn_type (str): The transaction type.
post (dict[str,str]): The POSTed data.
txn_type: The transaction type.
post: The POSTed data.
Returns:
dict[str,int]: The max debit and record numbers from the POSTed form.
The max debit and record numbers from the POSTed form.
"""
max_no = {}
if txn_type != "credit":
@ -281,7 +282,8 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
max_no["credit"] = 0
for key in post.keys():
m = re.match(
"^(debit|credit)-([1-9][0-9]*)-(id|ord|account|summary|amount)$",
(r"^(debit|credit)-([1-9]\d*)-"
r"(id|ord|account|summary|amount)$"),
key)
if m is None:
continue
@ -301,6 +303,9 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
List[Record]: The records.
"""
if self._records is None:
if self.pk is None:
self._records = []
else:
self._records = list(self.record_set.all())
self._records.sort(key=lambda x: (x.is_credit, x.ord))
return self._records

View File

@ -161,8 +161,6 @@
font-size: 1.21em;
border-bottom: thick double slategray;
}
.balance-sheet-table tbody {
}
.balance-sheet-table .group-title {
font-size: 1.1em;
font-weight: bolder;

View File

@ -50,8 +50,8 @@ let accounts;
*/
function getAllAccounts() {
const request = new XMLHttpRequest();
request.onreadystatechange = function() {
if (this.readyState === 4 && this.status === 200) {
request.onload = function() {
if (this.status === 200) {
accounts = JSON.parse(this.responseText);
}
};
@ -75,7 +75,7 @@ function updateParent(code) {
parent.text(gettext("Topmost"));
return;
}
const parentCode = code.value.substr(0, code.value.length - 1);
const parentCode = code.value.substring(0, code.value.length - 1);
if (parentCode in accounts) {
parent.text(parentCode + " " + accounts[parentCode]);
return;
@ -96,10 +96,10 @@ function updateParent(code) {
* @private
*/
function validateForm() {
let isValidated = true;
isValidated = isValidated && validateCode();
isValidated = isValidated && validateTitle();
return isValidated;
let isValid = true;
isValid = validateCode() && isValid;
isValid = validateTitle() && isValid;
return isValid;
}
/**
@ -136,7 +136,7 @@ function validateCode() {
return false;
}
}
const parentCode = code.value.substr(0, code.value.length - 1);
const parentCode = code.value.substring(0, code.value.length - 1);
if (!(parentCode in accounts)) {
code.classList.add("is-invalid");
errorMessage.text(gettext("The parent account of this code does not exist."));

View File

@ -298,7 +298,7 @@ function parseSummaryForHelper(summary) {
const pos = summary.lastIndexOf("×");
let count = 1;
if (pos !== -1) {
count = parseInt(summary.substr(pos + 1));
count = parseInt(summary.substring(pos + 1));
}
if (count === 0) {
count = 1;
@ -329,7 +329,7 @@ function parseSummaryForCategoryHelpers(summary) {
});
// A bus route
const matchBus = summary.match(/^(.+)—(.+)—(.+)→(.+?)(?:×[0-9]+)?$/);
const matchBus = summary.match(/^([^—]+)—([^—]+)—([^—]+)→([^—]+?)(?:×\d+)?$/);
if (matchBus !== null) {
$("#summary-bus-category").get(0).value = matchBus[1];
setSummaryBusCategoryButtons(matchBus[1]);
@ -342,7 +342,7 @@ function parseSummaryForCategoryHelpers(summary) {
}
// A general travel route
const matchTravel = summary.match(/^(.+)—(.+)([→|↔])(.+?)(?:×[0-9]+)?$/);
const matchTravel = summary.match(/^([^—]+)—([^—]+)([→|↔])([^—]+?)(?:×\d+)?$/);
if (matchTravel !== null) {
$("#summary-travel-category").get(0).value = matchTravel[1];
setSummaryTravelCategoryButtons(matchTravel[1]);
@ -357,7 +357,7 @@ function parseSummaryForCategoryHelpers(summary) {
// A general category
const generalCategoryTab = $("#summary-tab-category");
const matchCategory = summary.match(/^(.+)—.+(?:×[0-9]+)?$/);
const matchCategory = summary.match(/^([^—]+)—.+(?:×\d+)?$/);
if (matchCategory !== null) {
$("#summary-general-category").get(0).value = matchCategory[1];
setSummaryGeneralCategoryButtons(matchCategory[1]);

View File

@ -91,8 +91,8 @@ let accountOptions;
*/
function getAccountOptions() {
const request = new XMLHttpRequest();
request.onreadystatechange = function() {
if (this.readyState === 4 && this.status === 200) {
request.onload = function() {
if (this.status === 200) {
accountOptions = JSON.parse(this.responseText);
$(".record-account").each(function () {
initializeAccountOptions($(this));
@ -184,8 +184,8 @@ function updateTotalAmount(element) {
}
});
total = String(total);
while (total.match(/^[1-9][0-9]*[0-9]{3}/)) {
total = total.replace(/^([1-9][0-9]*)([0-9]{3})/, "$1,$2");
while (total.match(/^[1-9]\d*\d{3}/)) {
total = total.replace(/^([1-9]\d*)(\d{3})/, "$1,$2");
}
$("#" + type + "-total").text(total);
}
@ -337,28 +337,28 @@ function resetRecordButtons() {
* @private
*/
function validateForm() {
let isValidated = true;
isValidated = isValidated && validateDate();
let isValid = true;
isValid = validateDate() && isValid;
$(".debit-record").each(function () {
isValidated = isValidated && validateRecord(this);
isValid = validateRecord(this) && isValid;
});
$(".credit-account").each(function () {
isValidated = isValidated && validateRecord(this);
isValid = validateRecord(this) && isValid;
});
$(".record-account").each(function () {
isValidated = isValidated && validateAccount(this);
isValid = validateAccount(this) && isValid;
});
$(".record-summary").each(function () {
isValidated = isValidated && validateSummary(this);
isValid = validateSummary(this) && isValid;
});
$(".record-amount").each(function () {
isValidated = isValidated && validateAmount(this);
isValid = validateAmount(this) && isValid;
});
if (isTransfer()) {
isValidated = isValidated && validateBalance();
isValid = validateBalance() && isValid;
}
isValidated = isValidated && validateNote();
return isValidated;
isValid = validateNote() && isValid;
return isValid;
}
/**

View File

@ -42,7 +42,7 @@ First written: 2020/8/8
<form action="{% url "accounting:accounts.delete" account as url %}{% url_keep_return url %}" method="post">
{% csrf_token %}
<!-- The Modal -->
<div class="modal" id="del-modal">
<div class="modal fade" id="del-modal">
<div class="modal-dialog">
<div class="modal-content">
@ -57,8 +57,8 @@ First written: 2020/8/8
<!-- Modal footer -->
<div class="modal-footer">
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Cancel")|force_escape }}</button>
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
</div>
</div>
</div>

View File

@ -19,6 +19,7 @@ form-record-non-transfer.html: The template for a record in the non-transfer tra
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2020/8/5
{% endcomment %}
{% load accounting %}
<li id="{{ record_type }}-{{ no }}" class="list-group-item {% if record.non_field_errors %} list-group-item-danger {% endif %} draggable-record {{ record_type }}-record" data-no="{{ no }}">
<div id="{{ record_type }}-{{ no }}-error">{% if record.non_field_errors %}{{ record.non_field_errors.0 }}{% endif %}</div>
@ -37,7 +38,7 @@ First written: 2020/8/5
</div>
<div class="col-sm-4">
<label for="{{ record_type }}-{{ no }}-amount" class="record-label">{{ _("Amount:")|force_escape }}</label>
<input id="{{ record_type }}-{{ no }}-amount" class="form-control record-amount {{ record_type }}-to-sum {% if record.amount.errors %} is-invalid {% endif %}" type="number" step="0.01" min="0.01" name="{{ record_type }}-{{ no }}-amount" value="{{ record.amount.value|default:"" }}" required="required" data-type="{{ record_type }}" />
<input id="{{ record_type }}-{{ no }}-amount" class="form-control record-amount {{ record_type }}-to-sum {% if record.amount.errors %} is-invalid {% endif %}" type="number" step="0.01" min="0.01" name="{{ record_type }}-{{ no }}-amount" value="{{ record.amount.value|short_value }}" required="required" data-type="{{ record_type }}" />
<div id="{{ record_type }}-{{ no }}-amount-error" class="invalid-feedback">{{ record.amount.errors.0|default:"" }}</div>
</div>
</div>

View File

@ -19,6 +19,7 @@ form-record-transfer.html: The template for a record in the transfer transaction
Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2020/8/5
{% endcomment %}
{% load accounting %}
<li id="{{ record_type }}-{{ no }}" class="list-group-item {% if record.non_field_errors %} list-group-item-danger {% endif %} draggable-record {{ record_type }}-record" data-no="{{ no }}">
<div id="{{ record_type }}-{{ no }}-error">{% if record.non_field_errors %}{{ record.non_field_errors.0 }}{% endif %}</div>
@ -36,7 +37,7 @@ First written: 2020/8/5
</div>
<div class="col-lg-4">
<label for="{{ record_type }}-{{ no }}-amount" class="record-label">{{ _("Amount:")|force_escape }}</label>
<input id="{{ record_type }}-{{ no }}-amount" class="form-control record-amount {{ record_type }}-to-sum {% if record.amount.errors %} is-invalid {% endif %}" type="number" step="0.01" min="0.01" name="{{ record_type }}-{{ no }}-amount" value="{{ record.amount.value|default:"" }}" required="required" data-type="{{ record_type }}" />
<input id="{{ record_type }}-{{ no }}-amount" class="form-control record-amount {{ record_type }}-to-sum {% if record.amount.errors %} is-invalid {% endif %}" type="number" step="0.01" min="0.01" name="{{ record_type }}-{{ no }}-amount" value="{{ record.amount.value|short_value }}" required="required" data-type="{{ record_type }}" />
<div id="{{ record_type }}-{{ no }}-amount-error" class="invalid-feedback">{{ record.amount.errors.0|default:"" }}</div>
</div>
</div>

View File

@ -25,7 +25,7 @@ First written: 2020/7/9
<!-- the accounting record search dialog -->
<form action="{% url "accounting:search" %}" method="GET">
<!-- The Modal -->
<div class="modal" id="accounting-search-modal">
<div class="modal fade" id="accounting-search-modal">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -25,7 +25,7 @@ First written: 2020/4/3
<!-- The Modal -->
<form id="summary-helper-form" action="" method="get">
<input id="summary-record" type="hidden" value="" />
<div class="modal" id="summary-modal">
<div class="modal fade" id="summary-modal">
<div class="modal-dialog">
<div class="modal-content">
@ -155,8 +155,8 @@ First written: 2020/4/3
<!-- Modal footer -->
<div class="modal-footer">
<button id="summary-confirm" class="btn btn-danger" type="submit" data-dismiss="modal">{{ _("Confirm")|force_escape }}</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Cancel")|force_escape }}</button>
<button id="summary-confirm" class="btn btn-danger" type="submit" data-dismiss="modal">{{ _("Confirm")|force_escape }}</button>
</div>
</div>
</div>

View File

@ -44,7 +44,7 @@ First written: 2020/7/23
<form action="{% url "accounting:transactions.delete" txn as url %}{% url_keep_return url %}" method="post">
{% csrf_token %}
<!-- The Modal -->
<div class="modal" id="del-modal">
<div class="modal fade" id="del-modal">
<div class="modal-dialog">
<div class="modal-content">
@ -59,8 +59,8 @@ First written: 2020/7/23
<!-- Modal footer -->
<div class="modal-footer">
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Cancel")|force_escape }}</button>
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
</div>
</div>
</div>

View File

@ -44,7 +44,7 @@ First written: 2020/7/23
<form action="{% url "accounting:transactions.delete" txn as url %}{% url_keep_return url %}" method="post">
{% csrf_token %}
<!-- The Modal -->
<div class="modal" id="del-modal">
<div class="modal fade" id="del-modal">
<div class="modal-dialog">
<div class="modal-content">
@ -59,8 +59,8 @@ First written: 2020/7/23
<!-- Modal footer -->
<div class="modal-footer">
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Cancel")|force_escape }}</button>
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
</div>
</div>
</div>

View File

@ -44,7 +44,7 @@ First written: 2020/7/23
<form action="{% url "accounting:transactions.delete" txn as url %}{% url_keep_return url %}" method="post">
{% csrf_token %}
<!-- The Modal -->
<div class="modal" id="del-modal">
<div class="modal fade" id="del-modal">
<div class="modal-dialog">
<div class="modal-content">
@ -59,8 +59,8 @@ First written: 2020/7/23
<!-- Modal footer -->
<div class="modal-footer">
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
<button type="button" class="btn btn-secondary" data-dismiss="modal">{{ _("Cancel")|force_escape }}</button>
<button class="btn btn-danger" type="submit" name="del-confirm">{{ _("Confirm")|force_escape }}</button>
</div>
</div>
</div>

View File

@ -20,7 +20,7 @@
"""
import re
from decimal import Decimal
from typing import Union, Optional
from typing import Optional
from django import template
from django.template import RequestContext
@ -32,28 +32,41 @@ from mia_core.period import Period
register = template.Library()
def _format_positive_amount(value: Union[str, Decimal]) -> str:
def _strip_decimal_zeros(value: Decimal) -> str:
"""Formats a decimal value, stripping excess decimal zeros.
Args:
value: The value.
Returns:
str: The value with excess decimal zeros stripped.
"""
s = str(value)
s = re.sub(r"^(.*\.\d*?)0+$", r"\1", s)
s = re.sub(r"^(.*)\.$", r"\1", s)
return s
def _format_positive_amount(value: Decimal) -> str:
"""Formats a positive amount, groups every 3 digits by commas.
Args:
value: The amount.
Returns:
ReportUrl: The formatted amount.
str: The amount in the desired format.
"""
s = str(value)
s = _strip_decimal_zeros(value)
while True:
m = re.match("^([1-9][0-9]*)([0-9]{3}.*)", s)
m = re.match(r"^([1-9]\d*)(\d{3}.*)", s)
if m is None:
break
s = m.group(1) + "," + m.group(2)
s = re.sub(r"^(.*\.[0-9]*?)0+$", r"\1", s)
s = re.sub(r"^(.*)\.$", r"\1", s)
return s
@register.filter
def accounting_amount(value: Union[Decimal]) -> str:
def accounting_amount(value: Optional[Decimal]) -> str:
"""Formats an amount with the accounting notation, grouping every 3 digits
by commas, and marking negative numbers with brackets instead of signs.
@ -61,7 +74,7 @@ def accounting_amount(value: Union[Decimal]) -> str:
value: The amount.
Returns:
ReportUrl: The formatted amount.
str: The amount in the desired format.
"""
if value is None:
return ""
@ -74,14 +87,14 @@ def accounting_amount(value: Union[Decimal]) -> str:
@register.filter
def short_amount(value: Union[Decimal]) -> str:
def short_amount(value: Optional[Decimal]) -> str:
"""Formats an amount, groups every 3 digits by commas.
Args:
value: The amount.
Returns:
ReportUrl: The formatted amount.
str: The amount in the desired format.
"""
if value is None:
return ""
@ -93,6 +106,21 @@ def short_amount(value: Union[Decimal]) -> str:
return s
@register.filter
def short_value(value: Optional[Decimal]) -> str:
"""Formats a decimal value, stripping excess decimal zeros.
Args:
value: The value.
Returns:
str: The value with excess decimal zeroes stripped.
"""
if value is None:
return ""
return _strip_decimal_zeros(value)
@register.simple_tag(takes_context=True)
def report_url(context: RequestContext,
cash_account: Optional[Account],

View File

@ -28,7 +28,7 @@ from django.conf import settings
from django.contrib import messages
from django.db import transaction
from django.db.models import Sum, Case, When, F, Q, Count, BooleanField, \
ExpressionWrapper, Exists, OuterRef, Value, CharField
ExpressionWrapper, Exists, OuterRef, Value, CharField, DecimalField
from django.db.models.functions import TruncMonth, Coalesce, Left, StrIndex
from django.http import JsonResponse, HttpResponseRedirect, Http404, \
HttpRequest, HttpResponse
@ -94,7 +94,7 @@ def cash(request: HttpRequest, account: Account,
~Q(account__code__startswith="21"),
~Q(account__code__startswith="22"))
.order_by("transaction__date", "transaction__ord",
"is_credit", "ord"))
"-is_credit", "ord"))
balance_before = Record.objects \
.filter(
Q(transaction__date__lt=period.start),
@ -103,9 +103,10 @@ def cash(request: HttpRequest, account: Account,
Q(account__code__startswith="21") |
Q(account__code__startswith="22"))) \
.aggregate(
balance=Coalesce(Sum(Case(
When(is_credit=True, then=-1),
default=1) * F("amount")), 0))["balance"]
balance=Coalesce(Sum(Case(When(is_credit=True, then=-1),
default=1) * F("amount"),
output_field=DecimalField()),
0, output_field=DecimalField()))["balance"]
else:
records = list(
Record.objects
@ -116,15 +117,16 @@ def cash(request: HttpRequest, account: Account,
Q(record__account__code__startswith=account.code))),
~Q(account__code__startswith=account.code))
.order_by("transaction__date", "transaction__ord",
"is_credit", "ord"))
"-is_credit", "ord"))
balance_before = Record.objects \
.filter(
transaction__date__lt=period.start,
account__code__startswith=account.code) \
.aggregate(
balance=Coalesce(Sum(Case(When(
is_credit=True, then=-1),
default=1) * F("amount")), 0))["balance"]
balance=Coalesce(Sum(Case(When(is_credit=True, then=-1),
default=1) * F("amount"),
output_field=DecimalField()),
0, output_field=DecimalField()))["balance"]
balance = balance_before
for record in records:
sign = 1 if record.is_credit else -1
@ -211,14 +213,13 @@ def cash_summary(request: HttpRequest, account: Account) -> HttpResponse:
.values("month")
.order_by("month")
.annotate(
debit=Coalesce(
Sum(Case(When(is_credit=False, then=F("amount")))),
0),
credit=Coalesce(
Sum(Case(When(is_credit=True, then=F("amount")))),
0),
balance=Sum(Case(
When(is_credit=False, then=-F("amount")),
debit=Coalesce(Sum(Case(When(is_credit=False,
then=F("amount")))),
0, output_field=DecimalField()),
credit=Coalesce(Sum(Case(When(is_credit=True,
then=F("amount")))),
0, output_field=DecimalField()),
balance=Sum(Case(When(is_credit=False, then=-F("amount")),
default=F("amount"))))]
else:
months = [utils.MonthlySummary(**x) for x in Record.objects
@ -230,14 +231,13 @@ def cash_summary(request: HttpRequest, account: Account) -> HttpResponse:
.values("month")
.order_by("month")
.annotate(
debit=Coalesce(
Sum(Case(When(is_credit=False, then=F("amount")))),
0),
credit=Coalesce(
Sum(Case(When(is_credit=True, then=F("amount")))),
0),
balance=Sum(Case(
When(is_credit=False, then=-F("amount")),
debit=Coalesce(Sum(Case(When(is_credit=False,
then=F("amount")))),
0, output_field=DecimalField()),
credit=Coalesce(Sum(Case(When(is_credit=True,
then=F("amount")))),
0, output_field=DecimalField()),
balance=Sum(Case(When(is_credit=False, then=-F("amount")),
default=F("amount"))))]
cumulative_balance = 0
for month in months:
@ -305,9 +305,10 @@ def ledger(request: HttpRequest, account: Account,
transaction__date__lt=period.start,
account__code__startswith=account.code) \
.aggregate(
balance=Coalesce(Sum(Case(When(
is_credit=True, then=-1),
default=1) * F("amount")), 0))["balance"]
balance=Coalesce(Sum(Case(When(is_credit=True, then=-1),
default=1) * F("amount"),
output_field=DecimalField()),
0, output_field=DecimalField()))["balance"]
record_brought_forward = Record(
transaction=Transaction(date=period.start),
account=account,
@ -370,14 +371,13 @@ def ledger_summary(request: HttpRequest, account: Account) -> HttpResponse:
.values("month")
.order_by("month")
.annotate(
debit=Coalesce(
Sum(Case(When(is_credit=False, then=F("amount")))),
0),
credit=Coalesce(
Sum(Case(When(is_credit=True, then=F("amount")))),
0),
balance=Sum(Case(
When(is_credit=False, then=F("amount")),
debit=Coalesce(Sum(Case(When(is_credit=False,
then=F("amount")))),
0, output_field=DecimalField()),
credit=Coalesce(Sum(Case(When(is_credit=True,
then=F("amount")))),
0, output_field=DecimalField()),
balance=Sum(Case(When(is_credit=False, then=F("amount")),
default=-F("amount"))))]
cumulative_balance = 0
for month in months:
@ -436,11 +436,10 @@ def journal(request: HttpRequest, period: Period) -> HttpResponse:
| Q(code__startswith="2")
| Q(code__startswith="3")) \
.annotate(balance=Sum(
Case(
When(record__is_credit=True, then=-1),
default=1
) * F("record__amount"),
filter=Q(record__transaction__date__lt=period.start))) \
Case(When(record__is_credit=True, then=-1),
default=1) * F("record__amount"),
filter=Q(record__transaction__date__lt=period.start),
output_field=DecimalField())) \
.filter(~Q(balance=0))
debit_records = [Record(
transaction=Transaction(date=period.start),
@ -513,9 +512,9 @@ def trial_balance(request: HttpRequest, period: Period) -> HttpResponse:
| Q(code__startswith="2")
| Q(code__startswith="3")))
.annotate(
amount=Sum(Case(
When(record__is_credit=True, then=-1),
default=1) * F("record__amount")))
amount=Sum(Case(When(record__is_credit=True, then=-1),
default=1) * F("record__amount"),
output_field=DecimalField()))
.filter(Q(amount__isnull=False), ~Q(amount=0))
.annotate(
debit_amount=Case(
@ -534,9 +533,9 @@ def trial_balance(request: HttpRequest, period: Period) -> HttpResponse:
| Q(code__startswith="3")),
~Q(code=Account.ACCUMULATED_BALANCE))
.annotate(
amount=Sum(Case(
When(record__is_credit=True, then=-1),
default=1) * F("record__amount")))
amount=Sum(Case(When(record__is_credit=True, then=-1),
default=1) * F("record__amount"),
output_field=DecimalField()))
.filter(Q(amount__isnull=False), ~Q(amount=0))
.annotate(
debit_amount=Case(
@ -555,9 +554,9 @@ def trial_balance(request: HttpRequest, period: Period) -> HttpResponse:
| (Q(transaction__date__lte=period.end)
& Q(account__code=Account.ACCUMULATED_BALANCE))) \
.aggregate(
balance=Sum(Case(
When(is_credit=True, then=-1),
default=1) * F("amount")))["balance"]
balance=Sum(Case(When(is_credit=True, then=-1),
default=1) * F("amount"),
output_field=DecimalField()))["balance"]
if balance is not None and balance != 0:
brought_forward = Account.objects.get(
code=Account.ACCUMULATED_BALANCE)
@ -614,9 +613,9 @@ def income_statement(request: HttpRequest, period: Period) -> HttpResponse:
| Q(code__startswith="2")
| Q(code__startswith="3")))
.annotate(
amount=Sum(Case(
When(record__is_credit=True, then=1),
default=-1) * F("record__amount")))
amount=Sum(Case(When(record__is_credit=True, then=1),
default=-1) * F("record__amount"),
output_field=DecimalField()))
.filter(Q(amount__isnull=False), ~Q(amount=0))
.order_by("code"))
groups = list(Account.objects.filter(
@ -687,9 +686,9 @@ def balance_sheet(request: HttpRequest, period: Period) -> HttpResponse:
| Q(code__startswith="3")),
~Q(code=Account.ACCUMULATED_BALANCE))
.annotate(
amount=Sum(Case(
When(record__is_credit=True, then=-1),
default=1) * F("record__amount")))
amount=Sum(Case(When(record__is_credit=True, then=-1),
default=1) * F("record__amount"),
output_field=DecimalField()))
.filter(Q(amount__isnull=False), ~Q(amount=0))
.order_by("code"))
for account in accounts:
@ -703,9 +702,9 @@ def balance_sheet(request: HttpRequest, period: Period) -> HttpResponse:
| Q(account__code__startswith="3"))
& ~Q(account__code=Account.ACCUMULATED_BALANCE))) \
.aggregate(
balance=Sum(Case(
When(is_credit=True, then=-1),
default=1) * F("amount")))["balance"]
balance=Sum(Case(When(is_credit=True, then=-1),
default=1) * F("amount"),
output_field=DecimalField()))["balance"]
if balance is not None and balance != 0:
brought_forward = Account.objects.get(
code=Account.ACCUMULATED_BALANCE)
@ -723,9 +722,9 @@ def balance_sheet(request: HttpRequest, period: Period) -> HttpResponse:
| Q(account__code__startswith="3"))
& ~Q(account__code=Account.ACCUMULATED_BALANCE))) \
.aggregate(
balance=Sum(Case(
When(is_credit=True, then=-1),
default=1) * F("amount")))["balance"]
balance=Sum(Case(When(is_credit=True, then=-1),
default=1) * F("amount"),
output_field=DecimalField()))["balance"]
if balance is not None and balance != 0:
net_change = Account.objects.get(code=Account.NET_CHANGE)
net_change.amount = balance
@ -781,12 +780,12 @@ class SearchListView(TemplateView):
if len(terms) == 0:
return []
conditions = [self._get_conditions_for_term(x) for x in terms]
if len(conditions) == 1:
return Record.objects.filter(conditions[0])
combined = conditions[0]
for x in conditions[1:]:
combined = combined & x
return Record.objects.filter(combined)
return Record.objects.filter(combined)\
.order_by("transaction__date", "transaction__ord", "is_credit",
"ord")
@staticmethod
def _get_conditions_for_term(term: str) -> Q:
@ -805,9 +804,9 @@ class SearchListView(TemplateView):
| Q(code=term)))\
| Q(summary__icontains=term)\
| Q(transaction__notes__icontains=term)
if re.match("^[0-9]+(?:\\.[0-9]+)?$", term):
if re.match(r"^\d+(?:\.\d+)?$", term):
conditions = conditions | Q(amount=Decimal(term))
if re.match("^[1-9][0-8]{9}$", term):
if re.match(r"^[1-9][0-8]{9}$", term):
conditions = conditions\
| Q(pk=int(term))\
| Q(transaction__pk=int(term))\
@ -1087,13 +1086,13 @@ class TransactionSortFormView(FormView):
def form_valid(self, form: TransactionSortForm) -> HttpResponseRedirect:
"""Handles the action when the POST form is valid."""
modified = [x for x in form.txn_orders if x.txn.ord != x.order]
modified = [x for x in form.txn_orders if x.txn.ord != x.ord]
if len(modified) == 0:
message = self.get_not_modified_message(form.cleaned_data)
else:
with transaction.atomic():
for x in modified:
Transaction.objects.filter(pk=x.txn.pk).update(ord=x.order)
Transaction.objects.filter(pk=x.txn.pk).update(ord=x.ord)
message = self.get_success_message(form.cleaned_data)
messages.success(self.request, message)
return redirect(self.get_success_url())

View File

@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: mia-core 3.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-08-17 23:56+0800\n"
"PO-Revision-Date: 2020-08-18 00:02+0800\n"
"POT-Creation-Date: 2021-01-17 00:24+0800\n"
"PO-Revision-Date: 2021-01-17 00:29+0800\n"
"Last-Translator: imacat <imacat@mail.imacat.idv.tw>\n"
"Language-Team: Traditional Chinese <imacat@mail.imacat.idv.tw>\n"
"Language: Traditional Chinese\n"
@ -38,13 +38,13 @@ msgstr "全部"
#: mia_core/period.py:588 mia_core/period.py:621
#: mia_core/templates/mia_core/include/period-chooser.html:60
#: mia_core/templatetags/mia_core.py:173
#: mia_core/templatetags/mia_core.py:219
msgid "This Month"
msgstr "這個月"
#: mia_core/period.py:629
#: mia_core/templates/mia_core/include/period-chooser.html:63
#: mia_core/templatetags/mia_core.py:180
#: mia_core/templatetags/mia_core.py:226
msgid "Last Month"
msgstr "上個月"
@ -60,13 +60,13 @@ msgstr "去年"
#: mia_core/period.py:661
#: mia_core/templates/mia_core/include/period-chooser.html:95
#: mia_core/templatetags/mia_core.py:153
#: mia_core/templatetags/mia_core.py:187
msgid "Today"
msgstr "今天"
#: mia_core/period.py:663
#: mia_core/templates/mia_core/include/period-chooser.html:98
#: mia_core/templatetags/mia_core.py:155
#: mia_core/templatetags/mia_core.py:190
msgid "Yesterday"
msgstr "昨天"
@ -115,17 +115,21 @@ msgstr "從:"
msgid "To:"
msgstr "到:"
#: mia_core/utils.py:347
#: mia_core/templatetags/mia_core.py:192
msgid "Tomorrow"
msgstr "明天"
#: mia_core/utils.py:342
msgctxt "Pagination|"
msgid "Previous"
msgstr "上一頁"
#: mia_core/utils.py:375 mia_core/utils.py:396
#: mia_core/utils.py:370 mia_core/utils.py:391
msgctxt "Pagination|"
msgid "..."
msgstr "…"
#: mia_core/utils.py:415
#: mia_core/utils.py:410
msgctxt "Pagination|"
msgid "Next"
msgstr "下一頁"

View File

@ -30,8 +30,13 @@ from opencc import OpenCC
class Command(BaseCommand):
"""Populates the database with sample accounting data."""
help = "Fills the database with the accounting accounts."
"""Updates the revision date, converts the Traditional Chinese
translation into Simplified Chinese, and then calls the
compilemessages command.
"""
help = ("Updates the revision date, converts the Traditional Chinese"
" translation into Simplified Chinese, and then calls the"
" compilemessages command.")
def __init__(self):
super().__init__()
@ -44,8 +49,9 @@ class Command(BaseCommand):
Args:
parser (CommandParser): The command line argument parser.
"""
parser.add_argument("proj_dir", nargs="+",
help="The domain, either django or djangojs")
parser.add_argument("app_dir", nargs="+",
help=("One or more application directories that"
" contains their locale subdirectories"))
parser.add_argument("--domain", "-d", action="append",
choices=["django", "djangojs"], required=True,
help="The domain, either django or djangojs")
@ -58,7 +64,7 @@ class Command(BaseCommand):
**options (dict[str,str]): The command line switches.
"""
locale_dirs = [os.path.join(settings.BASE_DIR, x, "locale")
for x in options["proj_dir"]]
for x in options["app_dir"]]
missing = [x for x in locale_dirs if not os.path.isdir(x)]
if len(missing) > 0:
error = "Directories not exist: " + ", ".join(missing)

View File

@ -69,7 +69,7 @@ class StampedModel(models.Model):
F"Missing current_user in {self.__class__.__name__}")
try:
self.created_by
except ObjectDoesNotExist as e:
except ObjectDoesNotExist:
self.created_by = self.current_user
self.updated_by = self.current_user
super().save(force_insert=force_insert, force_update=force_update,
@ -126,6 +126,9 @@ class LocalizedModel(models.Model):
current_value = getattr(self, name + "_l10n")
if current_value is None or current_value == "":
setattr(self, name + "_l10n", new_value)
if self.pk is None:
l10n_rec = None
else:
l10n_rec = self._get_l10n_set()\
.filter(name=name, language=language)\
.first()

View File

@ -195,7 +195,7 @@ class Period:
"""
if self._data_start is None:
return None
m = re.match("^[0-9]{4}-[0-2]{2}", self._period.spec)
m = re.match(r"^\d{4}-\d{2}", self._period.spec)
if m is None:
return None
if self._period.end < self._data_start:
@ -379,9 +379,9 @@ class Period:
if self.start <= self._data_start:
return None
previous_day = self.start - datetime.timedelta(days=1)
if re.match("^[0-9]{4}$", self.spec):
if re.match(r"^\d{4}$", self.spec):
return "-" + str(previous_day.year)
if re.match("^[0-9]{4}-[0-9]{2}$", self.spec):
if re.match(r"^\d{4}-\d{2}$", self.spec):
return dateformat.format(previous_day, "-Y-m")
return dateformat.format(previous_day, "-Y-m-d")
@ -441,7 +441,7 @@ class Period:
return
self.spec = spec
# A specific month
m = re.match("^([0-9]{4})-([0-9]{2})$", spec)
m = re.match(r"^(\d{4})-(\d{2})$", spec)
if m is not None:
year = int(m.group(1))
month = int(m.group(2))
@ -452,7 +452,7 @@ class Period:
self.prep_desc = gettext("In %s") % self.description
return
# From a specific month
m = re.match("^([0-9]{4})-([0-9]{2})-$", spec)
m = re.match(r"^(\d{4})-(\d{2})-$", spec)
if m is not None:
year = int(m.group(1))
month = int(m.group(2))
@ -464,7 +464,7 @@ class Period:
self.prep_desc = self.description
return
# Until a specific month
m = re.match("^-([0-9]{4})-([0-9]{2})$", spec)
m = re.match(r"^-(\d{4})-(\d{2})$", spec)
if m is not None:
year = int(m.group(1))
month = int(m.group(2))
@ -477,7 +477,7 @@ class Period:
self.prep_desc = self.description
return
# A specific year
m = re.match("^([0-9]{4})$", spec)
m = re.match(r"^(\d{4})$", spec)
if m is not None:
year = int(m.group(1))
# Raises ValueError
@ -487,7 +487,7 @@ class Period:
self.prep_desc = gettext("In %s") % self.description
return
# Until a specific year
m = re.match("^-([0-9]{4})$", spec)
m = re.match(r"^-(\d{4})$", spec)
if m is not None:
year = int(m.group(1))
# Raises ValueError
@ -505,7 +505,7 @@ class Period:
self.prep_desc = gettext("In %s") % self.description
return
# A specific date
m = re.match("^([0-9]{4})-([0-9]{2})-([0-9]{2})$",
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$",
spec)
if m is not None:
# Raises ValueError
@ -518,8 +518,8 @@ class Period:
self.prep_desc = gettext("In %s") % self.description
return
# A specific date period
m = re.match(("^([0-9]{4})-([0-9]{2})-([0-9]{2})"
"-([0-9]{4})-([0-9]{2})-([0-9]{2})$"),
m = re.match((r"^(\d{4})-(\d{2})-(\d{2})"
r"-(\d{4})-(\d{2})-(\d{2})$"),
spec)
if m is not None:
# Raises ValueError
@ -564,7 +564,7 @@ class Period:
self.prep_desc = gettext("In %s") % self.description
return
# Until a specific day
m = re.match("^-([0-9]{4})-([0-9]{2})-([0-9]{2})$", spec)
m = re.match(r"^-(\d{4})-(\d{2})-(\d{2})$", spec)
if m is not None:
# Raises ValueError
self.end = datetime.date(

View File

@ -26,7 +26,7 @@ First written: 2020/7/10
<!-- The Modal -->
<input id="period-url" type="hidden" value="{% url_period "0000-00-00" %}" />
<input id="period-month-picker-params" type="hidden" value="{{ period.month_picker_params }}" />
<div class="modal" id="period-modal">
<div class="modal fade" id="period-modal">
<div class="modal-dialog">
<div class="modal-content">

View File

@ -30,7 +30,7 @@ from django.template import defaultfilters, RequestContext
from django.urls import reverse
from django.utils import timezone
from django.utils.safestring import SafeString
from django.utils.translation import gettext
from django.utils.translation import gettext, get_language
from mia_core.utils import UrlBuilder, CssAndJavaScriptLibraries
@ -185,8 +185,20 @@ def smart_date(value: datetime.date) -> str:
"""
if value == date.today():
return gettext("Today")
if (date.today() - value).days == 1:
prev_days = (value - date.today()).days
if prev_days == -1:
return gettext("Yesterday")
if prev_days == 1:
return gettext("Tomorrow")
if get_language() == "zh-hant":
if prev_days == -2:
return "前天"
if prev_days == -3:
return "大前天"
if prev_days == 2:
return "後天"
if prev_days == 3:
return "大後天"
if date.today().year == value.year:
return defaultfilters.date(value, "n/j(D)").replace("星期", "")
return defaultfilters.date(value, "Y/n/j(D)").replace("星期", "")

View File

@ -19,9 +19,9 @@
"""
import datetime
import random
import urllib.parse
from typing import Dict, List, Any, Type
from secrets import randbelow
from typing import Dict, List, Any, Type, Optional
from django.conf import settings
from django.db.models import Model
@ -40,7 +40,7 @@ def new_pk(cls: Type[Model]) -> int:
The new random ID.
"""
while True:
pk = random.randint(100000000, 999999999)
pk = 100000000 + randbelow(900000000)
try:
cls.objects.get(pk=pk)
except cls.DoesNotExist:
@ -59,6 +59,35 @@ def strip_post(post: Dict[str, str]) -> None:
del post[key]
STORAGE_KEY: str = "stored_post"
def store_post(request: HttpRequest, post: Dict[str, str]):
"""Stores the POST data into the session.
Args:
request: The request.
post: The POST data.
"""
request.session[STORAGE_KEY] = post
def retrieve_store(request: HttpRequest) -> Optional[Dict[str, str]]:
"""Retrieves the POST data from the storage.
Args:
request: The request.
Returns:
The POST data, or None if the previously-stored data does not exist.
"""
if STORAGE_KEY not in request.session:
return None
post = request.session[STORAGE_KEY]
del request.session[STORAGE_KEY]
return post
def parse_date(s: str):
"""Parses a string for a date. The date can be either YYYY-MM-DD,
Y/M/D, or M/D/Y.
@ -134,8 +163,8 @@ class UrlBuilder:
self.params = []
for piece in start_url[pos + 1:].split("&"):
pos = piece.find("=")
name = urllib.parse.unquote(piece[:pos])
value = urllib.parse.unquote(piece[pos + 1:])
name = urllib.parse.unquote_plus(piece[:pos])
value = urllib.parse.unquote_plus(piece[pos + 1:])
self.params.append(self.Param(name, value))
def add(self, name, value):

View File

@ -34,7 +34,7 @@ from django.views.generic import DeleteView as CoreDeleteView, \
RedirectView as CoreRedirectView
from django.views.generic.base import View
from . import stored_post, utils
from . import utils
from .models import StampedModel
from .utils import UrlBuilder
@ -109,7 +109,7 @@ class FormView(View):
utils.strip_post(post)
return self.make_form_from_post(post)
else:
previous_post = stored_post.get_previous_post(self.request)
previous_post = utils.retrieve_store(self.request)
if previous_post is not None:
return self.make_form_from_post(previous_post)
if self.object is not None:
@ -146,8 +146,8 @@ class FormView(View):
def form_invalid(self, form: forms.Form) -> HttpResponseRedirect:
"""Handles the action when the POST form is invalid."""
return stored_post.error_redirect(
self.request, self.get_error_url(), form.data)
utils.store_post(self.request, form.data)
return redirect(self.get_error_url())
def form_valid(self, form: forms.Form) -> HttpResponseRedirect:
"""Handles the action when the POST form is valid."""

128
tests/test_run.py Normal file
View File

@ -0,0 +1,128 @@
# The accounting application of the Mia project.
# by imacat <imacat@mail.imacat.idv.tw>, 2022/12/5
# Copyright (c) 2022 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 tests to run the accounting application.
"""
import os
import sys
import unittest
from datetime import datetime
from pathlib import Path
from secrets import token_urlsafe
from django.core.management import call_command
from django.core.wsgi import get_wsgi_application
from django.test import Client
class RunTestCase(unittest.TestCase):
"""The test case to run the accounting application."""
def setUp(self):
"""Sets up the test.
Returns:
None.
"""
test_site_path: str = str(Path(__file__).parent / "test_site")
if test_site_path not in sys.path:
sys.path.append(test_site_path)
self.username: str = "admin"
self.password: str = token_urlsafe(16)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_site.settings")
get_wsgi_application()
call_command("migrate")
os.getlogin()
call_command("createsuperuser", interactive=False,
username=self.username, email="test@example.com")
from django.contrib.auth.models import User
user = User.objects.get(username=self.username)
user.set_password(self.password)
user.save()
call_command("accounting_accounts")
call_command("accounting_sample")
self.client = Client()
def test_run(self):
"""Tests the accounting application.
Returns:
None.
"""
response = self.client.get("/accounting/", follow=True)
self.assertEqual(response.status_code, 404)
response = self.client.post("/admin/login/",
{"username": self.username,
"password": self.password,
"next": "/ok"})
# 200 for errors, 302 for success.
self.assertEqual(response.status_code, 302)
response = self.client.get("/accounting/", follow=True)
self.assertEqual(response.status_code, 200)
today: str = datetime.today().strftime("%Y-%m-%d")
response = self.client.post(
"/accounting/transactions/expense/create?r=/ok",
{"date": today,
"debit-2-ord": 6,
"debit-2-summary": "lunch",
"debit-2-amount": 80,
"debit-2-account": "6272",
"debit-8-ord": 4,
"debit-8-summary": "movies",
"debit-8-amount": 320,
"debit-8-account": "6273",
"notes": "yammy"})
self.assertEqual(response.status_code, 302)
response = self.client.post(
"/accounting/transactions/income/create?r=/ok",
{"date": today,
"credit-3-ord": 7,
"credit-3-summary": "withdrawal",
"credit-3-amount": 1000,
"credit-3-account": "1113",
"credit-6-ord": 3,
"credit-6-summary": "payroll",
"credit-6-amount": 10000,
"credit-6-account": "4611",
"notes": "wonderful"})
self.assertEqual(response.status_code, 302)
response = self.client.post(
"/accounting/transactions/transfer/create?r=/ok",
{"date": today,
"debit-2-ord": 6,
"debit-2-summary": "lunch",
"debit-2-amount": 80,
"debit-2-account": "6272",
"debit-8-ord": 4,
"debit-8-summary": "movies",
"debit-8-amount": 320,
"debit-8-account": "6273",
"credit-3-ord": 7,
"credit-3-summary": "withdrawal",
"credit-3-amount": 100,
"credit-3-account": "1113",
"credit-6-ord": 3,
"credit-6-summary": "",
"credit-6-amount": 300,
"credit-6-account": "1111",
"notes": "nothing"})
self.assertEqual(response.status_code, 302)

22
tests/test_site/manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_site.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,25 @@
{% load i18n %}
{% load static %}
{% load mia_core %}
{% init_libs %}
{% block settings %}{% endblock %}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/html" lang="{% get_current_language as language %}{{ language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% for css in libs.css %}
<link rel="stylesheet" type="text/css" href="{% if css|is_static_url %}{% static css %}{% else %}{{ css }}{% endif %}" />
{% endfor %}
{% for js in libs.js %}
<script src="{% if js|is_static_url %}{% static js %}{% else %}{{ js }}{% endif %}"></script>
{% endfor %}
<title>{{ title }}</title>
</head>
<body>
<h1>{{ title }}</h1>
{% block content %}{% endblock %}
</body>

View File

View File

@ -0,0 +1,16 @@
"""
ASGI config for test_site project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_site.settings')
application = get_asgi_application()

View File

@ -0,0 +1,127 @@
"""
Django settings for test_site project.
Generated by 'django-admin startproject' using Django 3.2.16.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
import os
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-vc+(5e-2xn%bc29&k#ah_vf17-)usz4af4y)njbp_k09)uev$$'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["testserver"]
# Application definition
INSTALLED_APPS = [
'mia_core.apps.MiaCoreConfig',
'accounting.apps.AccountingConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'test_site.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [(os.path.join(BASE_DIR, 'templates'))],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'test_site.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = '/static/'
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@ -0,0 +1,27 @@
"""test_site URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from decorator_include import decorator_include
from django.contrib import admin
from django.contrib.auth.decorators import login_required
from django.urls import include, path
from django.views.i18n import JavaScriptCatalog
urlpatterns = [
path('accounting/', decorator_include(login_required, 'accounting.urls')),
path('admin/', admin.site.urls),
path('i18n/', include("django.conf.urls.i18n")),
path('jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
]

View File

@ -0,0 +1,16 @@
"""
WSGI config for test_site project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_site.settings')
application = get_wsgi_application()