Compare commits

..

58 Commits
v0.0.2 ... main

Author SHA1 Message Date
7086c26ce7 Updated README.rst for the current Flask project. 2023-04-05 12:52:44 +08:00
2ec072854b Advanced to version 0.2.0. 2023-04-04 19:19:53 +08:00
a41d381322 Added a warning to the last Django release. 2023-04-04 19:19:15 +08:00
c301f7ca74 Renamed the project from "Mia! Accounting" to "Mia! Accounting Django". 2023-04-04 19:01:57 +08:00
020555602d Removed the deprecated and unused USE_L10N setting from the test site. 2023-01-22 08:08:42 +08:00
9176be3c11 Added requirements.txt for readthedocs.io. 2022-12-08 01:07:08 +08:00
1d16b250d8 Revised the documentation. 2022-12-08 01:06:15 +08:00
72b9555e29 Added the build directory to .gitignore. 2022-12-08 00:53:03 +08:00
d09255432d Applied Sphinx autodoc, and added the automatically-generated module documents to the documentation. 2022-12-08 00:52:49 +08:00
64e81a64ef Added the Sphinx documentation skeleton. 2022-12-08 00:42:55 +08:00
e14a0432b2 Applied a random secret key to the settings of the test site, although it is not important. 2022-12-06 08:14:39 +08:00
7e7d428b52 Added the test URI variables in the test_run test of the RunTestCase test case. 2022-12-06 07:07:49 +08:00
353ebd7242 Revised the test_run test of the RunTestCase test case to test the failure adding the transfer transaction. 2022-12-06 07:07:25 +08:00
467739e0c8 Revised the test_run test of the RunTestCase test case to test if the result is successful. 2022-12-06 07:07:14 +08:00
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
94 changed files with 1475 additions and 586 deletions

5
.gitignore vendored
View File

