mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-06-07 15:12:51 +02:00
changes
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .accounts import *
|
||||
from .account_groups import *
|
||||
from .balance import *
|
||||
@@ -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")},
|
||||
)
|
||||
@@ -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")},
|
||||
)
|
||||
@@ -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}
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
from functools import wraps
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
def only_htmx(view):
|
||||
@wraps(view)
|
||||
def _view(request, *args, **kwargs):
|
||||
if not request.META.get("HTTP_HX_REQUEST"):
|
||||
raise PermissionDenied
|
||||
|
||||
return view(request, *args, **kwargs)
|
||||
|
||||
return _view
|
||||
@@ -0,0 +1,205 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
|
||||
|
||||
# class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
# def __init__(self, model, *args, **kwargs):
|
||||
# self.model = model
|
||||
# self.queryset = kwargs.pop("queryset", model.objects.all())
|
||||
# super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||
# self._created_instance = None
|
||||
#
|
||||
# self.widget = TomSelect(clear_button=True, create=True)
|
||||
#
|
||||
# def to_python(self, value):
|
||||
# if value in self.empty_values:
|
||||
# return None
|
||||
# try:
|
||||
# key = self.to_field_name or "pk"
|
||||
# return self.model.objects.get(**{key: value})
|
||||
# except (ValueError, TypeError, self.model.DoesNotExist):
|
||||
# return value # Return the raw value; we'll handle creation in clean()
|
||||
#
|
||||
# def clean(self, value):
|
||||
# if isinstance(value, self.model):
|
||||
# return value
|
||||
# if isinstance(value, str):
|
||||
# try:
|
||||
# if value.isdigit():
|
||||
# return self.model.objects.get(id=value)
|
||||
# else:
|
||||
# raise self.model.DoesNotExist
|
||||
# except self.model.DoesNotExist:
|
||||
# try:
|
||||
# with transaction.atomic():
|
||||
# instance = self.model.objects.create(name=value)
|
||||
# self._created_instance = instance
|
||||
# return instance
|
||||
# except Exception as e:
|
||||
# raise ValidationError(
|
||||
# self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
# )
|
||||
# return super().clean(value)
|
||||
#
|
||||
# def bound_data(self, data, initial):
|
||||
# if self._created_instance and isinstance(data, str):
|
||||
# if data == self._created_instance.name:
|
||||
# return self._created_instance.pk
|
||||
# return super().bound_data(data, initial)
|
||||
|
||||
|
||||
class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
def __init__(self, model, *args, **kwargs):
|
||||
self.model = model
|
||||
self.queryset = kwargs.pop("queryset", model.objects.all())
|
||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||
self._created_instance = None
|
||||
|
||||
self.widget = TomSelect(clear_button=True, create=True)
|
||||
|
||||
def to_python(self, value):
|
||||
if value in self.empty_values:
|
||||
return None
|
||||
try:
|
||||
key = self.to_field_name or "pk"
|
||||
return self.model.objects.get(**{key: value})
|
||||
except (ValueError, TypeError, self.model.DoesNotExist):
|
||||
return value # Return the raw value; we'll handle creation in clean()
|
||||
|
||||
def clean(self, value):
|
||||
if value in self.empty_values:
|
||||
if self.required:
|
||||
raise ValidationError(self.error_messages["required"], code="required")
|
||||
return None
|
||||
|
||||
if isinstance(value, self.model):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
if self.required:
|
||||
raise ValidationError(
|
||||
self.error_messages["required"], code="required"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
if value.isdigit():
|
||||
return self.model.objects.get(id=value)
|
||||
else:
|
||||
raise self.model.DoesNotExist
|
||||
except self.model.DoesNotExist:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance = self.model.objects.create(name=value)
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
return super().clean(value)
|
||||
|
||||
def bound_data(self, data, initial):
|
||||
if self._created_instance and isinstance(data, str):
|
||||
if data == self._created_instance.name:
|
||||
return self._created_instance.pk
|
||||
return super().bound_data(data, initial)
|
||||
|
||||
|
||||
class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
"""
|
||||
A custom ModelMultipleChoiceField that creates new entries if they don't exist.
|
||||
|
||||
This field allows users to select multiple existing options or add new ones.
|
||||
If a selected option doesn't exist, it will be created in the database.
|
||||
|
||||
Attributes:
|
||||
create_field (str): The name of the field to use when creating new instances.
|
||||
"""
|
||||
|
||||
def __init__(self, model, **kwargs):
|
||||
"""
|
||||
Initialize the CreateIfNotExistsModelMultipleChoiceField.
|
||||
|
||||
Args:
|
||||
create_field (str): The name of the field to use when creating new instances.
|
||||
*args: Variable length argument list.
|
||||
**kwargs: Arbitrary keyword arguments.
|
||||
"""
|
||||
self.create_field = kwargs.pop("create_field", None)
|
||||
if not self.create_field:
|
||||
raise ValueError("The 'create_field' parameter is required.")
|
||||
self.model = model
|
||||
self.queryset = kwargs.pop("queryset", model.objects.all())
|
||||
super().__init__(queryset=self.queryset, **kwargs)
|
||||
|
||||
self.widget = TomSelectMultiple(
|
||||
remove_button=True, clear_button=True, create=True, checkboxes=True
|
||||
)
|
||||
|
||||
def _create_new_instance(self, value):
|
||||
"""
|
||||
Create a new instance of the model with the given value.
|
||||
|
||||
Args:
|
||||
value: The value to use for creating the new instance.
|
||||
|
||||
Returns:
|
||||
Model: The newly created model instance.
|
||||
|
||||
Raises:
|
||||
ValidationError: If there's an error creating the new instance.
|
||||
"""
|
||||
try:
|
||||
with transaction.atomic():
|
||||
new_instance = self.queryset.model(**{self.create_field: value})
|
||||
new_instance.full_clean()
|
||||
new_instance.save()
|
||||
return new_instance
|
||||
except Exception as e:
|
||||
raise ValidationError(f"Error creating new instance: {str(e)}")
|
||||
|
||||
def clean(self, value):
|
||||
"""
|
||||
Clean and validate the field value.
|
||||
|
||||
This method checks if each selected choice exists in the database.
|
||||
If a choice doesn't exist, it creates a new instance of the model.
|
||||
|
||||
Args:
|
||||
value (list): List of selected values.
|
||||
|
||||
Returns:
|
||||
list: A list containing all selected and newly created model instances.
|
||||
|
||||
Raises:
|
||||
ValidationError: If there's an error during the cleaning process.
|
||||
"""
|
||||
if not value:
|
||||
return []
|
||||
|
||||
print(value)
|
||||
|
||||
string_values = set(str(v) for v in value)
|
||||
existing_objects = list(
|
||||
self.queryset.filter(**{f"{self.create_field}__in": string_values})
|
||||
)
|
||||
existing_values = set(
|
||||
str(getattr(obj, self.create_field)) for obj in existing_objects
|
||||
)
|
||||
|
||||
new_values = string_values - existing_values
|
||||
new_objects = []
|
||||
|
||||
for new_value in new_values:
|
||||
try:
|
||||
new_objects.append(self._create_new_instance(new_value))
|
||||
except ValidationError as e:
|
||||
raise ValidationError(f"Error creating '{new_value}': {str(e)}")
|
||||
|
||||
return existing_objects + new_objects
|
||||
@@ -0,0 +1,16 @@
|
||||
import zoneinfo
|
||||
from django.utils import timezone
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
class TimezoneMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
tz = request.COOKIES.get("mytz")
|
||||
if tz:
|
||||
timezone.activate(zoneinfo.ZoneInfo(tz))
|
||||
else:
|
||||
timezone.activate(zoneinfo.ZoneInfo("UTC"))
|
||||
return self.get_response(request)
|
||||
@@ -0,0 +1,41 @@
|
||||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
# https://github.com/valerymelou/django-active-link/
|
||||
@register.simple_tag(takes_context=True)
|
||||
def active_link(
|
||||
context, views="", namespaces="", css_class="active", inactive_class=""
|
||||
):
|
||||
"""
|
||||
Renders the given CSS class if the request path matches the path of the view.
|
||||
:param context: The context where the tag was called. Used to access the request object.
|
||||
:param views: The name of the view or views separated by || (include namespaces if any).
|
||||
:param namespaces: The name of the namespace or namespaces separated by ||.
|
||||
:param css_class: The CSS class to render.
|
||||
:param inactive_class: The CSS class to render if the views is not active.
|
||||
:return:
|
||||
"""
|
||||
request = context.get("request")
|
||||
if request is None:
|
||||
# Can't work without the request object.
|
||||
return ""
|
||||
|
||||
if views:
|
||||
views = views.split("||")
|
||||
current_view = request.resolver_match.view_name
|
||||
|
||||
if current_view in views:
|
||||
return css_class
|
||||
elif namespaces:
|
||||
namespaces = namespaces.split("||")
|
||||
current_namespace = request.resolver_match.namespaces
|
||||
|
||||
if any(item in current_namespace for item in namespaces):
|
||||
return css_class
|
||||
else:
|
||||
return ""
|
||||
|
||||
return inactive_class
|
||||
@@ -0,0 +1,60 @@
|
||||
from django import template
|
||||
from django.utils import timezone
|
||||
from datetime import date
|
||||
from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||
|
||||
from django.utils.formats import date_format
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter(name="customnaturaldate")
|
||||
def naturaldate(value):
|
||||
if not isinstance(value, date):
|
||||
return value
|
||||
|
||||
today = timezone.localdate(timezone.now())
|
||||
delta = value - today
|
||||
|
||||
if delta.days == 0:
|
||||
return _("today")
|
||||
elif delta.days == 1:
|
||||
return _("tomorrow")
|
||||
elif delta.days == -1:
|
||||
return _("yesterday")
|
||||
elif -7 <= delta.days < 0:
|
||||
return _("last 7 days")
|
||||
elif 0 < delta.days <= 7:
|
||||
return _("in the next 7 days")
|
||||
elif delta.days < -365:
|
||||
years = abs(delta.days) // 365
|
||||
return ngettext_lazy("%(years)s year ago", "%(years)s years ago", years) % {
|
||||
"years": years
|
||||
}
|
||||
elif delta.days < -30:
|
||||
months = abs(delta.days) // 30
|
||||
return ngettext_lazy(
|
||||
"%(months)s month ago", "%(months)s months ago", months
|
||||
) % {"months": months}
|
||||
elif delta.days < -7:
|
||||
weeks = abs(delta.days) // 7
|
||||
return ngettext_lazy("%(weeks)s week ago", "%(weeks)s weeks ago", weeks) % {
|
||||
"weeks": weeks
|
||||
}
|
||||
elif delta.days > 365:
|
||||
years = delta.days // 365
|
||||
return ngettext_lazy("in %(years)s year", "in %(years)s years", years) % {
|
||||
"years": years
|
||||
}
|
||||
elif delta.days > 30:
|
||||
months = delta.days // 30
|
||||
return ngettext_lazy("in %(months)s month", "in %(months)s months", months) % {
|
||||
"months": months
|
||||
}
|
||||
elif delta.days > 7:
|
||||
weeks = delta.days // 7
|
||||
return ngettext_lazy("in %(weeks)s week", "in %(weeks)s weeks", weeks) % {
|
||||
"weeks": weeks
|
||||
}
|
||||
else:
|
||||
return date_format(value, format="DATE_FORMAT")
|
||||
@@ -0,0 +1,13 @@
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def site_title(value):
|
||||
value = value.strip()
|
||||
if value:
|
||||
return f"{value} {settings.TITLE_SEPARATOR or '::'} {settings.SITE_TITLE or "SITE_TITLE NOT SET"}"
|
||||
else:
|
||||
return settings.SITE_TITLE or "SITE_TITLE NOT SET"
|
||||
@@ -0,0 +1,65 @@
|
||||
from crispy_forms.layout import BaseInput
|
||||
|
||||
|
||||
class NoClassSubmit(BaseInput):
|
||||
"""
|
||||
Used to create a Submit button descriptor for the {% crispy %} template tag.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
template: str
|
||||
The default template which this Layout Object will be rendered
|
||||
with.
|
||||
field_classes: str
|
||||
CSS classes to be applied to the ``<input>``.
|
||||
input_type: str
|
||||
The ``type`` attribute of the ``<input>``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
The name attribute of the button.
|
||||
value : str
|
||||
The value attribute of the button.
|
||||
css_id : str, optional
|
||||
A custom DOM id for the layout object. If not provided the name
|
||||
argument is slugified and turned into the id for the submit button.
|
||||
By default None.
|
||||
css_class : str, optional
|
||||
Additional CSS classes to be applied to the ``<input>``. By default
|
||||
None.
|
||||
template : str, optional
|
||||
Overrides the default template, if provided. By default None.
|
||||
**kwargs : dict, optional
|
||||
Additional attributes are passed to `flatatt` and converted into
|
||||
key="value", pairs. These attributes are added to the ``<input>``.
|
||||
|
||||
Examples
|
||||
--------
|
||||
Note: ``form`` arg to ``render()`` is not required for ``BaseInput``
|
||||
inherited objects.
|
||||
|
||||
>>> submit = Submit('Search the Site', 'search this site')
|
||||
>>> submit.render("", "", Context())
|
||||
'<input type="submit" name="search-the-site" value="search this site" '
|
||||
'class="btn btn-primary" id="submit-id-search-the-site"/>'
|
||||
|
||||
>>> submit = Submit('Search the Site', 'search this site', css_id="custom-id",
|
||||
css_class="custom class", my_attr=True, data="my-data")
|
||||
>>> submit.render("", "", Context())
|
||||
'<input type="submit" name="search-the-site" value="search this site" '
|
||||
'class="btn btn-primary custom class" id="custom-id" data="my-data" my-attr/>'
|
||||
|
||||
Usually you will not call the render method on the object directly. Instead
|
||||
add it to your ``Layout`` manually or use the `add_input` method::
|
||||
|
||||
class ExampleForm(forms.Form):
|
||||
[...]
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.helper = FormHelper()
|
||||
self.helper.add_input(Submit('submit', 'Submit'))
|
||||
"""
|
||||
|
||||
input_type = "submit"
|
||||
field_classes = ""
|
||||
@@ -0,0 +1,54 @@
|
||||
from django.forms import widgets, SelectMultiple
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class TomSelect(widgets.Select):
|
||||
def __init__(
|
||||
self,
|
||||
attrs=None,
|
||||
remove_button=False,
|
||||
remove_button_text=_("Remove"),
|
||||
create=False,
|
||||
create_text=_("Add"),
|
||||
clear_button=True,
|
||||
clear_text=_("Clear"),
|
||||
no_results_text=_("No results..."),
|
||||
checkboxes=False,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
super().__init__(attrs, *args, **kwargs)
|
||||
self.remove_button = remove_button
|
||||
self.remove_button_text = remove_button_text
|
||||
self.clear_button = clear_button
|
||||
self.create = create
|
||||
self.create_text = create_text
|
||||
self.clear_text = clear_text
|
||||
self.no_results_text = no_results_text
|
||||
self.checkboxes = checkboxes
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
attrs["data-txt-no-results"] = self.no_results_text
|
||||
|
||||
if self.remove_button:
|
||||
attrs["data-remove-button"] = "true"
|
||||
attrs["data-txt-remove"] = self.remove_button_text
|
||||
|
||||
if self.create:
|
||||
attrs["data-create"] = "true"
|
||||
attrs["data-txt-create"] = self.create_text
|
||||
|
||||
if self.clear_button:
|
||||
attrs["data-clear-button"] = "true"
|
||||
attrs["data-txt-clear"] = self.clear_text
|
||||
|
||||
if self.checkboxes:
|
||||
attrs["data-checkboxes"] = "true"
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class TomSelectMultiple(SelectMultiple, TomSelect):
|
||||
pass
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
|
||||
|
||||
@admin.register(Currency)
|
||||
@@ -9,3 +9,6 @@ class CurrencyAdmin(admin.ModelAdmin):
|
||||
if db_field.name == "suffix" or db_field.name == "prefix":
|
||||
kwargs["strip"] = False
|
||||
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
||||
|
||||
|
||||
admin.site.register(ExchangeRate)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout
|
||||
from django import forms
|
||||
from django.forms import CharField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class CurrencyForm(forms.ModelForm):
|
||||
prefix = CharField(strip=False, required=False)
|
||||
suffix = CharField(strip=False, required=False)
|
||||
|
||||
class Meta:
|
||||
model = Currency
|
||||
fields = ["name", "decimal_places", "prefix", "suffix", "code"]
|
||||
|
||||
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(
|
||||
"code", "name", "decimal_places", "prefix", "suffix"
|
||||
)
|
||||
|
||||
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"
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
# 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 = [
|
||||
("currencies", "0003_alter_currency_decimal_places"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ExchangeRate",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"rate",
|
||||
models.DecimalField(
|
||||
decimal_places=30, max_digits=42, verbose_name="Exchange Rate"
|
||||
),
|
||||
),
|
||||
("date", models.DateTimeField(verbose_name="Date and Time")),
|
||||
(
|
||||
"from_currency",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="from_exchange_rates",
|
||||
to="currencies.currency",
|
||||
verbose_name="From Currency",
|
||||
),
|
||||
),
|
||||
(
|
||||
"to_currency",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="to_exchange_rates",
|
||||
to="currencies.currency",
|
||||
verbose_name="To Currency",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Exchange Rate",
|
||||
"verbose_name_plural": "Exchange Rates",
|
||||
"unique_together": {("from_currency", "to_currency", "date")},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -20,3 +20,30 @@ class Currency(models.Model):
|
||||
class Meta:
|
||||
verbose_name = _("Currency")
|
||||
verbose_name_plural = _("Currencies")
|
||||
|
||||
|
||||
class ExchangeRate(models.Model):
|
||||
from_currency = models.ForeignKey(
|
||||
Currency,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="from_exchange_rates",
|
||||
verbose_name=_("From Currency"),
|
||||
)
|
||||
to_currency = models.ForeignKey(
|
||||
Currency,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="to_exchange_rates",
|
||||
verbose_name=_("To Currency"),
|
||||
)
|
||||
rate = models.DecimalField(
|
||||
max_digits=42, decimal_places=30, verbose_name=_("Exchange Rate")
|
||||
)
|
||||
date = models.DateTimeField(verbose_name=_("Date and Time"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Exchange Rate")
|
||||
verbose_name_plural = _("Exchange Rates")
|
||||
unique_together = ("from_currency", "to_currency", "date")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.from_currency.code} to {self.to_currency.code} on {self.date}"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("currencies/", views.currency_list, name="currencies_list"),
|
||||
path("currencies/add/", views.currency_add, name="currency_add"),
|
||||
path(
|
||||
"currencies/<int:pk>/edit/",
|
||||
views.currency_edit,
|
||||
name="currency_edit",
|
||||
),
|
||||
path(
|
||||
"currencies/<int:pk>/delete/",
|
||||
views.currency_delete,
|
||||
name="currency_delete",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
from django.db.models import Func, F, Value, DurationField, Case, When, DecimalField, Q
|
||||
from django.db.models.functions import Abs, Extract
|
||||
from django.utils import timezone
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.currencies.models import ExchangeRate
|
||||
|
||||
|
||||
def convert(amount, from_currency, to_currency, date=None):
|
||||
if from_currency == to_currency:
|
||||
return amount
|
||||
|
||||
if date is None:
|
||||
date = timezone.localtime(timezone.now())
|
||||
|
||||
try:
|
||||
exchange_rate = (
|
||||
ExchangeRate.objects.filter(
|
||||
Q(from_currency=from_currency, to_currency=to_currency)
|
||||
| Q(from_currency=to_currency, to_currency=from_currency)
|
||||
)
|
||||
.annotate(
|
||||
date_diff=Func(
|
||||
Extract(F("date") - Value(date), "epoch"), function="ABS"
|
||||
),
|
||||
effective_rate=Case(
|
||||
When(from_currency=from_currency, then=F("rate")),
|
||||
default=1 / F("rate"),
|
||||
output_field=DecimalField(),
|
||||
),
|
||||
)
|
||||
.order_by("date_diff")
|
||||
.first()
|
||||
)
|
||||
|
||||
if exchange_rate is None:
|
||||
return None
|
||||
|
||||
return amount * exchange_rate.effective_rate
|
||||
except ExchangeRate.DoesNotExist:
|
||||
return None
|
||||
@@ -1,3 +1,92 @@
|
||||
from django.shortcuts import render
|
||||
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
|
||||
|
||||
# Create your views here.
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.currencies.forms import CurrencyForm
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def currency_list(request):
|
||||
currencies = Currency.objects.all().order_by("id")
|
||||
return render(request, "currencies/pages/list.html", {"currencies": currencies})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def currency_add(request, **kwargs):
|
||||
if request.method == "POST":
|
||||
form = CurrencyForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Currency added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("currencies_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = CurrencyForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"currencies/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def currency_edit(request, pk):
|
||||
currency = get_object_or_404(Currency, id=pk)
|
||||
|
||||
if request.method == "POST":
|
||||
form = CurrencyForm(request.POST, instance=currency)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Currency updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("currencies_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = CurrencyForm(instance=currency)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"currencies/fragments/edit.html",
|
||||
{"form": form, "currency": currency},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def currency_delete(request, pk):
|
||||
currency = get_object_or_404(Currency, id=pk)
|
||||
|
||||
currency.delete()
|
||||
|
||||
messages.success(request, _("Currency deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Location": reverse("currencies_list")},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class MonthlyOverviewConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.monthly_overview"
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,27 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("monthly/", views.index, name="monthly_index"),
|
||||
path(
|
||||
"monthly/<int:month>/<int:year>/transactions/list/",
|
||||
views.transactions_list,
|
||||
name="monthly_transactions_list",
|
||||
),
|
||||
path(
|
||||
"monthly/<int:month>/<int:year>/",
|
||||
views.monthly_overview,
|
||||
name="monthly_overview",
|
||||
),
|
||||
path(
|
||||
"monthly/<int:month>/<int:year>/summary/",
|
||||
views.monthly_summary,
|
||||
name="monthly_summary",
|
||||
),
|
||||
path(
|
||||
"available_dates/",
|
||||
views.month_year_picker,
|
||||
name="available_dates",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
from .ui import *
|
||||
from .main import *
|
||||
@@ -0,0 +1,309 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import (
|
||||
Case,
|
||||
When,
|
||||
Value,
|
||||
IntegerField,
|
||||
Sum,
|
||||
Q,
|
||||
)
|
||||
from django.shortcuts import render, redirect
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.functions.dates import remaining_days_in_month
|
||||
from apps.transactions.filters import TransactionsFilter
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
now = timezone.localdate(timezone.now())
|
||||
|
||||
return redirect(to="monthly_overview", month=now.month, year=now.year)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_overview(request, month: int, year: int):
|
||||
transactions = (
|
||||
Transaction.objects.all()
|
||||
.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.order_by("date", "id")
|
||||
.select_related()
|
||||
)
|
||||
|
||||
if month < 1 or month > 12:
|
||||
from django.http import Http404
|
||||
|
||||
raise Http404("Month is out of range")
|
||||
|
||||
next_month = 1 if month == 12 else month + 1
|
||||
next_year = year + 1 if next_month == 1 and month == 12 else year
|
||||
|
||||
previous_month = 12 if month == 1 else month - 1
|
||||
previous_year = year - 1 if previous_month == 12 and month == 1 else year
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"monthly_overview/pages/overview.html",
|
||||
context={
|
||||
"month": month,
|
||||
"year": year,
|
||||
"next_month": next_month,
|
||||
"next_year": next_year,
|
||||
"previous_month": previous_month,
|
||||
"previous_year": previous_year,
|
||||
"filter": f,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transactions_list(request, month: int, year: int):
|
||||
today = timezone.localdate(timezone.now())
|
||||
yesterday = today - timezone.timedelta(days=1)
|
||||
tomorrow = today + timezone.timedelta(days=1)
|
||||
|
||||
f = TransactionsFilter(request.GET)
|
||||
transactions_filtered = (
|
||||
f.qs.filter()
|
||||
.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.annotate(
|
||||
date_order=Case(
|
||||
When(date=tomorrow, then=Value(0)),
|
||||
When(date=today, then=Value(1)),
|
||||
When(date=yesterday, then=Value(2)),
|
||||
default=Value(3),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
)
|
||||
.order_by("date_order", "date", "id")
|
||||
.prefetch_related(
|
||||
"account",
|
||||
"category",
|
||||
"tags",
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
)
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"monthly_overview/fragments/list.html",
|
||||
context={"transactions": transactions_filtered},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def monthly_summary(request, month: int, year: int):
|
||||
# Helper function to calculate sums for different transaction types
|
||||
def calculate_sum(transaction_type, is_paid):
|
||||
return (
|
||||
base_queryset.filter(type=transaction_type, is_paid=is_paid)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__suffix",
|
||||
"account__currency__prefix",
|
||||
"account__currency__decimal_places",
|
||||
)
|
||||
.annotate(total=Sum("amount"))
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
# Helper function to format currency sums
|
||||
def format_currency_sum(queryset):
|
||||
return [
|
||||
{
|
||||
"currency": item["account__currency__name"],
|
||||
"suffix": item["account__currency__suffix"],
|
||||
"prefix": item["account__currency__prefix"],
|
||||
"decimal_places": item["account__currency__decimal_places"],
|
||||
"amount": item["total"],
|
||||
}
|
||||
for item in queryset
|
||||
]
|
||||
|
||||
# Calculate totals
|
||||
def calculate_total(income, expenses):
|
||||
totals = {}
|
||||
|
||||
# Process income
|
||||
for item in income:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["total"]
|
||||
|
||||
# Subtract expenses
|
||||
for item in expenses:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) - item["total"]
|
||||
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
(
|
||||
item["account__currency__suffix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"prefix": next(
|
||||
(
|
||||
item["account__currency__prefix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"decimal_places": next(
|
||||
(
|
||||
item["account__currency__decimal_places"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
2,
|
||||
),
|
||||
"amount": amount,
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
# Calculate total final
|
||||
def sum_totals(total1, total2):
|
||||
totals = {}
|
||||
for item in total1 + total2:
|
||||
currency = item["currency"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["amount"]
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
item["suffix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"prefix": next(
|
||||
item["prefix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"decimal_places": next(
|
||||
item["decimal_places"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"amount": amount,
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
# Base queryset with all required filters
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year, reference_date__month=month, account__is_asset=False
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
|
||||
# Calculate sums for different transaction types
|
||||
paid_income = calculate_sum(Transaction.Type.INCOME, True)
|
||||
projected_income = calculate_sum(Transaction.Type.INCOME, False)
|
||||
paid_expenses = calculate_sum(Transaction.Type.EXPENSE, True)
|
||||
projected_expenses = calculate_sum(Transaction.Type.EXPENSE, False)
|
||||
|
||||
total_current = calculate_total(paid_income, paid_expenses)
|
||||
total_projected = calculate_total(projected_income, projected_expenses)
|
||||
|
||||
total_final = sum_totals(total_current, total_projected)
|
||||
|
||||
# Calculate daily spending allowance
|
||||
remaining_days = remaining_days_in_month(
|
||||
month=month, year=year, current_date=timezone.localdate(timezone.now())
|
||||
)
|
||||
if (
|
||||
timezone.localdate(timezone.now()).month == month
|
||||
and timezone.localdate(timezone.now()).year == year
|
||||
):
|
||||
daily_spending_allowance = [
|
||||
{
|
||||
"currency": item["currency"],
|
||||
"suffix": item["suffix"],
|
||||
"prefix": item["prefix"],
|
||||
"decimal_places": item["decimal_places"],
|
||||
"amount": (
|
||||
amount
|
||||
if (amount := item["amount"] / remaining_days) > 0
|
||||
else Decimal("0")
|
||||
),
|
||||
}
|
||||
for item in total_final
|
||||
]
|
||||
else:
|
||||
daily_spending_allowance = []
|
||||
|
||||
# Construct the response dictionary
|
||||
data = {
|
||||
"paid_income": format_currency_sum(paid_income),
|
||||
"projected_income": format_currency_sum(projected_income),
|
||||
"paid_expenses": format_currency_sum(paid_expenses),
|
||||
"projected_expenses": format_currency_sum(projected_expenses),
|
||||
"total_current": total_current,
|
||||
"total_projected": total_projected,
|
||||
"total_final": total_final,
|
||||
"daily_spending_allowance": daily_spending_allowance,
|
||||
}
|
||||
|
||||
# account_summary = (
|
||||
# Account.objects.annotate(
|
||||
# balance=Coalesce(
|
||||
# Sum(
|
||||
# Case(
|
||||
# When(
|
||||
# transaction__type=Transaction.Type.INCOME,
|
||||
# transaction__is_paid=True,
|
||||
# transaction__reference_date__year=year,
|
||||
# transaction__reference_date__month=month,
|
||||
# then=F("transaction__amount"),
|
||||
# ),
|
||||
# When(
|
||||
# transaction__type=Transaction.Type.EXPENSE,
|
||||
# transaction__is_paid=True,
|
||||
# transaction__reference_date__year=year,
|
||||
# transaction__reference_date__month=month,
|
||||
# then=-F("transaction__amount"),
|
||||
# ),
|
||||
# output_field=DecimalField(),
|
||||
# )
|
||||
# ),
|
||||
# Decimal(0),
|
||||
# )
|
||||
# )
|
||||
# .values(
|
||||
# "id",
|
||||
# "name",
|
||||
# "balance",
|
||||
# "currency__prefix",
|
||||
# "currency__suffix",
|
||||
# "currency__decimal_places",
|
||||
# )
|
||||
# .order_by("id")
|
||||
# )
|
||||
|
||||
return render(
|
||||
request,
|
||||
"monthly_overview/fragments/monthly_summary.html",
|
||||
context={"totals": data},
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db.models import (
|
||||
Count,
|
||||
)
|
||||
from django.db.models.functions import ExtractYear, ExtractMonth
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
def month_year_picker(request):
|
||||
# Get current month and year from request or use current date
|
||||
current_date = timezone.localdate(timezone.now())
|
||||
current_month = int(request.GET.get("month", current_date.month))
|
||||
current_year = int(request.GET.get("year", current_date.year))
|
||||
|
||||
# Set start and end dates
|
||||
start_date = timezone.datetime(current_year - 1, 1, 1).date()
|
||||
end_date = timezone.datetime(current_year + 1, 12, 31).date()
|
||||
|
||||
# Get years from transactions
|
||||
transaction_years = Transaction.objects.dates("reference_date", "year", order="ASC")
|
||||
|
||||
# Extend start_date and end_date if necessary
|
||||
if transaction_years:
|
||||
start_date = min(start_date, transaction_years.first().replace(month=1, day=1))
|
||||
end_date = max(end_date, transaction_years.last().replace(month=12, day=31))
|
||||
|
||||
# Generate all months between start_date and end_date
|
||||
all_months = []
|
||||
current_month_date = start_date
|
||||
while current_month_date <= end_date:
|
||||
all_months.append(current_month_date)
|
||||
current_month_date += relativedelta(months=1)
|
||||
|
||||
# Get transaction counts for each month
|
||||
transaction_counts = (
|
||||
Transaction.objects.annotate(
|
||||
year=ExtractYear("reference_date"), month=ExtractMonth("reference_date")
|
||||
)
|
||||
.values("year", "month")
|
||||
.annotate(transaction_count=Count("id"))
|
||||
.order_by("year", "month")
|
||||
)
|
||||
|
||||
# Create a dictionary for quick lookup
|
||||
count_dict = {
|
||||
(item["year"], item["month"]): item["transaction_count"]
|
||||
for item in transaction_counts
|
||||
}
|
||||
|
||||
# Create the final result
|
||||
result = [
|
||||
{
|
||||
"year": date.year,
|
||||
"month": date.month,
|
||||
"transaction_count": count_dict.get((date.year, date.month), 0),
|
||||
}
|
||||
for date in all_months
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"monthly_overview/fragments/month_year_picker.html",
|
||||
{
|
||||
"month_year_data": result,
|
||||
"current_month": current_month,
|
||||
"current_year": current_year,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NetWorthConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.net_worth"
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("net-worth/", views.net_worth_main, name="net_worth"),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
from django.db.models import Sum
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models.functions import TruncMonth
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.accounts.models import Account
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
def calculate_net_worth():
|
||||
accounts = Account.objects.all()
|
||||
net_worth = {}
|
||||
|
||||
for account in accounts:
|
||||
currency = account.currency
|
||||
if currency.code not in net_worth:
|
||||
net_worth[currency.code] = Decimal("0")
|
||||
|
||||
income = Transaction.objects.filter(
|
||||
account=account, type=Transaction.Type.INCOME, is_paid=True
|
||||
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
expenses = Transaction.objects.filter(
|
||||
account=account, type=Transaction.Type.EXPENSE, is_paid=True
|
||||
).aggregate(total=Sum("amount"))["total"] or Decimal("0")
|
||||
|
||||
account_balance = income - expenses
|
||||
net_worth[currency.code] += account_balance
|
||||
|
||||
return net_worth
|
||||
|
||||
|
||||
def calculate_historical_net_worth(start_date, end_date):
|
||||
asset_accounts = Account.objects.all()
|
||||
currencies = Currency.objects.all()
|
||||
|
||||
# Initialize the result dictionary
|
||||
historical_net_worth = {}
|
||||
|
||||
# Get all months between start_date and end_date
|
||||
months = (
|
||||
Transaction.objects.filter(account__in=asset_accounts)
|
||||
.annotate(month=TruncMonth("date"))
|
||||
.values("month")
|
||||
.distinct()
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
for month_data in months:
|
||||
month = month_data["month"]
|
||||
month_str = month.strftime("%Y-%m")
|
||||
historical_net_worth[month_str] = {
|
||||
currency.code: Decimal("0.00") for currency in currencies
|
||||
}
|
||||
|
||||
for account in asset_accounts:
|
||||
currency = account.currency
|
||||
|
||||
income = Transaction.objects.filter(
|
||||
account=account,
|
||||
type=Transaction.Type.INCOME,
|
||||
is_paid=True,
|
||||
date__lte=month,
|
||||
).aggregate(total=Sum("amount"))["total"] or Decimal("0.00")
|
||||
|
||||
expenses = Transaction.objects.filter(
|
||||
account=account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
is_paid=True,
|
||||
date__lte=month,
|
||||
).aggregate(total=Sum("amount"))["total"] or Decimal("0.00")
|
||||
|
||||
account_balance = income - expenses
|
||||
historical_net_worth[month_str][currency.code] += account_balance
|
||||
|
||||
return historical_net_worth
|
||||
@@ -0,0 +1,39 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import render
|
||||
|
||||
from apps.net_worth.utils.calculate_net_worth import (
|
||||
calculate_net_worth,
|
||||
calculate_historical_net_worth,
|
||||
)
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
# Create your views here.
|
||||
def net_worth_main(request):
|
||||
net_worth = calculate_net_worth()
|
||||
# historical = calculate_historical_net_worth(
|
||||
# start_date=datetime(day=1, month=1, year=2021).date(),
|
||||
# end_date=datetime(day=1, month=1, year=2025).date(),
|
||||
# )
|
||||
# print(historical)
|
||||
|
||||
# Format the net worth with currency details
|
||||
formatted_net_worth = []
|
||||
for currency_code, amount in net_worth.items():
|
||||
currency = Currency.objects.get(code=currency_code)
|
||||
formatted_net_worth.append(
|
||||
{
|
||||
"amount": amount,
|
||||
"code": currency.code,
|
||||
"name": currency.name,
|
||||
"prefix": currency.prefix,
|
||||
"suffix": currency.suffix,
|
||||
"decimal_places": currency.decimal_places,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request, "net_worth/net_worth.html", {"currency_net_worth": formatted_net_worth}
|
||||
)
|
||||
@@ -1,6 +1,11 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
InstallmentPlan,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Transaction)
|
||||
@@ -16,5 +21,17 @@ class TransactionModelAdmin(admin.ModelAdmin):
|
||||
]
|
||||
|
||||
|
||||
class TransactionInline(admin.TabularInline):
|
||||
model = Transaction
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(InstallmentPlan)
|
||||
class InstallmentPlanAdmin(admin.ModelAdmin):
|
||||
inlines = [
|
||||
TransactionInline,
|
||||
]
|
||||
|
||||
|
||||
admin.site.register(TransactionCategory)
|
||||
admin.site.register(TransactionTag)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import django_filters
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field
|
||||
from django import forms
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from apps.transactions.models import TransactionTag
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
|
||||
SITUACAO_CHOICES = (
|
||||
("1", _("Paid")),
|
||||
("0", _("Projected")),
|
||||
)
|
||||
|
||||
|
||||
def content_filter(queryset, name, value):
|
||||
queryset = queryset.filter(
|
||||
Q(description__icontains=value) | Q(notes__icontains=value)
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
class TransactionsFilter(django_filters.FilterSet):
|
||||
description = django_filters.CharFilter(
|
||||
label=_("Content"),
|
||||
method=content_filter,
|
||||
widget=forms.TextInput(attrs={"type": "search"}),
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=Transaction.Type.choices,
|
||||
label=_("Transaction Type"),
|
||||
)
|
||||
account = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="account__name",
|
||||
queryset=Account.objects.all(),
|
||||
to_field_name="name",
|
||||
label="Accounts",
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
)
|
||||
category = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="category__name",
|
||||
queryset=TransactionCategory.objects.all(),
|
||||
to_field_name="name",
|
||||
label="Categories",
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
)
|
||||
tags = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name="tags__name",
|
||||
queryset=TransactionTag.objects.all(),
|
||||
to_field_name="name",
|
||||
label="Tags",
|
||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||
)
|
||||
is_paid = django_filters.MultipleChoiceFilter(
|
||||
choices=SITUACAO_CHOICES,
|
||||
field_name="is_paid",
|
||||
label="Situação",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
"description",
|
||||
"type",
|
||||
"account",
|
||||
"is_paid",
|
||||
"category",
|
||||
"tags",
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
# if filterset is bound, use initial values as defaults
|
||||
if data is not None:
|
||||
# get a mutable copy of the QueryDict
|
||||
data = data.copy()
|
||||
|
||||
# # set type to all if it isn't set
|
||||
if data.get("type") is None:
|
||||
data.setlist("type", ["IN", "EX"])
|
||||
|
||||
if data.get("is_paid") is None:
|
||||
data.setlist("is_paid", ["1", "0"])
|
||||
|
||||
super().__init__(data, *args, **kwargs)
|
||||
|
||||
self.form.helper = FormHelper()
|
||||
self.form.helper.form_tag = False
|
||||
self.form.helper.form_method = "GET"
|
||||
self.form.helper.disable_csrf = True
|
||||
self.form.helper.layout = Layout(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/transaction_type_filter_buttons.html",
|
||||
),
|
||||
Field(
|
||||
"is_paid",
|
||||
template="transactions/widgets/transaction_type_filter_buttons.html",
|
||||
),
|
||||
Field("description"),
|
||||
Field("account", size=1),
|
||||
Field("category", size=1),
|
||||
Field("tags", size=1),
|
||||
)
|
||||
+323
-47
@@ -1,12 +1,26 @@
|
||||
from crispy_bootstrap5.bootstrap5 import Switch
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Row, Column, Field, Fieldset
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django import forms
|
||||
from django.db import transaction
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Submit, Row, Column, Div, Field, Hidden
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from .models import Transaction, TransactionCategory, TransactionTag
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
InstallmentPlan,
|
||||
)
|
||||
from apps.transactions.widgets import (
|
||||
ArbitraryDecimalDisplayNumberInput,
|
||||
MonthYearWidget,
|
||||
@@ -14,6 +28,19 @@ from apps.transactions.widgets import (
|
||||
|
||||
|
||||
class TransactionForm(forms.ModelForm):
|
||||
category = DynamicModelChoiceField(
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
fields = [
|
||||
@@ -31,20 +58,21 @@ class TransactionForm(forms.ModelForm):
|
||||
widgets = {
|
||||
"date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
"account": TomSelect(),
|
||||
}
|
||||
labels = {
|
||||
"tags": mark_safe('<i class="fa-solid fa-hashtag me-1"></i>' + _("Tags")),
|
||||
"category": mark_safe(
|
||||
'<i class="fa-solid fa-icons me-1"></i>' + _("Category")
|
||||
),
|
||||
"notes": mark_safe(
|
||||
'<i class="fa-solid fa-align-justify me-1"></i>' + _("Notes")
|
||||
),
|
||||
"amount": mark_safe('<i class="fa-solid fa-coins me-1"></i>' + _("Amount")),
|
||||
"description": mark_safe(
|
||||
'<i class="fa-solid fa-quote-left me-1"></i>' + _("Name")
|
||||
),
|
||||
}
|
||||
# labels = {
|
||||
# "tags": mark_safe('<i class="fa-solid fa-hashtag me-1"></i>' + _("Tags")),
|
||||
# "category": mark_safe(
|
||||
# '<i class="fa-solid fa-icons me-1"></i>' + _("Category")
|
||||
# ),
|
||||
# "notes": mark_safe(
|
||||
# '<i class="fa-solid fa-align-justify me-1"></i>' + _("Notes")
|
||||
# ),
|
||||
# "amount": mark_safe('<i class="fa-solid fa-coins me-1"></i>' + _("Amount")),
|
||||
# "description": mark_safe(
|
||||
# '<i class="fa-solid fa-quote-left me-1"></i>' + _("Name")
|
||||
# ),
|
||||
# }
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -66,28 +94,59 @@ class TransactionForm(forms.ModelForm):
|
||||
),
|
||||
"description",
|
||||
Field("amount", inputmode="decimal"),
|
||||
Field("category", css_class="select"),
|
||||
Field("tags", css_class="multiselect", size=1),
|
||||
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",
|
||||
),
|
||||
"notes",
|
||||
Submit("submit", "Save", css_class="btn btn-warning"),
|
||||
)
|
||||
|
||||
self.fields["reference_date"].required = False
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
decimal_places = self.instance.account.currency.decimal_places
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=decimal_places
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=2
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
else:
|
||||
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
date = cleaned_data.get("date")
|
||||
reference_date = cleaned_data.get("reference_date")
|
||||
|
||||
if date and not reference_date:
|
||||
cleaned_data["reference_date"] = date.replace(day=1)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class TransferForm(forms.Form):
|
||||
from_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.all(), label="From Account"
|
||||
queryset=Account.objects.all(),
|
||||
label="From Account",
|
||||
widget=TomSelect(),
|
||||
)
|
||||
to_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.all(), label="To Account"
|
||||
queryset=Account.objects.all(),
|
||||
label="To Account",
|
||||
widget=TomSelect(),
|
||||
)
|
||||
|
||||
from_amount = forms.DecimalField(
|
||||
@@ -102,20 +161,30 @@ class TransferForm(forms.Form):
|
||||
required=False,
|
||||
)
|
||||
|
||||
from_category = forms.ModelChoiceField(
|
||||
queryset=TransactionCategory.objects.all(),
|
||||
from_category = DynamicModelChoiceField(
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label="From Category",
|
||||
label=_("Category"),
|
||||
)
|
||||
to_category = forms.ModelChoiceField(
|
||||
queryset=TransactionCategory.objects.all(), required=False, label="To Category"
|
||||
to_category = DynamicModelChoiceField(
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
)
|
||||
|
||||
from_tags = forms.ModelMultipleChoiceField(
|
||||
queryset=TransactionTag.objects.all(), required=False, label="From Tags"
|
||||
from_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
)
|
||||
to_tags = forms.ModelMultipleChoiceField(
|
||||
queryset=TransactionTag.objects.all(), required=False, label="To Tags"
|
||||
to_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
)
|
||||
|
||||
date = forms.DateField(
|
||||
@@ -133,16 +202,25 @@ class TransferForm(forms.Form):
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("date", css_class="form-group col-md-6 mb-0"),
|
||||
Column("reference_date", css_class="form-group col-md-6 mb-0"),
|
||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||
Column(
|
||||
Field("reference_date"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
"description",
|
||||
Field("description"),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column("from_account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("from_amount", css_class="form-group col-md-6 mb-0"),
|
||||
Column(
|
||||
"from_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
Field("from_amount"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
@@ -156,8 +234,14 @@ class TransferForm(forms.Form):
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column("to_account", css_class="form-group col-md-6 mb-0"),
|
||||
Column("to_amount", css_class="form-group col-md-6 mb-0"),
|
||||
Column(
|
||||
"to_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
Field("to_amount"),
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
@@ -168,16 +252,16 @@ class TransferForm(forms.Form):
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
),
|
||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Tranfer"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=2
|
||||
)
|
||||
self.fields["from_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
decimal_places=2
|
||||
)
|
||||
self.fields["to_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
@@ -227,3 +311,195 @@ class TransferForm(forms.Form):
|
||||
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||
|
||||
return from_transaction, to_transaction
|
||||
|
||||
|
||||
class InstallmentPlanForm(forms.Form):
|
||||
type = forms.ChoiceField(choices=Transaction.Type.choices)
|
||||
account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.all(),
|
||||
label=_("Account"),
|
||||
widget=TomSelect(),
|
||||
)
|
||||
start_date = forms.DateField(
|
||||
label=_("Start Date"),
|
||||
widget=forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"),
|
||||
)
|
||||
description = forms.CharField(max_length=500, label=_("Description"))
|
||||
number_of_installments = forms.IntegerField(
|
||||
min_value=1, label=_("Number of Installments")
|
||||
)
|
||||
recurrence = forms.ChoiceField(
|
||||
choices=(
|
||||
("yearly", _("Yearly")),
|
||||
("monthly", _("Monthly")),
|
||||
("weekly", _("Weekly")),
|
||||
("daily", _("Daily")),
|
||||
),
|
||||
initial="monthly",
|
||||
widget=TomSelect(clear_button=False),
|
||||
)
|
||||
installment_amount = forms.DecimalField(
|
||||
max_digits=42,
|
||||
decimal_places=30,
|
||||
required=True,
|
||||
label=_("Installment Amount"),
|
||||
)
|
||||
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.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Field(
|
||||
"type",
|
||||
template="transactions/widgets/income_expense_toggle_buttons.html",
|
||||
),
|
||||
"account",
|
||||
"description",
|
||||
Row(
|
||||
Column("start_date", css_class="form-group col-md-4 mb-0"),
|
||||
Column("number_of_installments", css_class="form-group col-md-4 mb-0"),
|
||||
Column("recurrence", css_class="form-group col-md-4 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
"installment_amount",
|
||||
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",
|
||||
),
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Add"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["installment_amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
|
||||
def save(self):
|
||||
number_of_installments = self.cleaned_data["number_of_installments"]
|
||||
transaction_type = self.cleaned_data["type"]
|
||||
start_date = self.cleaned_data["start_date"]
|
||||
recurrence = self.cleaned_data["recurrence"]
|
||||
account = self.cleaned_data["account"]
|
||||
description = self.cleaned_data["description"]
|
||||
installment_amount = self.cleaned_data["installment_amount"]
|
||||
category = self.cleaned_data["category"]
|
||||
|
||||
with transaction.atomic():
|
||||
installment_plan = InstallmentPlan.objects.create(
|
||||
account=account,
|
||||
description=description,
|
||||
number_of_installments=number_of_installments,
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
for i in range(number_of_installments):
|
||||
if recurrence == "yearly":
|
||||
delta = relativedelta(years=i)
|
||||
elif recurrence == "monthly":
|
||||
delta = relativedelta(months=i)
|
||||
elif recurrence == "weekly":
|
||||
delta = relativedelta(weeks=i)
|
||||
elif recurrence == "daily":
|
||||
delta = relativedelta(days=i)
|
||||
|
||||
transaction_date = start_date + delta
|
||||
new_transaction = Transaction.objects.create(
|
||||
account=account,
|
||||
type=transaction_type,
|
||||
date=transaction_date,
|
||||
reference_date=transaction_date.replace(day=1),
|
||||
amount=installment_amount,
|
||||
description=description,
|
||||
notes=f"{i + 1}/{number_of_installments}",
|
||||
category=category,
|
||||
installment_plan=installment_plan,
|
||||
)
|
||||
|
||||
new_transaction.tags.set(self.cleaned_data.get("tags", []))
|
||||
|
||||
return installment_plan
|
||||
|
||||
|
||||
class TransactionTagForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionTag
|
||||
fields = ["name"]
|
||||
labels = {"name": _("Tag 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(Field("name", css_class="mb-3"))
|
||||
|
||||
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 TransactionCategoryForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = TransactionCategory
|
||||
fields = ["name", "mute"]
|
||||
labels = {"name": _("Category name")}
|
||||
help_texts = {
|
||||
"mute": _("Muted categories won't count towards your monthly total")
|
||||
}
|
||||
|
||||
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(Field("name", css_class="mb-3"), Switch("mute"))
|
||||
|
||||
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"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-09-27 19:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('transactions', '0008_rename_transactiontags_transactiontag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='transaction',
|
||||
name='tags',
|
||||
field=models.ManyToManyField(blank=True, to='transactions.transactiontag', verbose_name='Tags'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-05 02:15
|
||||
|
||||
import apps.transactions.validators
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0002_account_exchange_currency"),
|
||||
("transactions", "0009_alter_transaction_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="InstallmentPlan",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("start_date", models.DateField(verbose_name="Start Date")),
|
||||
(
|
||||
"description",
|
||||
models.CharField(max_length=500, verbose_name="Description"),
|
||||
),
|
||||
(
|
||||
"number_of_installments",
|
||||
models.PositiveIntegerField(
|
||||
validators=[django.core.validators.MinValueValidator(1)],
|
||||
verbose_name="Number of Installments",
|
||||
),
|
||||
),
|
||||
(
|
||||
"total_amount",
|
||||
models.DecimalField(
|
||||
decimal_places=30,
|
||||
max_digits=42,
|
||||
validators=[
|
||||
apps.transactions.validators.validate_non_negative,
|
||||
apps.transactions.validators.validate_decimal_places,
|
||||
],
|
||||
verbose_name="Total Amount",
|
||||
),
|
||||
),
|
||||
(
|
||||
"account",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="accounts.account",
|
||||
verbose_name="Account",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Installment Plan",
|
||||
"verbose_name_plural": "Installment Plans",
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="transaction",
|
||||
name="installment_plan",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="transactions",
|
||||
to="transactions.installmentplan",
|
||||
verbose_name="Installment Plan",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-05 16:17
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transactions", "0010_installmentplan_transaction_installment_plan"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="installmentplan",
|
||||
name="start_date",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="installmentplan",
|
||||
name="total_amount",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-06 22:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transactions", "0011_remove_installmentplan_start_date_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="transaction",
|
||||
name="category",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="transactions.transactioncategory",
|
||||
verbose_name="Category",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-07 14:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("transactions", "0012_alter_transaction_category"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="transactioncategory",
|
||||
name="name",
|
||||
field=models.CharField(max_length=255, unique=True, verbose_name="Name"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="transactiontag",
|
||||
name="name",
|
||||
field=models.CharField(max_length=255, unique=True, verbose_name="Name"),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,18 @@
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.functions.decimals import truncate_decimal
|
||||
from apps.transactions.fields import MonthYearField
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
from apps.currencies.utils.convert import convert
|
||||
|
||||
|
||||
class TransactionCategory(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
mute = models.BooleanField(default=False, verbose_name=_("Mute"))
|
||||
|
||||
class Meta:
|
||||
@@ -21,7 +25,7 @@ class TransactionCategory(models.Model):
|
||||
|
||||
|
||||
class TransactionTag(models.Model):
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"))
|
||||
name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction Tags")
|
||||
@@ -32,6 +36,30 @@ class TransactionTag(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class InstallmentPlan(models.Model):
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account")
|
||||
)
|
||||
description = models.CharField(max_length=500, verbose_name=_("Description"))
|
||||
number_of_installments = models.PositiveIntegerField(
|
||||
validators=[MinValueValidator(1)], verbose_name=_("Number of Installments")
|
||||
)
|
||||
# start_date = models.DateField(verbose_name=_("Start Date"))
|
||||
# end_date = models.DateField(verbose_name=_("End Date"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Installment Plan")
|
||||
verbose_name_plural = _("Installment Plans")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.description} - {self.number_of_installments} installments"
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
# Delete related transactions
|
||||
self.transactions.all().delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class Transaction(models.Model):
|
||||
class Type(models.TextChoices):
|
||||
INCOME = "IN", _("Income")
|
||||
@@ -61,13 +89,22 @@ class Transaction(models.Model):
|
||||
notes = models.TextField(blank=True, verbose_name=_("Notes"))
|
||||
category = models.ForeignKey(
|
||||
TransactionCategory,
|
||||
on_delete=models.CASCADE,
|
||||
on_delete=models.SET_NULL,
|
||||
verbose_name=_("Category"),
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True)
|
||||
|
||||
installment_plan = models.ForeignKey(
|
||||
InstallmentPlan,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="transactions",
|
||||
verbose_name=_("Installment Plan"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Transaction")
|
||||
verbose_name_plural = _("Transactions")
|
||||
@@ -79,3 +116,21 @@ class Transaction(models.Model):
|
||||
)
|
||||
self.full_clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def exchanged_amount(self):
|
||||
if self.account.exchange_currency:
|
||||
converted_amount = convert(
|
||||
self.amount,
|
||||
self.account.exchange_currency,
|
||||
self.account.currency,
|
||||
date=self.date,
|
||||
)
|
||||
if converted_amount:
|
||||
return {
|
||||
"amount": converted_amount,
|
||||
"suffix": self.account.exchange_currency.suffix,
|
||||
"prefix": self.account.exchange_currency.prefix,
|
||||
"decimal_places": self.account.exchange_currency.decimal_places,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django import template
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils.formats import number_format
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
@@ -7,7 +8,9 @@ register = template.Library()
|
||||
|
||||
|
||||
def _format_string(prefix, amount, decimal_places, suffix):
|
||||
formatted_amount = floatformat(abs(amount), f"{decimal_places}g")
|
||||
formatted_amount = number_format(
|
||||
value=abs(amount), decimal_pos=decimal_places, force_grouping=True
|
||||
)
|
||||
if amount < 0:
|
||||
return f"-{prefix}{formatted_amount}{suffix}"
|
||||
else:
|
||||
@@ -32,3 +35,8 @@ def entry_currency(entry):
|
||||
suffix = entry["suffix"]
|
||||
|
||||
return _format_string(prefix, amount, decimal_places, suffix)
|
||||
|
||||
|
||||
@register.simple_tag(name="currency_display")
|
||||
def currency_display(amount, prefix, suffix, decimal_places):
|
||||
return _format_string(prefix, amount, decimal_places, suffix)
|
||||
|
||||
@@ -3,22 +3,6 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/",
|
||||
views.transactions_overview,
|
||||
name="transactions_overview",
|
||||
),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/list/",
|
||||
views.transactions_list,
|
||||
name="transactions_list",
|
||||
),
|
||||
path(
|
||||
"transactions/<int:month>/<int:year>/summary/",
|
||||
views.monthly_summary,
|
||||
name="monthly_summary",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/pay",
|
||||
views.transaction_pay,
|
||||
@@ -39,14 +23,38 @@ urlpatterns = [
|
||||
views.transaction_add,
|
||||
name="transaction_add",
|
||||
),
|
||||
path(
|
||||
"available_dates/",
|
||||
views.month_year_picker,
|
||||
name="available_dates",
|
||||
),
|
||||
path(
|
||||
"transactions/transfer",
|
||||
views.transactions_transfer,
|
||||
name="transactions_transfer",
|
||||
),
|
||||
path(
|
||||
"transactions/installments/add/",
|
||||
views.AddInstallmentPlanView.as_view(),
|
||||
name="installments_add",
|
||||
),
|
||||
path("tags/", views.tag_list, name="tags_list"),
|
||||
path("tags/add/", views.tag_add, name="tag_add"),
|
||||
path(
|
||||
"tags/<int:tag_id>/edit/",
|
||||
views.tag_edit,
|
||||
name="tag_edit",
|
||||
),
|
||||
path(
|
||||
"tags/<int:tag_id>/delete/",
|
||||
views.tag_delete,
|
||||
name="tag_delete",
|
||||
),
|
||||
path("categories/", views.categories_list, name="categories_list"),
|
||||
path("categories/add/", views.category_add, name="category_add"),
|
||||
path(
|
||||
"categories/<int:category_id>/edit/",
|
||||
views.category_edit,
|
||||
name="category_edit",
|
||||
),
|
||||
path(
|
||||
"categories/<int:category_id>/delete/",
|
||||
views.category_delete,
|
||||
name="category_delete",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db.models import Sum
|
||||
|
||||
|
||||
def calculate_sum(queryset, transaction_type, is_paid):
|
||||
return (
|
||||
queryset.filter(type=transaction_type, is_paid=is_paid)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__suffix",
|
||||
"account__currency__prefix",
|
||||
"account__currency__decimal_places",
|
||||
)
|
||||
.annotate(total=Sum("amount"))
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
|
||||
# Helper function to format currency sums
|
||||
def format_currency_sum(queryset):
|
||||
return [
|
||||
{
|
||||
"currency": item["account__currency__name"],
|
||||
"suffix": item["account__currency__suffix"],
|
||||
"prefix": item["account__currency__prefix"],
|
||||
"decimal_places": item["account__currency__decimal_places"],
|
||||
"amount": item["total"],
|
||||
}
|
||||
for item in queryset
|
||||
]
|
||||
|
||||
|
||||
# Calculate totals
|
||||
def calculate_total(income, expenses):
|
||||
totals = {}
|
||||
|
||||
# Process income
|
||||
for item in income:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["total"]
|
||||
|
||||
# Subtract expenses
|
||||
for item in expenses:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) - item["total"]
|
||||
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
(
|
||||
item["account__currency__suffix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"prefix": next(
|
||||
(
|
||||
item["account__currency__prefix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"decimal_places": next(
|
||||
(
|
||||
item["account__currency__decimal_places"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
2,
|
||||
),
|
||||
"amount": amount,
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
|
||||
# Calculate total final
|
||||
def sum_totals(total1, total2):
|
||||
totals = {}
|
||||
for item in total1 + total2:
|
||||
currency = item["currency"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["amount"]
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
item["suffix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"prefix": next(
|
||||
item["prefix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"decimal_places": next(
|
||||
item["decimal_places"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"amount": amount,
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
@@ -1,419 +0,0 @@
|
||||
import datetime
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db.models import Sum, Q, F, Case, When, DecimalField
|
||||
from django.db.models.functions import Coalesce
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.transactions.forms import TransactionForm, TransferForm
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.common.functions.dates import remaining_days_in_month
|
||||
|
||||
|
||||
@login_required
|
||||
def index(request):
|
||||
now = timezone.localdate(timezone.now())
|
||||
|
||||
return redirect(to="transactions_overview", month=now.month, year=now.year)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_overview(request, month: int, year: int):
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.translation import get_language
|
||||
|
||||
current_language = get_language()
|
||||
|
||||
thousand_separator = get_format("THOUSAND_SEPARATOR")
|
||||
print(thousand_separator, current_language)
|
||||
if month < 1 or month > 12:
|
||||
from django.http import Http404
|
||||
|
||||
raise Http404("Month is out of range")
|
||||
|
||||
next_month = 1 if month == 12 else month + 1
|
||||
next_year = year + 1 if next_month == 1 and month == 12 else year
|
||||
|
||||
previous_month = 12 if month == 1 else month - 1
|
||||
previous_year = year - 1 if previous_month == 12 and month == 1 else year
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/overview.html",
|
||||
context={
|
||||
"month": month,
|
||||
"year": year,
|
||||
"next_month": next_month,
|
||||
"next_year": next_year,
|
||||
"previous_month": previous_month,
|
||||
"previous_year": previous_year,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_list(request, month: int, year: int):
|
||||
from django.db.models.functions import ExtractMonth, ExtractYear
|
||||
|
||||
queryset = (
|
||||
Transaction.objects.annotate(
|
||||
month=ExtractMonth("reference_date"), year=ExtractYear("reference_date")
|
||||
)
|
||||
.values("month", "year")
|
||||
.distinct()
|
||||
.order_by("year", "month")
|
||||
)
|
||||
# print(queryset)
|
||||
|
||||
transactions = (
|
||||
Transaction.objects.all()
|
||||
.filter(
|
||||
reference_date__year=year,
|
||||
reference_date__month=month,
|
||||
)
|
||||
.order_by("date", "id")
|
||||
.select_related()
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/list.html",
|
||||
context={"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_add(request, **kwargs):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully!"))
|
||||
|
||||
# redirect to a new URL:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully!"))
|
||||
|
||||
# redirect to a new URL:
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/edit.html",
|
||||
{"form": form, "transaction": transaction},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_delete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
transaction.delete()
|
||||
|
||||
messages.success(request, _("Transaction deleted successfully!"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast"},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def transactions_transfer(request):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransferForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transfer added successfully."))
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = TransferForm(
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
}
|
||||
)
|
||||
|
||||
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
||||
|
||||
|
||||
@login_required
|
||||
def transaction_pay(request, transaction_id):
|
||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/item.html",
|
||||
context={"transaction": transaction},
|
||||
)
|
||||
response.headers["HX-Trigger"] = (
|
||||
f'{"paid" if new_is_paid else "unpaid"}, transaction_updated'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
def month_year_picker(request):
|
||||
current_month = int(
|
||||
request.GET.get("month", timezone.localdate(timezone.now()).month)
|
||||
)
|
||||
current_year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
|
||||
available_years = Transaction.objects.dates(
|
||||
"reference_date", "year", order="ASC"
|
||||
) or [datetime.datetime(current_year, current_month, 1)]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/month_year_picker.html",
|
||||
{
|
||||
"available_years": available_years,
|
||||
"months": range(1, 13),
|
||||
"current_month": current_month,
|
||||
"current_year": current_year,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
def monthly_summary(request, month: int, year: int):
|
||||
# Helper function to calculate sums for different transaction types
|
||||
def calculate_sum(transaction_type, is_paid):
|
||||
return (
|
||||
base_queryset.filter(type=transaction_type, is_paid=is_paid)
|
||||
.values(
|
||||
"account__currency__name",
|
||||
"account__currency__suffix",
|
||||
"account__currency__prefix",
|
||||
"account__currency__decimal_places",
|
||||
)
|
||||
.annotate(total=Sum("amount"))
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
# Helper function to format currency sums
|
||||
|
||||
def format_currency_sum(queryset):
|
||||
return [
|
||||
{
|
||||
"currency": item["account__currency__name"],
|
||||
"suffix": item["account__currency__suffix"],
|
||||
"prefix": item["account__currency__prefix"],
|
||||
"decimal_places": item["account__currency__decimal_places"],
|
||||
"amount": round(
|
||||
item["total"], item["account__currency__decimal_places"]
|
||||
),
|
||||
}
|
||||
for item in queryset
|
||||
]
|
||||
|
||||
# Calculate totals
|
||||
def calculate_total(income, expenses):
|
||||
totals = {}
|
||||
|
||||
# Process income
|
||||
for item in income:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["total"]
|
||||
|
||||
# Subtract expenses
|
||||
for item in expenses:
|
||||
currency = item["account__currency__name"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) - item["total"]
|
||||
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
(
|
||||
item["account__currency__suffix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"prefix": next(
|
||||
(
|
||||
item["account__currency__prefix"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
"",
|
||||
),
|
||||
"decimal_places": next(
|
||||
(
|
||||
item["account__currency__decimal_places"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
2,
|
||||
),
|
||||
"amount": round(
|
||||
amount,
|
||||
next(
|
||||
(
|
||||
item["account__currency__decimal_places"]
|
||||
for item in list(income) + list(expenses)
|
||||
if item["account__currency__name"] == currency
|
||||
),
|
||||
2,
|
||||
),
|
||||
),
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
# Calculate total final
|
||||
def sum_totals(total1, total2):
|
||||
totals = {}
|
||||
for item in total1 + total2:
|
||||
currency = item["currency"]
|
||||
totals[currency] = totals.get(currency, Decimal("0")) + item["amount"]
|
||||
return [
|
||||
{
|
||||
"currency": currency,
|
||||
"suffix": next(
|
||||
item["suffix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"prefix": next(
|
||||
item["prefix"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"decimal_places": next(
|
||||
item["decimal_places"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
"amount": round(
|
||||
amount,
|
||||
next(
|
||||
item["decimal_places"]
|
||||
for item in total1 + total2
|
||||
if item["currency"] == currency
|
||||
),
|
||||
),
|
||||
}
|
||||
for currency, amount in totals.items()
|
||||
]
|
||||
|
||||
# Base queryset with all required filters
|
||||
base_queryset = Transaction.objects.filter(
|
||||
reference_date__year=year, reference_date__month=month, account__is_asset=False
|
||||
).exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
|
||||
# Calculate sums for different transaction types
|
||||
paid_income = calculate_sum(Transaction.Type.INCOME, True)
|
||||
projected_income = calculate_sum(Transaction.Type.INCOME, False)
|
||||
paid_expenses = calculate_sum(Transaction.Type.EXPENSE, True)
|
||||
projected_expenses = calculate_sum(Transaction.Type.EXPENSE, False)
|
||||
|
||||
total_current = calculate_total(paid_income, paid_expenses)
|
||||
total_projected = calculate_total(projected_income, projected_expenses)
|
||||
|
||||
total_final = sum_totals(total_current, total_projected)
|
||||
|
||||
# Calculate daily spending allowance
|
||||
remaining_days = remaining_days_in_month(
|
||||
month=month, year=year, current_date=timezone.localdate(timezone.now())
|
||||
)
|
||||
daily_spending_allowance = [
|
||||
{
|
||||
"currency": item["currency"],
|
||||
"suffix": item["suffix"],
|
||||
"prefix": item["prefix"],
|
||||
"decimal_places": item["decimal_places"],
|
||||
"amount": (
|
||||
amount
|
||||
if (amount := item["amount"] / remaining_days) > 0
|
||||
else Decimal("0")
|
||||
),
|
||||
}
|
||||
for item in total_final
|
||||
]
|
||||
|
||||
# Construct the response dictionary
|
||||
response_data = {
|
||||
"paid_income": format_currency_sum(paid_income),
|
||||
"projected_income": format_currency_sum(projected_income),
|
||||
"paid_expenses": format_currency_sum(paid_expenses),
|
||||
"projected_expenses": format_currency_sum(projected_expenses),
|
||||
"total_current": total_current,
|
||||
"total_projected": total_projected,
|
||||
"total_final": total_final,
|
||||
"daily_spending_allowance": daily_spending_allowance,
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/monthly_summary.html",
|
||||
context={
|
||||
"totals": response_data,
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from .transactions import *
|
||||
from .tags import *
|
||||
from .categories import *
|
||||
@@ -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.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionCategoryForm
|
||||
from apps.transactions.models import TransactionCategory
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def categories_list(request):
|
||||
categories = TransactionCategory.objects.all().order_by("id")
|
||||
return render(request, "categories/pages/list.html", {"categories": categories})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def category_add(request, **kwargs):
|
||||
if request.method == "POST":
|
||||
form = TransactionCategoryForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Category added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("categories_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionCategoryForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def category_edit(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionCategoryForm(request.POST, instance=category)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Category updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("categories_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionCategoryForm(instance=category)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"categories/fragments/edit.html",
|
||||
{"form": form, "category": category},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def category_delete(request, category_id):
|
||||
category = get_object_or_404(TransactionCategory, id=category_id)
|
||||
|
||||
category.delete()
|
||||
|
||||
messages.success(request, _("Category deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Location": reverse("categories_list")},
|
||||
)
|
||||
@@ -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.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionTagForm
|
||||
from apps.transactions.models import TransactionTag
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def tag_list(request):
|
||||
tags = TransactionTag.objects.all().order_by("id")
|
||||
return render(request, "tags/pages/list.html", {"tags": tags})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def tag_add(request, **kwargs):
|
||||
if request.method == "POST":
|
||||
form = TransactionTagForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Tag added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("tags_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionTagForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def tag_edit(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionTagForm(request.POST, instance=tag)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Tag updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Location": reverse("tags_list"),
|
||||
"HX-Trigger": "hide_offcanvas, toasts",
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = TransactionTagForm(instance=tag)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"tags/fragments/edit.html",
|
||||
{"form": form, "tag": tag},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def tag_delete(request, tag_id):
|
||||
tag = get_object_or_404(TransactionTag, id=tag_id)
|
||||
|
||||
tag.delete()
|
||||
|
||||
messages.success(request, _("Tag deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Location": reverse("tags_list")},
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
import datetime
|
||||
|
||||
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.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.forms import TransactionForm, TransferForm, InstallmentPlanForm
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transaction_add(request, **kwargs):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
transaction_type = Transaction.Type(request.GET.get("type", "IN"))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(
|
||||
initial={
|
||||
"date": expected_date,
|
||||
"type": transaction_type,
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/add.html",
|
||||
{"form": form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transaction_edit(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransactionForm(request.POST, instance=transaction)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transaction updated successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
else:
|
||||
form = TransactionForm(instance=transaction)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"transactions/fragments/edit.html",
|
||||
{"form": form, "transaction": transaction},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@csrf_exempt
|
||||
@require_http_methods(["DELETE"])
|
||||
def transaction_delete(request, transaction_id, **kwargs):
|
||||
transaction = get_object_or_404(Transaction, id=transaction_id)
|
||||
|
||||
if transaction.installment_plan:
|
||||
messages.error(
|
||||
request,
|
||||
_(
|
||||
"This transaction is part of a Installment Plan, you can't delete it directly."
|
||||
),
|
||||
)
|
||||
else:
|
||||
transaction.delete()
|
||||
|
||||
messages.success(request, _("Transaction deleted successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def transactions_transfer(request):
|
||||
month = int(request.GET.get("month", timezone.localdate(timezone.now()).month))
|
||||
year = int(request.GET.get("year", timezone.localdate(timezone.now()).year))
|
||||
|
||||
now = timezone.localdate(timezone.now())
|
||||
expected_date = datetime.datetime(
|
||||
day=now.day if month == now.month and year == now.year else 1,
|
||||
month=month,
|
||||
year=year,
|
||||
).date()
|
||||
|
||||
if request.method == "POST":
|
||||
form = TransferForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Transfer added successfully."))
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, toast, hide_offcanvas"},
|
||||
)
|
||||
else:
|
||||
form = TransferForm(
|
||||
initial={
|
||||
"reference_date": expected_date,
|
||||
"date": expected_date,
|
||||
}
|
||||
)
|
||||
|
||||
return render(request, "transactions/fragments/transfer.html", {"form": form})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transaction_pay(request, transaction_id):
|
||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
|
||||
response = render(
|
||||
request,
|
||||
"transactions/fragments/item.html",
|
||||
context={"transaction": transaction},
|
||||
)
|
||||
response.headers["HX-Trigger"] = (
|
||||
f'{"paid" if new_is_paid else "unpaid"}, monthly_summary_update'
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class AddInstallmentPlanView(View):
|
||||
template_name = "transactions/fragments/add_installment_plan.html"
|
||||
|
||||
def get(self, request):
|
||||
form = InstallmentPlanForm()
|
||||
return render(request, self.template_name, {"form": form})
|
||||
|
||||
def post(self, request):
|
||||
form = InstallmentPlanForm(request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
messages.success(request, _("Installment plan created successfully."))
|
||||
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"},
|
||||
)
|
||||
|
||||
return render(request, self.template_name, {"form": form})
|
||||
@@ -1,13 +1,13 @@
|
||||
from datetime import datetime, date
|
||||
|
||||
from django import forms
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from django.template.defaultfilters import floatformat
|
||||
from django.utils.formats import get_format
|
||||
from django.utils.formats import get_format, number_format
|
||||
|
||||
|
||||
def convert_to_decimal(value):
|
||||
def convert_to_decimal(value: str):
|
||||
# Remove any whitespace
|
||||
value = value.strip()
|
||||
|
||||
@@ -41,8 +41,9 @@ class MonthYearWidget(forms.DateInput):
|
||||
class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
||||
"""A widget for displaying and inputing decimal numbers with the least amount of trailing zeros possible. You
|
||||
must set this on your Form's __init__ method."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.decimal_places = kwargs.pop("decimal_places", 2)
|
||||
self.decimal_places = kwargs.pop("decimal_places", None)
|
||||
self.type = "text"
|
||||
super().__init__(*args, **kwargs)
|
||||
self.attrs.update(
|
||||
@@ -53,15 +54,28 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
||||
)
|
||||
|
||||
def format_value(self, value):
|
||||
if value is not None and isinstance(value, Decimal):
|
||||
# Strip trailing 0s, leaving a minimum of 2 decimal places
|
||||
while (
|
||||
abs(value.as_tuple().exponent) > self.decimal_places
|
||||
and value.as_tuple().digits[-1] == 0
|
||||
):
|
||||
value = Decimal(str(value)[:-1])
|
||||
if value is not None and isinstance(value, (Decimal, float, str)):
|
||||
try:
|
||||
# Convert to Decimal if it's a float or string
|
||||
if isinstance(value, float):
|
||||
value = Decimal(value)
|
||||
elif isinstance(value, str):
|
||||
value = Decimal(convert_to_decimal(value))
|
||||
|
||||
value = floatformat(value, f"{self.decimal_places}g")
|
||||
# Remove trailing zeros
|
||||
value = value.normalize()
|
||||
|
||||
# Format the number using Django's localization
|
||||
formatted_value = number_format(
|
||||
value,
|
||||
force_grouping=False,
|
||||
decimal_pos=self.decimal_places,
|
||||
)
|
||||
|
||||
return formatted_value
|
||||
except (InvalidOperation, ValueError):
|
||||
# If there's an error in conversion, return the original value
|
||||
return value
|
||||
return value
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
|
||||
+13
-3
@@ -5,24 +5,31 @@ from django.contrib.auth.forms import (
|
||||
AdminPasswordChangeForm,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.users.models import User, UserSettings
|
||||
|
||||
admin.site.unregister(Group)
|
||||
|
||||
|
||||
class UserSettingsInline(admin.StackedInline):
|
||||
model = UserSettings
|
||||
can_delete = False
|
||||
extra = 0
|
||||
verbose_name_plural = _("User Settings")
|
||||
verbose_name = _("User Setting")
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin, ModelAdmin):
|
||||
ordering = ("email",)
|
||||
exclude = ("username",)
|
||||
list_display = ("email", "is_staff")
|
||||
search_fields = ("first_name", "last_name", "email")
|
||||
inlines = (UserSettingsInline,)
|
||||
|
||||
form = UserChangeForm
|
||||
add_form = UserCreationForm
|
||||
@@ -67,3 +74,6 @@ class UserAdmin(BaseUserAdmin, ModelAdmin):
|
||||
@admin.register(Group)
|
||||
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
|
||||
pass
|
||||
|
||||
|
||||
admin.site.register(UserSettings)
|
||||
|
||||
+15
-7
@@ -14,20 +14,18 @@ from django.contrib.auth.forms import (
|
||||
SetPasswordForm,
|
||||
PasswordChangeForm,
|
||||
UserCreationForm,
|
||||
AuthenticationForm,
|
||||
)
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_recaptcha.fields import ReCaptchaField
|
||||
from django_recaptcha.widgets import ReCaptchaV3
|
||||
from unfold.forms import AuthenticationForm
|
||||
|
||||
|
||||
class LoginForm(AuthenticationForm):
|
||||
username = UsernameField(
|
||||
label="Seu e-mail",
|
||||
widget=forms.TextInput(
|
||||
attrs={"class": "form-control", "placeholder": "E-mail"}
|
||||
widget=forms.EmailInput(
|
||||
attrs={"class": "form-control", "placeholder": "E-mail", "name": "email"}
|
||||
),
|
||||
)
|
||||
password = forms.CharField(
|
||||
@@ -39,6 +37,16 @@ class LoginForm(AuthenticationForm):
|
||||
)
|
||||
|
||||
error_messages = {
|
||||
"invalid_login": _("E-mail ou senha inválidos."),
|
||||
"inactive": _("Esta conta esta desativada."),
|
||||
"invalid_login": _("Invalid e-mail or password"),
|
||||
"inactive": _("This account is deactivated"),
|
||||
}
|
||||
|
||||
def __init__(self, request=None, *args, **kwargs):
|
||||
super().__init__(request, *args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.layout = Layout(
|
||||
"username",
|
||||
"password",
|
||||
Submit("Submit", "Login"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.1 on 2024-10-07 00:17
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserSettings',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('hide_amounts', models.BooleanField(default=False)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -17,3 +18,13 @@ class User(AbstractUser):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name} ({self.email})"
|
||||
|
||||
|
||||
class UserSettings(models.Model):
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), on_delete=models.CASCADE, related_name="settings"
|
||||
)
|
||||
hide_amounts = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email}'s settings"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from apps.users.models import UserSettings
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_settings(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UserSettings.objects.create(user=instance)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def save_user_settings(sender, instance, **kwargs):
|
||||
instance.settings.save()
|
||||
@@ -6,4 +6,9 @@ urlpatterns = [
|
||||
path("login/", views.UserLoginView.as_view(), name="login"),
|
||||
# path("login/fallback/", views.UserLoginView.as_view(), name="fallback_login"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
path(
|
||||
"user/toggle-amount-visibility/",
|
||||
views.toggle_amount_visibility,
|
||||
name="toggle_amount_visibility",
|
||||
),
|
||||
]
|
||||
|
||||
+29
-4
@@ -1,23 +1,48 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.views import (
|
||||
LoginView,
|
||||
)
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.users.forms import (
|
||||
LoginForm,
|
||||
EmailLoginForm,
|
||||
)
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
logout(request)
|
||||
return redirect(reverse("inicio"))
|
||||
return redirect(reverse("login"))
|
||||
|
||||
|
||||
class UserLoginView(LoginView):
|
||||
form_class = LoginForm
|
||||
# template_name = "users/login.html"
|
||||
template_name = "users/login.html"
|
||||
redirect_authenticated_user = True
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
def toggle_amount_visibility(request):
|
||||
current_hide_amounts = request.user.settings.hide_amounts
|
||||
new_hide_amounts = not current_hide_amounts
|
||||
|
||||
request.user.settings.hide_amounts = new_hide_amounts
|
||||
request.user.settings.save()
|
||||
|
||||
if new_hide_amounts is True:
|
||||
messages.info(request, _("Transaction amounts are now hidden"))
|
||||
response = HttpResponse(
|
||||
'<i class="fa-solid fa-eye-slash fa-fw"></i><div id="settings-hide-amounts" class="d-inline tw-invisible"></div>'
|
||||
)
|
||||
else:
|
||||
messages.info(request, _("Transaction amounts are now displayed"))
|
||||
response = HttpResponse('<i class="fa-solid fa-eye fa-fw"></i>')
|
||||
|
||||
response.headers["HX-Trigger"] = "transaction_updated, toast"
|
||||
return response
|
||||
|
||||
Reference in New Issue
Block a user