Added the account form in the accounting application.

This commit is contained in:
依瑪貓 2020-08-09 11:52:12 +08:00
parent 008def227d
commit 724ba44a71
7 changed files with 392 additions and 6 deletions

View File

@ -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])

View 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;
}

View 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 %}

View File

@ -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"),

View File

@ -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")

View File

@ -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):

View File

@ -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.