@ -1,11 +1,14 @@
*.pyc *.pyc
__pycache__ __pycache__
dist dist
build
*.egg-info *.egg-info
migrations migrations
*.mo *.mo
.idea .idea
venv venv
mirror
excludes excludes
zh_Hans 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.
```

338
README.rst Normal file
View File

@ -0,0 +1,338 @@
======================================
The Mia! Accounting Django Application
======================================
Obsolete Project
================
This project is obsolete. No further Django release will be
available. Check the current `Mia! Accounting project`_ for Flask_,
and the current `Mia! Accounting live demonstration`_.
.. _Mia! Accounting project: https://github.com/imacat/mia-accounting
.. _Flask: https://flask.palletsprojects.com
.. _Mia! Accounting live demonstration: https://accounting.imacat.idv.tw
Warning
=======
This is the last release of Django. It will be replaced by a new
Flask_ release, starting from scratch. Do not upgrade to the next
release, as it will not work.
This was my first large Python project, and at that time, I had zero
experience with Django. I ended up in a mess with Django MVT. The
code is unnecessarily complicated, and I do not actually know how the
views work anymore.
If you are new to the Mia! Accounting project, please skip this and
try the new release. You may contact me if you have problems with
the existing installation, but I may not be able to help you.
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! Accounting Django 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-django.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! Accounting Django application requires Python 3.7 and Django
3.1.
Install ``mia-accounting-django`` with ``pip``.
.. code::
pip install mia-accounting-django
``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 Django
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! Accounting Django application is hosted on GitHub.
https://github.com/imacat/mia-accounting-django
Address all bugs and support requests to imacat@mail.imacat.idv.tw.
Copyright
=========
Copyright (c) 2020-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.

20
docs/Makefile Normal file
View File

@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

35
docs/make.bat Normal file
View File

@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
if "%1" == "" goto help
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

4
docs/requirements.txt Normal file
View File

@ -0,0 +1,4 @@
django
django-dirtyfields
titlecase
django-decorator-include

View File

@ -0,0 +1,21 @@
accounting.migrations package
=============================
Submodules
----------
accounting.migrations.0001\_initial module
------------------------------------------
.. automodule:: accounting.migrations.0001_initial
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.migrations
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,94 @@
accounting package
==================
Subpackages
-----------
.. toctree::
:maxdepth: 4
accounting.migrations
accounting.templatetags
Submodules
----------
accounting.apps module
----------------------
.. automodule:: accounting.apps
:members:
:undoc-members:
:show-inheritance:
accounting.converters module
----------------------------
.. automodule:: accounting.converters
:members:
:undoc-members:
:show-inheritance:
accounting.forms module
-----------------------
.. automodule:: accounting.forms
:members:
:undoc-members:
:show-inheritance:
accounting.models module
------------------------
.. automodule:: accounting.models
:members:
:undoc-members:
:show-inheritance:
accounting.tests module
-----------------------
.. automodule:: accounting.tests
:members:
:undoc-members:
:show-inheritance:
accounting.urls module
----------------------
.. automodule:: accounting.urls
:members:
:undoc-members:
:show-inheritance:
accounting.utils module
-----------------------
.. automodule:: accounting.utils
:members:
:undoc-members:
:show-inheritance:
accounting.validators module
----------------------------
.. automodule:: accounting.validators
:members:
:undoc-members:
:show-inheritance:
accounting.views module
-----------------------
.. automodule:: accounting.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,21 @@
accounting.templatetags package
===============================
Submodules
----------
accounting.templatetags.accounting module
-----------------------------------------
.. automodule:: accounting.templatetags.accounting
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: accounting.templatetags
:members:
:undoc-members:
:show-inheritance:

36
docs/source/conf.py Normal file
View File

@ -0,0 +1,36 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
import sys
import django
sys.path.insert(0, os.path.abspath('../../src/'))
sys.path.append(os.path.abspath('../../tests/test_site/'))
os.environ["DJANGO_SETTINGS_MODULE"] = "test_site.settings"
django.setup()
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'Mia! Accounting Django'
copyright = '2022-2023, imacat'
author = 'imacat'
release = '0.2.0'
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
extensions = ["sphinx.ext.autodoc"]
templates_path = ['_templates']
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']

20
docs/source/index.rst Normal file
View File

@ -0,0 +1,20 @@
.. Mia! Accounting Django documentation master file, created by
sphinx-quickstart on Thu Dec 8 00:42:14 2022.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Mia! Accounting Django's documentation!
==================================================
.. toctree::
:maxdepth: 2
:caption: Contents:
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View File

@ -0,0 +1,10 @@
mia\_core.migrations package
============================
Module contents
---------------
.. automodule:: mia_core.migrations
:members:
:undoc-members:
:show-inheritance:

70
docs/source/mia_core.rst Normal file
View File

@ -0,0 +1,70 @@
mia\_core package
=================
Subpackages
-----------
.. toctree::
:maxdepth: 4
mia_core.migrations
mia_core.templatetags
Submodules
----------
mia\_core.apps module
---------------------
.. automodule:: mia_core.apps
:members:
:undoc-members:
:show-inheritance:
mia\_core.models module
-----------------------
.. automodule:: mia_core.models
:members:
:undoc-members:
:show-inheritance:
mia\_core.period module
-----------------------
.. automodule:: mia_core.period
:members:
:undoc-members:
:show-inheritance:
mia\_core.tests module
----------------------
.. automodule:: mia_core.tests
:members:
:undoc-members:
:show-inheritance:
mia\_core.utils module
----------------------
.. automodule:: mia_core.utils
:members:
:undoc-members:
:show-inheritance:
mia\_core.views module
----------------------
.. automodule:: mia_core.views
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: mia_core
:members:
:undoc-members:
:show-inheritance:

View File

@ -0,0 +1,21 @@
mia\_core.templatetags package
==============================
Submodules
----------
mia\_core.templatetags.mia\_core module
---------------------------------------
.. automodule:: mia_core.templatetags.mia_core
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: mia_core.templatetags
:members:
:undoc-members:
:show-inheritance:

8
docs/source/modules.rst Normal file
View File

@ -0,0 +1,8 @@
src
===
.. toctree::
:maxdepth: 4
accounting
mia_core

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-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.
[metadata]
name = mia-accounting
version = 0.2.0
author = imacat
author_email = imacat@mail.imacat.idv.tw
description = The Mia! Accounting Django project.
long_description = file: README.rst
long_description_content_type = text/x-rst
url = https://github.com/imacat/mia-accounting-django
project_urls =
Bug Tracker = https://github.com/imacat/mia-accounting-django/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: class PeriodConverter:
"""The path converter for the period.""" """The path converter for the period."""
regex = ("([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)|" regex = (r"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)|"
"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?-" r"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?-"
"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?") r"([0-9]{4}(-[0-9]{2}(-[0-9]{2})?)?)?")
def to_python(self, value): def to_python(self, value):
"""Returns the period by the period specification. """Returns the period by the period specification.
@ -90,7 +90,7 @@ class DateConverter:
Returns: Returns:
datetime.date: The date. 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)) year = int(m.group(1))
month = int(m.group(2)) month = int(m.group(2))
day = int(m.group(3)) day = int(m.group(3))

View File

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

View File

@ -20,7 +20,7 @@
""" """
import datetime import datetime
import getpass import getpass
import random from secrets import randbelow
from typing import Optional from typing import Optional
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
@ -98,20 +98,20 @@ class Command(BaseCommand):
self._filler.add_expense_transaction( self._filler.add_expense_transaction(
-2, -2,
[(6272, _("Lunch—Spaghetti"), random.randint(40, 200)), [(6272, _("Lunch—Spaghetti"), 40 + randbelow(160)),
(6272, _("Drink—Tea"), random.randint(40, 200))]) (6272, _("Drink—Tea"), 40 + randbelow(160))])
self._filler.add_expense_transaction( self._filler.add_expense_transaction(
-1, -1,
([(6272, _("Lunch—Pizza"), random.randint(40, 200)), ([(6272, _("Lunch—Pizza"), 40 + randbelow(160)),
(6272, _("Drink—Tea"), random.randint(40, 200))])) (6272, _("Drink—Tea"), 40 + randbelow(160))]))
self._filler.add_expense_transaction( self._filler.add_expense_transaction(
-1, -1,
[(6272, _("Lunch—Spaghetti"), random.randint(40, 200)), [(6272, _("Lunch—Spaghetti"), 40 + randbelow(160)),
(6272, _("Drink—Soda"), random.randint(40, 200))]) (6272, _("Drink—Soda"), 40 + randbelow(160))])
self._filler.add_expense_transaction( self._filler.add_expense_transaction(
0, 0,
[(6272, _("Lunch—Salad"), random.randint(40, 200)), [(6272, _("Lunch—Salad"), 40 + randbelow(160)),
(6272, _("Drink—Coffee"), random.randint(40, 200))]) (6272, _("Drink—Coffee"), 40 + randbelow(160))])
@staticmethod @staticmethod
def get_user(username_option): def get_user(username_option):
@ -155,7 +155,7 @@ class Command(BaseCommand):
payday = today.replace(day=5) payday = today.replace(day=5)
if payday > today: if payday > today:
payday = self.previous_month(payday) payday = self.previous_month(payday)
for i in range(months): for _ in range(months):
self.add_payroll(payday) self.add_payroll(payday)
payday = self.previous_month(payday) payday = self.previous_month(payday)
@ -181,7 +181,7 @@ class Command(BaseCommand):
Args: Args:
payday: The payday. payday: The payday.
""" """
income = random.randint(40000, 50000) income = 40000 + randbelow(10000)
pension = 882 if income <= 40100\ pension = 882 if income <= 40100\
else 924 if income <= 42000\ else 924 if income <= 42000\
else 966 if income <= 43900\ 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 self.ord = 1 if max_ord is None else max_ord + 1
# Collects the records to be deleted # Collects the records to be deleted
to_keep = [x.pk for x in self.records if x.pk is not None] 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 to_save = [x for x in self.records
if x.is_dirty(check_relationship=True)] if x.is_dirty(check_relationship=True)]
for record in to_save: for record in to_save:
@ -215,7 +216,7 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
txn_type: The transaction type. txn_type: The transaction type.
""" """
self.old_date = self.date 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( self.date = datetime.date(
int(m.group(1)), int(m.group(1)),
int(m.group(2)), int(m.group(2)),
@ -228,7 +229,8 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
for i in range(max_no[record_type]): for i in range(max_no[record_type]):
no = i + 1 no = i + 1
if F"{record_type}-{no}-id" in post: 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: else:
record = Record( record = Record(
is_credit=(record_type == "credit"), 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. """Finds the max debit and record numbers from the POSTed form.
Args: Args:
txn_type (str): The transaction type. txn_type: The transaction type.
post (dict[str,str]): The POSTed data. post: The POSTed data.
Returns: 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 = {} max_no = {}
if txn_type != "credit": if txn_type != "credit":
@ -281,7 +282,8 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
max_no["credit"] = 0 max_no["credit"] = 0
for key in post.keys(): for key in post.keys():
m = re.match( 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) key)
if m is None: if m is None:
continue continue
@ -301,8 +303,11 @@ class Transaction(DirtyFieldsMixin, StampedModel, RandomPkModel):
List[Record]: The records. List[Record]: The records.
""" """
if self._records is None: if self._records is None:
self._records = list(self.record_set.all()) if self.pk is None:
self._records.sort(key=lambda x: (x.is_credit, x.ord)) self._records = []
else:
self._records = list(self.record_set.all())
self._records.sort(key=lambda x: (x.is_credit, x.ord))
return self._records return self._records
@records.setter @records.setter

View File

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

View File

@ -50,8 +50,8 @@ let accounts;
*/ */
function getAllAccounts() { function getAllAccounts() {
const request = new XMLHttpRequest(); const request = new XMLHttpRequest();
request.onreadystatechange = function() { request.onload = function() {
if (this.readyState === 4 && this.status === 200) { if (this.status === 200) {
accounts = JSON.parse(this.responseText); accounts = JSON.parse(this.responseText);
} }
}; };
@ -75,7 +75,7 @@ function updateParent(code) {
parent.text(gettext("Topmost")); parent.text(gettext("Topmost"));
return; 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) { if (parentCode in accounts) {
parent.text(parentCode + " " + accounts[parentCode]); parent.text(parentCode + " " + accounts[parentCode]);
return; return;
@ -96,10 +96,10 @@ function updateParent(code) {
* @private * @private
*/ */
function validateForm() { function validateForm() {
let isValidated = true; let isValid = true;
isValidated = isValidated && validateCode(); isValid = validateCode() && isValid;
isValidated = isValidated && validateTitle(); isValid = validateTitle() && isValid;
return isValidated; return isValid;
} }
/** /**
@ -136,7 +136,7 @@ function validateCode() {
return false; 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)) { if (!(parentCode in accounts)) {
code.classList.add("is-invalid"); code.classList.add("is-invalid");
errorMessage.text(gettext("The parent account of this code does not exist.")); 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("×"); const pos = summary.lastIndexOf("×");
let count = 1; let count = 1;
if (pos !== -1) { if (pos !== -1) {
count = parseInt(summary.substr(pos + 1)); count = parseInt(summary.substring(pos + 1));
} }
if (count === 0) { if (count === 0) {
count = 1; count = 1;
@ -329,7 +329,7 @@ function parseSummaryForCategoryHelpers(summary) {
}); });
// A bus route // A bus route
const matchBus = summary.match(/^(.+)—(.+)—(.+)→(.+?)(?:×[0-9]+)?$/); const matchBus = summary.match(/^([^—]+)—([^—]+)—([^—]+)→([^—]+?)(?:×\d+)?$/);
if (matchBus !== null) { if (matchBus !== null) {
$("#summary-bus-category").get(0).value = matchBus[1]; $("#summary-bus-category").get(0).value = matchBus[1];
setSummaryBusCategoryButtons(matchBus[1]); setSummaryBusCategoryButtons(matchBus[1]);
@ -342,7 +342,7 @@ function parseSummaryForCategoryHelpers(summary) {
} }
// A general travel route // A general travel route
const matchTravel = summary.match(/^(.+)—(.+)([→|↔])(.+?)(?:×[0-9]+)?$/); const matchTravel = summary.match(/^([^—]+)—([^—]+)([→|↔])([^—]+?)(?:×\d+)?$/);
if (matchTravel !== null) { if (matchTravel !== null) {
$("#summary-travel-category").get(0).value = matchTravel[1]; $("#summary-travel-category").get(0).value = matchTravel[1];
setSummaryTravelCategoryButtons(matchTravel[1]); setSummaryTravelCategoryButtons(matchTravel[1]);
@ -357,7 +357,7 @@ function parseSummaryForCategoryHelpers(summary) {
// A general category // A general category
const generalCategoryTab = $("#summary-tab-category"); const generalCategoryTab = $("#summary-tab-category");
const matchCategory = summary.match(/^(.+)—.+(?:×[0-9]+)?$/); const matchCategory = summary.match(/^([^—]+)—.+(?:×\d+)?$/);
if (matchCategory !== null) { if (matchCategory !== null) {
$("#summary-general-category").get(0).value = matchCategory[1]; $("#summary-general-category").get(0).value = matchCategory[1];
setSummaryGeneralCategoryButtons(matchCategory[1]); setSummaryGeneralCategoryButtons(matchCategory[1]);

View File

@ -91,8 +91,8 @@ let accountOptions;
*/ */
function getAccountOptions() { function getAccountOptions() {
const request = new XMLHttpRequest(); const request = new XMLHttpRequest();
request.onreadystatechange = function() { request.onload = function() {
if (this.readyState === 4 && this.status === 200) { if (this.status === 200) {
accountOptions = JSON.parse(this.responseText); accountOptions = JSON.parse(this.responseText);
$(".record-account").each(function () { $(".record-account").each(function () {
initializeAccountOptions($(this)); initializeAccountOptions($(this));
@ -184,8 +184,8 @@ function updateTotalAmount(element) {
} }
}); });
total = String(total); total = String(total);
while (total.match(/^[1-9][0-9]*[0-9]{3}/)) { while (total.match(/^[1-9]\d*\d{3}/)) {
total = total.replace(/^([1-9][0-9]*)([0-9]{3})/, "$1,$2"); total = total.replace(/^([1-9]\d*)(\d{3})/, "$1,$2");
} }
$("#" + type + "-total").text(total); $("#" + type + "-total").text(total);
} }
@ -337,28 +337,28 @@ function resetRecordButtons() {
* @private * @private
*/ */
function validateForm() { function validateForm() {
let isValidated = true; let isValid = true;
isValidated = isValidated && validateDate(); isValid = validateDate() && isValid;
$(".debit-record").each(function () { $(".debit-record").each(function () {
isValidated = isValidated && validateRecord(this); isValid = validateRecord(this) && isValid;
}); });
$(".credit-account").each(function () { $(".credit-account").each(function () {
isValidated = isValidated && validateRecord(this); isValid = validateRecord(this) && isValid;
}); });
$(".record-account").each(function () { $(".record-account").each(function () {
isValidated = isValidated && validateAccount(this); isValid = validateAccount(this) && isValid;
}); });
$(".record-summary").each(function () { $(".record-summary").each(function () {
isValidated = isValidated && validateSummary(this); isValid = validateSummary(this) && isValid;
}); });
$(".record-amount").each(function () { $(".record-amount").each(function () {
isValidated = isValidated && validateAmount(this); isValid = validateAmount(this) && isValid;
}); });
if (isTransfer()) { if (isTransfer()) {
isValidated = isValidated && validateBalance(); isValid = validateBalance() && isValid;
} }
isValidated = isValidated && validateNote(); isValid = validateNote() && isValid;
return isValidated; 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"> <form action="{% url "accounting:accounts.delete" account as url %}{% url_keep_return url %}" method="post">
{% csrf_token %} {% csrf_token %}
<!-- The Modal --> <!-- The Modal -->
<div class="modal" id="del-modal"> <div class="modal fade" id="del-modal">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@ -57,8 +57,8 @@ First written: 2020/8/8
<!-- Modal footer --> <!-- Modal footer -->
<div class="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 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> </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) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2020/8/5 First written: 2020/8/5
{% endcomment %} {% 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 }}"> <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> <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>
<div class="col-sm-4"> <div class="col-sm-4">
<label for="{{ record_type }}-{{ no }}-amount" class="record-label">{{ _("Amount:")|force_escape }}</label> <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 id="{{ record_type }}-{{ no }}-amount-error" class="invalid-feedback">{{ record.amount.errors.0|default:"" }}</div>
</div> </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) Author: imacat@mail.imacat.idv.tw (imacat)
First written: 2020/8/5 First written: 2020/8/5
{% endcomment %} {% 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 }}"> <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> <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>
<div class="col-lg-4"> <div class="col-lg-4">
<label for="{{ record_type }}-{{ no }}-amount" class="record-label">{{ _("Amount:")|force_escape }}</label> <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 id="{{ record_type }}-{{ no }}-amount-error" class="invalid-feedback">{{ record.amount.errors.0|default:"" }}</div>
</div> </div>
</div> </div>

View File

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

View File

@ -25,7 +25,7 @@ First written: 2020/4/3
<!-- The Modal --> <!-- The Modal -->
<form id="summary-helper-form" action="" method="get"> <form id="summary-helper-form" action="" method="get">
<input id="summary-record" type="hidden" value="" /> <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-dialog">
<div class="modal-content"> <div class="modal-content">
@ -155,8 +155,8 @@ First written: 2020/4/3
<!-- Modal footer --> <!-- Modal footer -->
<div class="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 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> </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"> <form action="{% url "accounting:transactions.delete" txn as url %}{% url_keep_return url %}" method="post">
{% csrf_token %} {% csrf_token %}
<!-- The Modal --> <!-- The Modal -->
<div class="modal" id="del-modal"> <div class="modal fade" id="del-modal">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@ -59,8 +59,8 @@ First written: 2020/7/23
<!-- Modal footer --> <!-- Modal footer -->
<div class="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 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> </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"> <form action="{% url "accounting:transactions.delete" txn as url %}{% url_keep_return url %}" method="post">
{% csrf_token %} {% csrf_token %}
<!-- The Modal --> <!-- The Modal -->
<div class="modal" id="del-modal"> <div class="modal fade" id="del-modal">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@ -59,8 +59,8 @@ First written: 2020/7/23
<!-- Modal footer --> <!-- Modal footer -->
<div class="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 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> </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"> <form action="{% url "accounting:transactions.delete" txn as url %}{% url_keep_return url %}" method="post">
{% csrf_token %} {% csrf_token %}
<!-- The Modal --> <!-- The Modal -->
<div class="modal" id="del-modal"> <div class="modal fade" id="del-modal">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@ -59,8 +59,8 @@ First written: 2020/7/23
<!-- Modal footer --> <!-- Modal footer -->
<div class="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 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> </div>
</div> </div>

View File

@ -20,7 +20,7 @@
""" """
import re import re
from decimal import Decimal from decimal import Decimal
from typing import Union, Optional from typing import Optional
from django import template from django import template
from django.template import RequestContext from django.template import RequestContext
@ -32,28 +32,41 @@ from mia_core.period import Period
register = template.Library() 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. """Formats a positive amount, groups every 3 digits by commas.
Args: Args:
value: The amount. value: The amount.
Returns: Returns:
ReportUrl: The formatted amount. str: The amount in the desired format.
""" """
s = str(value) s = _strip_decimal_zeros(value)
while True: 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: if m is None:
break break
s = m.group(1) + "," + m.group(2) 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 return s
@register.filter @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 """Formats an amount with the accounting notation, grouping every 3 digits
by commas, and marking negative numbers with brackets instead of signs. 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. value: The amount.
Returns: Returns:
ReportUrl: The formatted amount. str: The amount in the desired format.
""" """
if value is None: if value is None:
return "" return ""
@ -74,14 +87,14 @@ def accounting_amount(value: Union[Decimal]) -> str:
@register.filter @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. """Formats an amount, groups every 3 digits by commas.
Args: Args:
value: The amount. value: The amount.
Returns: Returns:
ReportUrl: The formatted amount. str: The amount in the desired format.
""" """
if value is None: if value is None:
return "" return ""
@ -93,6 +106,21 @@ def short_amount(value: Union[Decimal]) -> str:
return s 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) @register.simple_tag(takes_context=True)
def report_url(context: RequestContext, def report_url(context: RequestContext,
cash_account: Optional[Account], cash_account: Optional[Account],

View File

@ -80,10 +80,9 @@ class ReportUrl:
Args: Args:
namespace: The namespace of the current application. namespace: The namespace of the current application.
cash: The currently-specified account of the cash: The currently-specified account of the cash account or cash
cash account or cash summary. summary.
ledger: The currently-specified account of the ledger: The currently-specified account of the ledger or leger summary.
ledger or leger summary.
period: The currently-specified period. period: The currently-specified period.
""" """
@ -145,9 +144,8 @@ class DataFiller:
"""Adds accounts. """Adds accounts.
Args: Args:
accounts (tuple[tuple[any]]): Tuples of accounts: Tuples of (code, English, Traditional Chinese, Simplified
(code, English, Traditional Chinese, Simplified Chinese) Chinese) of the accounts.
of the accounts.
""" """
for data in accounts: for data in accounts:
code = data[0] code = data[0]
@ -167,8 +165,7 @@ class DataFiller:
"""Adds a transfer transaction. """Adds a transfer transaction.
Args: Args:
date: The date, or the number of days from date: The date, or the number of days from today.
today.
debit: Tuples of (account, summary, amount) of the debit records. debit: Tuples of (account, summary, amount) of the debit records.
credit: Tuples of (account, summary, amount) of the credit records. credit: Tuples of (account, summary, amount) of the credit records.
""" """

View File

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

0
src/mia_core/__init__.py Normal file
View File

View File

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

View File

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

View File

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

View File

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

View File

@ -26,7 +26,7 @@ First written: 2020/7/10
<!-- The Modal --> <!-- The Modal -->
<input id="period-url" type="hidden" value="{% url_period "0000-00-00" %}" /> <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 }}" /> <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-dialog">
<div class="modal-content"> <div class="modal-content">

View File

View File

@ -30,7 +30,7 @@ from django.template import defaultfilters, RequestContext
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.safestring import SafeString 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 from mia_core.utils import UrlBuilder, CssAndJavaScriptLibraries
@ -185,8 +185,20 @@ def smart_date(value: datetime.date) -> str:
""" """
if value == date.today(): if value == date.today():
return gettext("Today") return gettext("Today")
if (date.today() - value).days == 1: prev_days = (value - date.today()).days
if prev_days == -1:
return gettext("Yesterday") 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: if date.today().year == value.year:
return defaultfilters.date(value, "n/j(D)").replace("星期", "") return defaultfilters.date(value, "n/j(D)").replace("星期", "")
return defaultfilters.date(value, "Y/n/j(D)").replace("星期", "") return defaultfilters.date(value, "Y/n/j(D)").replace("星期", "")

View File

@ -19,9 +19,9 @@
""" """
import datetime import datetime
import random
import urllib.parse 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.conf import settings
from django.db.models import Model from django.db.models import Model
@ -40,7 +40,7 @@ def new_pk(cls: Type[Model]) -> int:
The new random ID. The new random ID.
""" """
while True: while True:
pk = random.randint(100000000, 999999999) pk = 100000000 + randbelow(900000000)
try: try:
cls.objects.get(pk=pk) cls.objects.get(pk=pk)
except cls.DoesNotExist: except cls.DoesNotExist:
@ -59,6 +59,35 @@ def strip_post(post: Dict[str, str]) -> None:
del post[key] 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): def parse_date(s: str):
"""Parses a string for a date. The date can be either YYYY-MM-DD, """Parses a string for a date. The date can be either YYYY-MM-DD,
Y/M/D, or M/D/Y. Y/M/D, or M/D/Y.
@ -134,8 +163,8 @@ class UrlBuilder:
self.params = [] self.params = []
for piece in start_url[pos + 1:].split("&"): for piece in start_url[pos + 1:].split("&"):
pos = piece.find("=") pos = piece.find("=")
name = urllib.parse.unquote(piece[:pos]) name = urllib.parse.unquote_plus(piece[:pos])
value = urllib.parse.unquote(piece[pos + 1:]) value = urllib.parse.unquote_plus(piece[pos + 1:])
self.params.append(self.Param(name, value)) self.params.append(self.Param(name, value))
def add(self, name, value): def add(self, name, value):
@ -400,8 +429,7 @@ class Pagination:
url (str): The link URL, or for a non-link slot. url (str): The link URL, or for a non-link slot.
title (str): The title of the link. title (str): The title of the link.
is_active (bool): Whether this link is currently active. is_active (bool): Whether this link is currently active.
is_small_screen (bool): Whether this link is for small is_small_screen (bool): Whether this link is for small screens.
screens
""" """
def __int__(self): def __int__(self):
self.url = None self.url = None

View File

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

158
tests/test_run.py Normal file
View File

@ -0,0 +1,158 @@
# 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")
expense_add_uri: str = "/accounting/transactions/expense/create?r=/ok"
income_add_uri: str = "/accounting/transactions/income/create?r=/ok"
transfer_add_uri: str \
= "/accounting/transactions/transfer/create?r=/ok"
response = self.client.post(
expense_add_uri,
{"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)
self.assertNotEqual(response.headers["Location"], expense_add_uri)
response = self.client.post(
income_add_uri,
{"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)
self.assertNotEqual(response.headers["Location"], income_add_uri)
response = self.client.post(
transfer_add_uri,
{"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": 320,
"credit-6-account": "1111",
"notes": "nothing"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["Location"], transfer_add_uri)
response = self.client.post(
transfer_add_uri,
{"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)
self.assertNotEqual(response.headers["Location"], transfer_add_uri)

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,126 @@
"""
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
from secrets import token_urlsafe
# 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 = token_urlsafe(48)
# 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_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()