Added the account form in the accounting application.
This commit is contained in:
parent
008def227d
commit
724ba44a71
@ -21,10 +21,13 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.core.validators import RegexValidator
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from mia_core.status import retrieve_status
|
||||||
from .models import Account, Record
|
from .models import Account, Record
|
||||||
from .validators import validate_record_account_code, validate_record_id
|
from .validators import validate_record_account_code, validate_record_id, \
|
||||||
|
validate_account_code
|
||||||
|
|
||||||
|
|
||||||
class RecordForm(forms.Form):
|
class RecordForm(forms.Form):
|
||||||
@ -301,3 +304,35 @@ class TransactionForm(forms.Form):
|
|||||||
"""
|
"""
|
||||||
return sum([int(x.data["amount"]) for x in self.credit_records
|
return sum([int(x.data["amount"]) for x in self.credit_records
|
||||||
if "amount" not in x.errors])
|
if "amount" not in x.errors])
|
||||||
|
|
||||||
|
|
||||||
|
class AccountForm(forms.Form):
|
||||||
|
"""An account form."""
|
||||||
|
code = forms.CharField(
|
||||||
|
error_messages={
|
||||||
|
"required": _("Please fill in the code."),
|
||||||
|
"invalid": _("Please fill in a number."),
|
||||||
|
"max_length": _("This code is too long (max. 5)."),
|
||||||
|
"min_value": _("This code is too long (max. 5)."),
|
||||||
|
}, validators=[
|
||||||
|
RegexValidator(
|
||||||
|
regex="^[1-9]+$",
|
||||||
|
message=_("You can only use numbers 1-9 in the code")),
|
||||||
|
validate_account_code,
|
||||||
|
])
|
||||||
|
title = forms.CharField(
|
||||||
|
max_length=128,
|
||||||
|
error_messages={
|
||||||
|
"required": _("Please fill in the title."),
|
||||||
|
"max_length": _("This title is too long (max. 128)."),
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(AccountForm, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parent(self):
|
||||||
|
code = self["code"].value()
|
||||||
|
if code is None or len(code) < 2:
|
||||||
|
return None
|
||||||
|
return Account.objects.get(code=code[:-1])
|
||||||
|
189
accounting/static/accounting/js/account-form.js
Normal file
189
accounting/static/accounting/js/account-form.js
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/* The Mia Website
|
||||||
|
* account-form.js: The JavaScript to edit an account
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Copyright (c) 2019-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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
* First written: 2020/3/23
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Initializes the page JavaScript.
|
||||||
|
$(function () {
|
||||||
|
getAllAccounts();
|
||||||
|
$("#account-code").on("blur", function () {
|
||||||
|
updateParent(this);
|
||||||
|
validateCode();
|
||||||
|
});
|
||||||
|
$("#account-title").on("blur", function () {
|
||||||
|
validateTitle();
|
||||||
|
});
|
||||||
|
$("#account-form").on("submit", function () {
|
||||||
|
return validateForm();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All the accounts
|
||||||
|
* @type {Array.}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
let accounts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtains all the accounts.
|
||||||
|
*
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function getAllAccounts() {
|
||||||
|
const request = new XMLHttpRequest();
|
||||||
|
request.onreadystatechange = function() {
|
||||||
|
if (this.readyState === 4 && this.status === 200) {
|
||||||
|
accounts = JSON.parse(this.responseText);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.open("GET", $("#all-account-url").val(), true);
|
||||||
|
request.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the parent account.
|
||||||
|
*
|
||||||
|
* @param {HTMLInputElement} code the code input element
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function updateParent(code) {
|
||||||
|
const parent = $("#account-parent");
|
||||||
|
if (code.value.length === 0) {
|
||||||
|
parent.text("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (code.value.length === 1) {
|
||||||
|
parent.text(gettext("Topmost"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parentCode = code.value.substr(0, code.value.length - 1);
|
||||||
|
if (parentCode in accounts) {
|
||||||
|
parent.text(parentCode + " " + accounts[parentCode]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parent.text(gettext("(Unknown)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*******************
|
||||||
|
* Form Validation *
|
||||||
|
*******************/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the form.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if the validation succeed, or false
|
||||||
|
* otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateForm() {
|
||||||
|
let isValidated = true;
|
||||||
|
isValidated = isValidated && validateCode();
|
||||||
|
isValidated = isValidated && validateTitle();
|
||||||
|
return isValidated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the code column.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if the validation succeed, or false
|
||||||
|
* otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateCode() {
|
||||||
|
const code = $("#account-code")[0];
|
||||||
|
const errorMessage = $("#account-code-error");
|
||||||
|
code.value = code.value.trim();
|
||||||
|
if (code.value === "") {
|
||||||
|
code.classList.add("is-invalid");
|
||||||
|
errorMessage.text(gettext("Please fill in the code."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!code.value.match(/^[1-9][0-9]*$/)) {
|
||||||
|
code.classList.add("is-invalid");
|
||||||
|
errorMessage.text(gettext("You can only use a number as the code"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const originalCode = $("#account-code-original").val();
|
||||||
|
if (code.value !== originalCode) {
|
||||||
|
if (originalCode !== "" && code.value.startsWith(originalCode)) {
|
||||||
|
code.classList.add("is-invalid");
|
||||||
|
errorMessage.text(gettext("You cannot set the code under itself."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (code.value in accounts) {
|
||||||
|
code.classList.add("is-invalid");
|
||||||
|
errorMessage.text(gettext("This code is already in use."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parentCode = code.value.substr(0, code.value.length - 1);
|
||||||
|
if (!(parentCode in accounts)) {
|
||||||
|
code.classList.add("is-invalid");
|
||||||
|
errorMessage.text(gettext("The parent account of this code does not exist."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (originalCode !== "" && code.value !== originalCode) {
|
||||||
|
const descendants = [];
|
||||||
|
Object.keys(accounts).forEach(function (key) {
|
||||||
|
if (key.startsWith(originalCode) && key !== originalCode) {
|
||||||
|
descendants.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (descendants.length > 0) {
|
||||||
|
descendants.sort(function (a, b) {
|
||||||
|
return b.length - a.length;
|
||||||
|
});
|
||||||
|
if (descendants[0].length
|
||||||
|
- originalCode.length
|
||||||
|
+ code.value.length > code.maxLength) {
|
||||||
|
code.classList.add("is-invalid");
|
||||||
|
errorMessage.text(gettext("The descendant account codes will be too long (max. 5)."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code.classList.remove("is-invalid");
|
||||||
|
errorMessage.text("");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the title column.
|
||||||
|
*
|
||||||
|
* @returns {boolean} true if the validation succeed, or false
|
||||||
|
* otherwise
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function validateTitle() {
|
||||||
|
const title = $("#account-title")[0];
|
||||||
|
const errorMessage = $("#account-title-error");
|
||||||
|
title.value = title.value.trim();
|
||||||
|
if (title.value === "") {
|
||||||
|
title.classList.add("is-invalid");
|
||||||
|
errorMessage.text(gettext("Please fill in the title."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
title.classList.remove("is-invalid");
|
||||||
|
errorMessage.text("");
|
||||||
|
return true;
|
||||||
|
}
|
83
accounting/templates/accounting/account_form.html
Normal file
83
accounting/templates/accounting/account_form.html
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% comment %}
|
||||||
|
The Mia Accounting Application
|
||||||
|
account_detail.html: The template for the account detail
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Author: imacat@mail.imacat.idv.tw (imacat)
|
||||||
|
First written: 2020/8/8
|
||||||
|
{% endcomment %}
|
||||||
|
{% load static %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load mia_core %}
|
||||||
|
{% load accounting %}
|
||||||
|
|
||||||
|
{% block settings %}
|
||||||
|
{% setvar "title" account %}
|
||||||
|
{% static "accounting/js/account-form.js" as file %}{% add_js file %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="btn-group btn-actions">
|
||||||
|
<a class="btn btn-primary" role="button" href="{% if account %}{% url "accounting:accounts.detail" request.resolver_match.kwargs.account %}{% else %}{% url "accounting:accounts" %}{% endif %}">
|
||||||
|
<i class="fas fa-chevron-circle-left"></i>
|
||||||
|
{{ _("Back")|force_escape }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="account-form" action="{% if request.resolver_match.kwargs.account %}{% url "accounting:accounts.update" request.resolver_match.kwargs.account %}{% else %}{% url "accounting:accounts.store" %}{% endif %}" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input id="all-account-url" type="hidden" value="{% url "accounting:api.accounts.all" %}" />
|
||||||
|
<input id="account-code-original" type="hidden" value="{% if request.resolver_match.kwargs.account %}{{ request.resolver_match.kwargs.account.code }}{% endif %}" />
|
||||||
|
<div class="row form-group">
|
||||||
|
<label class="col-sm-2" for="account-parent">{{ _("Parent Account:")|force_escape }}</label>
|
||||||
|
<div id="account-parent" class="col-sm-10">
|
||||||
|
{% if form.parent %}
|
||||||
|
{{ form.parent }}
|
||||||
|
{% else %}
|
||||||
|
{{ _("Topmost")|force_escape }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row form-group">
|
||||||
|
<label class="col-sm-2 col-form-label" for="account-code">{{ _("Code:")|force_escape }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input id="account-code" class="form-control {% if form.code.errors %} is-invalid {% endif %}" type="text" name="code" value="{{ form.code.value|default:"" }}" maxlength="5" required="required" />
|
||||||
|
<div id="account-code-error" class="invalid-feedback">{% if form.code.errors %}{{ form.code.errors.0 }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row form-group">
|
||||||
|
<label class="col-sm-2 col-form-label" for="account-title">{{ _("Title:")|force_escape }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input id="account-title" class="form-control {% if form.title.errors %} is-invalid {% endif %}" type="text" name="title" value="{{ form.title.value|default:"" }}" required="required" />
|
||||||
|
<div id="account-title-error" class="invalid-feedback">{% if form.title.errors %}{{ form.title.errors.0 }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<button class="btn btn-primary" type="submit">
|
||||||
|
<i class="fas fa-save"></i>
|
||||||
|
{{ _("Save")|force_escape }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -92,19 +92,19 @@ urlpatterns = [
|
|||||||
views.txn_sort, name="transactions.sort"),
|
views.txn_sort, name="transactions.sort"),
|
||||||
path("accounts",
|
path("accounts",
|
||||||
views.AccountListView.as_view(), name="accounts"),
|
views.AccountListView.as_view(), name="accounts"),
|
||||||
# TODO: To be done
|
|
||||||
path("accounts/create",
|
path("accounts/create",
|
||||||
mia_core_views.todo, name="accounts.create"),
|
views.account_form, name="accounts.create"),
|
||||||
# TODO: To be done
|
# TODO: To be done
|
||||||
path("accounts/store",
|
path("accounts/store",
|
||||||
mia_core_views.todo, name="accounts.store"),
|
mia_core_views.todo, name="accounts.store"),
|
||||||
|
path("api/accounts/all",
|
||||||
|
views.api_account_all, name="api.accounts.all"),
|
||||||
path("accounts/options",
|
path("accounts/options",
|
||||||
views.account_options, name="accounts.options"),
|
views.account_options, name="accounts.options"),
|
||||||
path("accounts/<account:account>",
|
path("accounts/<account:account>",
|
||||||
views.AccountView.as_view(), name="accounts.detail"),
|
views.AccountView.as_view(), name="accounts.detail"),
|
||||||
# TODO: To be done
|
|
||||||
path("accounts/<account:account>/edit",
|
path("accounts/<account:account>/edit",
|
||||||
mia_core_views.todo, name="accounts.edit"),
|
views.account_form, name="accounts.edit"),
|
||||||
# TODO: To be done
|
# TODO: To be done
|
||||||
path("accounts/<account:account>/update",
|
path("accounts/<account:account>/update",
|
||||||
mia_core_views.todo, name="accounts.update"),
|
mia_core_views.todo, name="accounts.update"),
|
||||||
|
@ -62,3 +62,22 @@ def validate_record_account_code(value):
|
|||||||
if child is not None:
|
if child is not None:
|
||||||
raise ValidationError(_("You cannot select a parent account."),
|
raise ValidationError(_("You cannot select a parent account."),
|
||||||
code="parent_account")
|
code="parent_account")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_account_code(value):
|
||||||
|
"""Validates an account code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (str): The account code.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: When the validation fails.
|
||||||
|
"""
|
||||||
|
if len(value) > 1:
|
||||||
|
try:
|
||||||
|
Account.objects.get(code=value[:-1])
|
||||||
|
except Account.DoesNotExist:
|
||||||
|
raise ValidationError(
|
||||||
|
_("The parent account of this code does not exist"),
|
||||||
|
code="parent_not_exist")
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ 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, Max, Count, BooleanField
|
from django.db.models import Sum, Case, When, F, Q, Max, Count, BooleanField
|
||||||
from django.db.models.functions import TruncMonth, Coalesce, Now
|
from django.db.models.functions import TruncMonth, Coalesce, Now
|
||||||
|
from django.forms import model_to_dict
|
||||||
from django.http import JsonResponse, HttpResponseRedirect, Http404
|
from django.http import JsonResponse, HttpResponseRedirect, Http404
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
@ -38,9 +39,10 @@ from django.views.generic import RedirectView, ListView, DetailView
|
|||||||
|
|
||||||
from mia_core.digest_auth import login_required
|
from mia_core.digest_auth import login_required
|
||||||
from mia_core.period import Period
|
from mia_core.period import Period
|
||||||
from mia_core.status import error_redirect
|
from mia_core.status import error_redirect, get_previous_post
|
||||||
from mia_core.utils import Pagination, get_multi_lingual_search, UrlBuilder, \
|
from mia_core.utils import Pagination, get_multi_lingual_search, UrlBuilder, \
|
||||||
strip_form, new_pk, PaginationException
|
strip_form, new_pk, PaginationException
|
||||||
|
from .forms import AccountForm
|
||||||
from .models import Record, Transaction, Account
|
from .models import Record, Transaction, Account
|
||||||
from .utils import get_cash_accounts, get_ledger_accounts, \
|
from .utils import get_cash_accounts, get_ledger_accounts, \
|
||||||
find_imbalanced, find_order_holes, fill_txn_from_post, \
|
find_imbalanced, find_order_holes, fill_txn_from_post, \
|
||||||
@ -1048,6 +1050,47 @@ class AccountView(DetailView):
|
|||||||
return self.request.resolver_match.kwargs["account"]
|
return self.request.resolver_match.kwargs["account"]
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
@login_required
|
||||||
|
def account_form(request, account=None):
|
||||||
|
"""The view to edit an accounting transaction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (HttpRequest): The request.
|
||||||
|
account (Account): The account.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HttpResponse: The response.
|
||||||
|
"""
|
||||||
|
previous_post = get_previous_post(request)
|
||||||
|
if previous_post is not None:
|
||||||
|
form = AccountForm(previous_post)
|
||||||
|
elif account is not None:
|
||||||
|
form = AccountForm({
|
||||||
|
"code": account.code,
|
||||||
|
"title": account.title,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
form = AccountForm()
|
||||||
|
return render(request, "accounting/account_form.html", {
|
||||||
|
"form": form,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_GET
|
||||||
|
@login_required
|
||||||
|
def api_account_all(request):
|
||||||
|
"""The API view to return all the accounts.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (HttpRequest): The request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JsonResponse: The response.
|
||||||
|
"""
|
||||||
|
return JsonResponse({x.code: x.title for x in Account.objects.all()})
|
||||||
|
|
||||||
|
|
||||||
@require_GET
|
@require_GET
|
||||||
@login_required
|
@login_required
|
||||||
def account_options(request):
|
def account_options(request):
|
||||||
|
@ -42,6 +42,23 @@ def error_redirect(request, url, form):
|
|||||||
return HttpResponseRedirect(str(UrlBuilder(url).query(s=status_id)))
|
return HttpResponseRedirect(str(UrlBuilder(url).query(s=status_id)))
|
||||||
|
|
||||||
|
|
||||||
|
def get_previous_post(request):
|
||||||
|
"""Retrieves the previously-stored status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (HttpRequest): The request.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The previously-stored status.
|
||||||
|
"""
|
||||||
|
if "s" not in request.GET:
|
||||||
|
return None
|
||||||
|
status = _retrieve(request, request.GET["s"])
|
||||||
|
if "form" not in status:
|
||||||
|
return None
|
||||||
|
return status["form"]
|
||||||
|
|
||||||
|
|
||||||
def retrieve_status(request):
|
def retrieve_status(request):
|
||||||
"""Retrieves the previously-stored status.
|
"""Retrieves the previously-stored status.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user