diff --git a/accounting/forms.py b/accounting/forms.py index f977af3..4e48336 100644 --- a/accounting/forms.py +++ b/accounting/forms.py @@ -22,6 +22,8 @@ import re from django import forms from django.core.validators import RegexValidator +from django.db.models import Q, Max +from django.db.models.functions import Length from django.utils.translation import gettext as _ from .models import Account, Record @@ -317,7 +319,6 @@ class AccountForm(forms.Form): RegexValidator( regex="^[1-9]+$", message=_("You can only use numbers 1-9 in the code.")), - validate_account_code, ]) title = forms.CharField( max_length=128, @@ -328,6 +329,7 @@ class AccountForm(forms.Form): def __init__(self, *args, **kwargs): super(AccountForm, self).__init__(*args, **kwargs) + self.account = None @property def parent(self): @@ -335,3 +337,109 @@ class AccountForm(forms.Form): if code is None or len(code) < 2: return None return Account.objects.get(code=code[:-1]) + + def clean(self): + """Validates the form globally. + + Raises: + ValidationError: When the validation fails. + """ + errors = [] + validators = [self._validate_code_not_under_myself, + self._validate_code_unique, + self._validate_code_parent_exists, + self._validate_code_descendant_code_size] + for validator in validators: + try: + validator() + except forms.ValidationError as e: + errors.append(e) + if errors: + raise forms.ValidationError(errors) + + def _validate_code_not_under_myself(self): + """Validates whether the code is under itself. + + Raises: + ValidationError: When the validation fails. + """ + if self.account is None: + return + if "code" not in self.data: + return + if self.data["code"] == self.account.code: + return + if not self.data["code"].startswith(self.account.code): + return + error = forms.ValidationError( + _("You cannot set the code under itself."), + code="not_under_myself") + self.add_error("code", error) + raise error + + def _validate_code_unique(self): + """Validates whether the code is unique. + + Raises: + ValidationError: When the validation fails. + """ + if "code" not in self.data: + return + try: + if self.account is None: + Account.objects.get(code=self.data["code"]) + else: + Account.objects.get(Q(code=self.data["code"]), + ~Q(pk=self.account.pk)) + except Account.DoesNotExist: + return + error = forms.ValidationError(_("This code is already in use."), + code="code_unique") + self.add_error("code", error) + raise error + + def _validate_code_parent_exists(self): + """Validates whether the parent account exists. + + Raises: + ValidationError: When the validation fails. + """ + if "code" not in self.data: + return + if len(self.data["code"]) < 2: + return + try: + Account.objects.get(code=self.data["code"][:-1]) + except Account.DoesNotExist: + error = forms.ValidationError( + _("The parent account of this code does not exist."), + code="code_unique") + self.add_error("code", error) + raise error + return + + def _validate_code_descendant_code_size(self): + """Validates whether the codes of the descendants will be too long. + + Raises: + ValidationError: When the validation fails. + """ + if "code" not in self.data: + return + if self.account is None: + return + cur_max_len = Account.objects\ + .filter(Q(code__startswith=self.account.code), + ~Q(pk=self.account.pk))\ + .aggregate(max_len=Max(Length("code")))["max_len"] + if cur_max_len is None: + return True + new_max_len = cur_max_len - len(self.account.code)\ + + len(self.data["code"]) + if new_max_len <= 5: + return + error = forms.ValidationError( + _("The descendant account codes will be too long (max. 5)."), + code="descendant_code_size") + self.add_error("code", error) + raise error diff --git a/accounting/models.py b/accounting/models.py index af43c01..3a8463a 100644 --- a/accounting/models.py +++ b/accounting/models.py @@ -24,7 +24,8 @@ from django.db import models, transaction from django.db.models import Q from django.urls import reverse -from mia_core.utils import get_multi_lingual_attr, set_multi_lingual_attr +from mia_core.utils import get_multi_lingual_attr, set_multi_lingual_attr, \ + new_pk class Account(DirtyFieldsMixin, models.Model): @@ -70,6 +71,19 @@ class Account(DirtyFieldsMixin, models.Model): """Returns the string representation of this account.""" return self.code.__str__() + " " + self.title + def save(self, current_user=None, force_insert=False, force_update=False, + using=None, update_fields=None): + self.parent = None if len(self.code) == 1\ + else Account.objects.get(code=self.code[:-1]) + if self.pk is None: + self.pk = new_pk(Account) + self.created_by = current_user + self.updated_by = current_user + with transaction.atomic(): + super(Account, self).save( + force_insert=force_insert, force_update=force_update, + using=using, update_fields=update_fields) + class Meta: db_table = "accounting_accounts" diff --git a/accounting/urls.py b/accounting/urls.py index 59d6b04..f6bcbc6 100644 --- a/accounting/urls.py +++ b/accounting/urls.py @@ -94,9 +94,8 @@ urlpatterns = [ views.AccountListView.as_view(), name="accounts"), path("accounts/create", views.account_form, name="accounts.create"), - # TODO: To be done path("accounts/store", - mia_core_views.todo, name="accounts.store"), + views.account_store, name="accounts.store"), path("api/accounts", views.api_account_list, name="api.accounts"), path("api/accounts/options", @@ -105,9 +104,8 @@ urlpatterns = [ views.AccountView.as_view(), name="accounts.detail"), path("accounts//edit", views.account_form, name="accounts.edit"), - # TODO: To be done path("accounts//update", - mia_core_views.todo, name="accounts.update"), + views.account_store, name="accounts.update"), # TODO: To be done path("accounts//delete", mia_core_views.todo, name="accounts.delete"), diff --git a/accounting/views.py b/accounting/views.py index c30503b..5023352 100644 --- a/accounting/views.py +++ b/accounting/views.py @@ -1053,11 +1053,48 @@ def account_form(request, account=None): }) else: form = AccountForm() + form.account = account return render(request, "accounting/account_form.html", { "form": form, }) +@require_POST +@login_required +def account_store(request, account=None): + """The view to edit an accounting transaction. + + Args: + request (HttpRequest): The request. + account (Account): The account. + + Returns: + HttpResponseRedirect: The response. + """ + post = request.POST.dict() + strip_post(post) + form = AccountForm(post) + form.account = account + if not form.is_valid(): + if account is None: + url = reverse("accounting:accounts.create") + else: + url = reverse("accounting:accounts.edit", args=(account,)) + return stored_post.error_redirect(request, url, post) + if account is None: + account = Account() + account.code = form["code"].value() + account.title = form["title"].value() + if not account.is_dirty(): + message = gettext_noop("This account was not modified.") + else: + account.save(current_user=request.user) + message = gettext_noop("This account was saved successfully.") + messages.success(request, message) + return HttpResponseRedirect(reverse("accounting:accounts.detail", + args=(account,))) + + @require_GET @login_required def api_account_list(request):