This commit is contained in:
Herculino Trotta
2024-10-09 00:31:21 -03:00
parent e78e4cc5e1
commit 3dde44b1cd
139 changed files with 4965 additions and 1004 deletions
+142
View File
@@ -0,0 +1,142 @@
from crispy_bootstrap5.bootstrap5 import Switch
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field, Column, Row
from django import forms
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import Account
from apps.accounts.models import AccountGroup
from apps.common.fields.forms.dynamic_select import (
DynamicModelMultipleChoiceField,
DynamicModelChoiceField,
)
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.transactions.models import TransactionCategory, TransactionTag
from apps.transactions.widgets import ArbitraryDecimalDisplayNumberInput
class AccountGroupForm(forms.ModelForm):
class Meta:
model = AccountGroup
fields = ["name"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"name",
)
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
class AccountForm(forms.ModelForm):
group = DynamicModelChoiceField(
model=AccountGroup,
required=False,
)
class Meta:
model = Account
fields = ["name", "group", "currency", "exchange_currency", "is_asset"]
widgets = {
"currency": TomSelect(),
"exchange_currency": TomSelect(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"name",
"group",
Switch("is_asset"),
"currency",
"exchange_currency",
)
if self.instance and self.instance.pk:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
),
),
)
else:
self.helper.layout.append(
FormActions(
NoClassSubmit(
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
),
),
)
class AccountBalanceForm(forms.Form):
account_id = forms.IntegerField(widget=forms.HiddenInput())
new_balance = forms.DecimalField(
max_digits=42, decimal_places=30, required=False, label=_("New balance")
)
category = DynamicModelChoiceField(
model=TransactionCategory,
required=False,
label=_("Category"),
)
tags = DynamicModelMultipleChoiceField(
model=TransactionTag,
to_field_name="name",
create_field="name",
required=False,
label=_("Tags"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.currency_suffix = self.initial.get("suffix", "")
self.currency_prefix = self.initial.get("prefix", "")
self.currency_decimal_places = self.initial.get("decimal_places", 2)
self.account_name = self.initial.get("account_name", "")
self.current_balance = self.initial.get("current_balance", 0)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"new_balance",
Row(
Column("category", css_class="form-group col-md-6 mb-0"),
Column("tags", css_class="form-group col-md-6 mb-0"),
css_class="form-row",
),
Field("account_id"),
)
self.fields["new_balance"].widget = ArbitraryDecimalDisplayNumberInput(
decimal_places=self.currency_decimal_places
)
AccountBalanceFormSet = forms.formset_factory(AccountBalanceForm, extra=0)
@@ -0,0 +1,28 @@
# Generated by Django 5.1.1 on 2024-10-04 03:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("currencies", "0004_exchangerate"),
]
operations = [
migrations.AddField(
model_name="account",
name="exchange_currency",
field=models.ForeignKey(
blank=True,
help_text="Default currency for exchange calculations",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="exchange_accounts",
to="currencies.currency",
verbose_name="Exchange Currency",
),
),
]
@@ -0,0 +1,41 @@
# Generated by Django 5.1.1 on 2024-10-08 23:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_account_exchange_currency"),
]
operations = [
migrations.CreateModel(
name="AccountGroup",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"name",
models.CharField(max_length=255, unique=True, verbose_name="Name"),
),
],
options={
"verbose_name": "Account Group",
"verbose_name_plural": "Account Groups",
"db_table": "account_groups",
},
),
migrations.AlterField(
model_name="account",
name="name",
field=models.CharField(max_length=255, verbose_name="Name"),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.2 on 2024-10-08 23:47
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0003_accountgroup_alter_account_name'),
]
operations = [
migrations.AddField(
model_name='account',
name='group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='accounts.accountgroup', verbose_name='Account Group'),
),
]
+32 -2
View File
@@ -1,15 +1,46 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from apps.transactions.models import Transaction
class AccountGroup(models.Model):
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
class Meta:
verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups")
db_table = "account_groups"
def __str__(self):
return self.name
class Account(models.Model):
name = models.CharField(max_length=40, verbose_name=_("Name"))
name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey(
AccountGroup,
on_delete=models.SET_NULL,
verbose_name=_("Account Group"),
blank=True,
null=True,
)
currency = models.ForeignKey(
"currencies.Currency",
verbose_name=_("Currency"),
on_delete=models.PROTECT,
related_name="accounts",
)
exchange_currency = models.ForeignKey(
"currencies.Currency",
verbose_name=_("Exchange Currency"),
on_delete=models.SET_NULL,
related_name="exchange_accounts",
null=True,
blank=True,
help_text=_("Default currency for exchange calculations"),
)
is_asset = models.BooleanField(
default=False,
verbose_name=_("Is an asset account?"),
@@ -17,7 +48,6 @@ class Account(models.Model):
"Asset accounts count towards your Net Worth, but not towards your month."
),
)
a = models.BigIntegerField
class Meta:
verbose_name = _("Account")
+35
View File
@@ -0,0 +1,35 @@
# urls.py
from django.urls import path
from apps.accounts import views
urlpatterns = [
path(
"account-reconciliation/",
views.account_reconciliation,
name="account_reconciliation",
),
path("accounts/", views.accounts_list, name="accounts_list"),
path("account/add/", views.account_add, name="account_add"),
path(
"account/<int:pk>/edit/",
views.account_edit,
name="account_edit",
),
path(
"account/<int:pk>/delete/",
views.account_delete,
name="account_delete",
),
path("account-groups/", views.account_groups_list, name="account_groups_list"),
path("account-groups/add/", views.account_group_add, name="account_group_add"),
path(
"account-groups/<int:pk>/edit/",
views.account_group_edit,
name="account_group_edit",
),
path(
"account-groups/<int:pk>/delete/",
views.account_group_delete,
name="account_group_delete",
),
]
-3
View File
@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.
+3
View File
@@ -0,0 +1,3 @@
from .accounts import *
from .account_groups import *
from .balance import *
+94
View File
@@ -0,0 +1,94 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountGroupForm
from apps.accounts.models import AccountGroup
from apps.common.decorators.htmx import only_htmx
@login_required
@require_http_methods(["GET"])
def account_groups_list(request):
account_groups = AccountGroup.objects.all().order_by("id")
return render(
request, "account_groups/pages/list.html", {"account_groups": account_groups}
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_group_add(request, **kwargs):
if request.method == "POST":
form = AccountGroupForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Account Group added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("account_groups_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountGroupForm()
return render(
request,
"account_groups/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_group_edit(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
if request.method == "POST":
form = AccountGroupForm(request.POST, instance=account_group)
if form.is_valid():
form.save()
messages.success(request, _("Account Group updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("account_groups_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountGroupForm(instance=account_group)
return render(
request,
"account_groups/fragments/edit.html",
{"form": form, "account_group": account_group},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_group_delete(request, pk):
account_group = get_object_or_404(AccountGroup, id=pk)
account_group.delete()
messages.success(request, _("Account Group deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Location": reverse("account_groups_list")},
)
+92
View File
@@ -0,0 +1,92 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import AccountForm
from apps.accounts.models import Account
from apps.common.decorators.htmx import only_htmx
@login_required
@require_http_methods(["GET"])
def accounts_list(request):
accounts = Account.objects.all().order_by("id")
return render(request, "accounts/pages/list.html", {"accounts": accounts})
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_add(request, **kwargs):
if request.method == "POST":
form = AccountForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, _("Account added successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("accounts_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountForm()
return render(
request,
"accounts/fragments/add.html",
{"form": form},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def account_edit(request, pk):
account = get_object_or_404(Account, id=pk)
if request.method == "POST":
form = AccountForm(request.POST, instance=account)
if form.is_valid():
form.save()
messages.success(request, _("Account updated successfully"))
return HttpResponse(
status=204,
headers={
"HX-Location": reverse("accounts_list"),
"HX-Trigger": "hide_offcanvas, toasts",
},
)
else:
form = AccountForm(instance=account)
return render(
request,
"accounts/fragments/edit.html",
{"form": form, "account": account},
)
@only_htmx
@login_required
@csrf_exempt
@require_http_methods(["DELETE"])
def account_delete(request, pk):
account = get_object_or_404(Account, id=pk)
account.delete()
messages.success(request, _("Account deleted successfully"))
return HttpResponse(
status=204,
headers={"HX-Location": reverse("accounts_list")},
)
+93
View File
@@ -0,0 +1,93 @@
from decimal import Decimal
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db import models
from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.accounts.forms import AccountBalanceFormSet
from apps.accounts.models import Account, Transaction
from apps.common.decorators.htmx import only_htmx
@only_htmx
@login_required
def account_reconciliation(request):
def get_account_balance(account):
income = Transaction.objects.filter(
account=account, type=Transaction.Type.INCOME, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
expense = Transaction.objects.filter(
account=account, type=Transaction.Type.EXPENSE, is_paid=True
).aggregate(total=models.Sum("amount"))["total"] or Decimal("0")
return income - expense
initial_data = [
{
"account_id": account.id,
"account_name": account.name,
"decimal_places": account.currency.decimal_places,
"suffix": account.currency.suffix,
"prefix": account.currency.prefix,
"current_balance": get_account_balance(account),
}
for account in Account.objects.all().select_related("currency")
]
if request.method == "POST":
formset = AccountBalanceFormSet(request.POST, initial=initial_data)
if formset.is_valid():
with transaction.atomic():
for form in formset:
if form.is_valid():
account_id = form.cleaned_data["account_id"]
new_balance = form.cleaned_data["new_balance"]
account = Account.objects.get(id=account_id)
category = form.cleaned_data["category"]
tags = form.cleaned_data.get("tags", [])
if new_balance is None:
continue
current_balance = get_account_balance(account)
difference = new_balance - current_balance
if difference != 0:
new_transaction = Transaction.objects.create(
account=account,
type=(
Transaction.Type.INCOME
if difference > 0
else Transaction.Type.EXPENSE
),
amount=abs(difference),
date=timezone.localdate(timezone.now()),
reference_date=timezone.localdate(
timezone.now()
).replace(day=1),
description=_("Balance reconciliation"),
is_paid=True,
category=category,
)
new_transaction.tags.set(tags)
messages.success(
request, _("Account balances have been reconciled successfully.")
)
return HttpResponse(
status=204,
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
)
else:
formset = AccountBalanceFormSet(initial=initial_data)
return render(
request, "accounts/fragments/account_reconciliation.html", {"form": formset}
)