diff --git a/app/apps/accounts/urls.py b/app/apps/accounts/urls.py index 24d9ad7..ae3d15a 100644 --- a/app/apps/accounts/urls.py +++ b/app/apps/accounts/urls.py @@ -8,7 +8,8 @@ urlpatterns = [ views.account_reconciliation, name="account_reconciliation", ), - path("accounts/", views.accounts_list, name="accounts_list"), + path("accounts/", views.accounts_index, name="accounts_index"), + path("accounts/list/", views.accounts_list, name="accounts_list"), path("account/add/", views.account_add, name="account_add"), path( "account//edit/", @@ -20,7 +21,8 @@ urlpatterns = [ views.account_delete, name="account_delete", ), - path("account-groups/", views.account_groups_list, name="account_groups_list"), + path("account-groups/", views.account_groups_index, name="account_groups_index"), + path("account-groups/list/", views.account_groups_list, name="account_groups_list"), path("account-groups/add/", views.account_group_add, name="account_group_add"), path( "account-groups//edit/", diff --git a/app/apps/accounts/views/account_groups.py b/app/apps/accounts/views/account_groups.py index b7a0d61..da64a41 100644 --- a/app/apps/accounts/views/account_groups.py +++ b/app/apps/accounts/views/account_groups.py @@ -12,12 +12,24 @@ from apps.accounts.models import AccountGroup from apps.common.decorators.htmx import only_htmx +@login_required +@require_http_methods(["GET"]) +def account_groups_index(request): + return render( + request, + "account_groups/pages/index.html", + ) + + +@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} + request, + "account_groups/fragments/list.html", + {"account_groups": account_groups}, ) @@ -34,8 +46,7 @@ def account_group_add(request, **kwargs): return HttpResponse( status=204, headers={ - "HX-Location": reverse("account_groups_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -63,8 +74,7 @@ def account_group_edit(request, pk): return HttpResponse( status=204, headers={ - "HX-Location": reverse("account_groups_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -90,5 +100,7 @@ def account_group_delete(request, pk): return HttpResponse( status=204, - headers={"HX-Location": reverse("account_groups_list")}, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, ) diff --git a/app/apps/accounts/views/accounts.py b/app/apps/accounts/views/accounts.py index 832aecc..6f09f07 100644 --- a/app/apps/accounts/views/accounts.py +++ b/app/apps/accounts/views/accounts.py @@ -12,11 +12,25 @@ from apps.accounts.models import Account from apps.common.decorators.htmx import only_htmx +@login_required +@require_http_methods(["GET"]) +def accounts_index(request): + return render( + request, + "accounts/pages/index.html", + ) + + +@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}) + return render( + request, + "accounts/fragments/list.html", + {"accounts": accounts}, + ) @only_htmx @@ -32,8 +46,7 @@ def account_add(request, **kwargs): return HttpResponse( status=204, headers={ - "HX-Location": reverse("accounts_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -61,8 +74,7 @@ def account_edit(request, pk): return HttpResponse( status=204, headers={ - "HX-Location": reverse("accounts_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -88,5 +100,7 @@ def account_delete(request, pk): return HttpResponse( status=204, - headers={"HX-Location": reverse("accounts_list")}, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, ) diff --git a/app/apps/api/views/transactions.py b/app/apps/api/views/transactions.py index ad79cd8..b15b443 100644 --- a/app/apps/api/views/transactions.py +++ b/app/apps/api/views/transactions.py @@ -32,3 +32,11 @@ class TransactionTagViewSet(viewsets.ModelViewSet): class InstallmentPlanViewSet(viewsets.ModelViewSet): queryset = InstallmentPlan.objects.all() serializer_class = InstallmentPlanSerializer + + def perform_create(self, serializer): + instance = serializer.save() + instance.create_transactions() + + def perform_update(self, serializer): + instance = serializer.save() + instance.create_transactions() diff --git a/app/apps/currencies/urls.py b/app/apps/currencies/urls.py index 85a5dcf..9f83c45 100644 --- a/app/apps/currencies/urls.py +++ b/app/apps/currencies/urls.py @@ -3,7 +3,8 @@ from django.urls import path from . import views urlpatterns = [ - path("currencies/", views.currency_list, name="currencies_list"), + path("currencies/", views.currencies_index, name="currencies_index"), + path("currencies/list/", views.currencies_list, name="currencies_list"), path("currencies/add/", views.currency_add, name="currency_add"), path( "currencies//edit/", diff --git a/app/apps/currencies/views.py b/app/apps/currencies/views.py index 46ca1cb..3d9b4d9 100644 --- a/app/apps/currencies/views.py +++ b/app/apps/currencies/views.py @@ -14,9 +14,23 @@ from apps.currencies.models import Currency @login_required @require_http_methods(["GET"]) -def currency_list(request): +def currencies_index(request): + return render( + request, + "currencies/pages/index.html", + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def currencies_list(request): currencies = Currency.objects.all().order_by("id") - return render(request, "currencies/pages/list.html", {"currencies": currencies}) + return render( + request, + "currencies/fragments/list.html", + {"currencies": currencies}, + ) @only_htmx @@ -32,8 +46,7 @@ def currency_add(request, **kwargs): return HttpResponse( status=204, headers={ - "HX-Location": reverse("currencies_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -61,8 +74,7 @@ def currency_edit(request, pk): return HttpResponse( status=204, headers={ - "HX-Location": reverse("currencies_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -88,5 +100,7 @@ def currency_delete(request, pk): return HttpResponse( status=204, - headers={"HX-Location": reverse("currencies_list")}, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, ) diff --git a/app/apps/monthly_overview/views/main.py b/app/apps/monthly_overview/views/main.py index 8c3f6cd..7039c92 100644 --- a/app/apps/monthly_overview/views/main.py +++ b/app/apps/monthly_overview/views/main.py @@ -103,6 +103,7 @@ def transactions_list(request, month: int, year: int): "tags", "account__exchange_currency", "account__currency", + "installment_plan", ) ) return render( diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index 861e309..cb68b70 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -312,44 +312,144 @@ class TransferForm(forms.Form): 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"), - ) - reference_date = MonthYearFormField(label=_("Reference Date"), required=False) - 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")), - ), - label=_("Recurrence"), - 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"), - ) +# 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"), +# ) +# reference_date = MonthYearFormField(label=_("Reference Date"), required=False) +# 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")), +# ), +# label=_("Recurrence"), +# 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("number_of_installments", css_class="form-group col-md-6 mb-0"), +# Column("recurrence", css_class="form-group col-md-6 mb-0"), +# css_class="form-row", +# ), +# Row( +# Column("start_date", css_class="form-group col-md-6 mb-0"), +# Column("reference_date", css_class="form-group col-md-6 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"] +# reference_date = self.cleaned_data["reference_date"] or 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"] +# +# print(reference_date, type(reference_date)) +# print(start_date, type(start_date)) +# +# 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 +# transaction_reference_date = (reference_date + delta).replace(day=1) +# new_transaction = Transaction.objects.create( +# account=account, +# type=transaction_type, +# date=transaction_date, +# is_paid=False, +# reference_date=transaction_reference_date, +# 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 InstallmentPlanForm(forms.ModelForm): tags = DynamicModelMultipleChoiceField( model=TransactionTag, to_field_name="name", @@ -357,6 +457,34 @@ class InstallmentPlanForm(forms.Form): required=False, label=_("Tags"), ) + category = DynamicModelChoiceField( + model=TransactionCategory, + required=False, + label=_("Category"), + ) + type = forms.ChoiceField(choices=Transaction.Type.choices) + reference_date = MonthYearFormField(label=_("Reference Date"), required=False) + + class Meta: + model = InstallmentPlan + fields = [ + "type", + "account", + "start_date", + "reference_date", + "description", + "number_of_installments", + "recurrence", + "installment_amount", + "category", + "tags", + "installment_start", + ] + widgets = { + "start_date": forms.DateInput(attrs={"type": "date"}, format="%Y-%m-%d"), + "account": TomSelect(), + "recurrence": TomSelect(clear_button=False), + } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -374,9 +502,10 @@ class InstallmentPlanForm(forms.Form): "description", Row( Column("number_of_installments", css_class="form-group col-md-6 mb-0"), - Column("recurrence", css_class="form-group col-md-6 mb-0"), + Column("installment_start", css_class="form-group col-md-6 mb-0"), css_class="form-row", ), + "recurrence", Row( Column("start_date", css_class="form-group col-md-6 mb-0"), Column("reference_date", css_class="form-group col-md-6 mb-0"), @@ -388,65 +517,37 @@ class InstallmentPlanForm(forms.Form): 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"] - reference_date = self.cleaned_data["reference_date"] or 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"] - - print(reference_date, type(reference_date)) - print(start_date, type(start_date)) - - with transaction.atomic(): - installment_plan = InstallmentPlan.objects.create( - account=account, - description=description, - number_of_installments=number_of_installments, + 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" + ), + ), ) - 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) + def save(self, **kwargs): + is_new = not self.instance.id - transaction_date = start_date + delta - transaction_reference_date = (reference_date + delta).replace(day=1) - new_transaction = Transaction.objects.create( - account=account, - type=transaction_type, - date=transaction_date, - is_paid=False, - reference_date=transaction_reference_date, - amount=installment_amount, - description=description, - notes=f"{i + 1}/{number_of_installments}", - category=category, - installment_plan=installment_plan, - ) + instance = super().save(**kwargs) + if is_new: + instance.create_transactions() + else: + instance.update_transactions() - new_transaction.tags.set(self.cleaned_data.get("tags", [])) - - return installment_plan + return instance class TransactionTagForm(forms.ModelForm): diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 4aa49e3..ece75dd 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -1,7 +1,10 @@ +import logging + from dateutil.relativedelta import relativedelta from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator -from django.db import models +from django.db import models, transaction +from django.db.models import Q from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ @@ -10,6 +13,8 @@ from apps.transactions.validators import validate_decimal_places, validate_non_n from apps.currencies.utils.convert import convert from apps.common.fields.month_year import MonthYearModelField +logger = logging.getLogger() + class TransactionCategory(models.Model): name = models.CharField(max_length=255, verbose_name=_("Name"), unique=True) @@ -36,28 +41,28 @@ 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 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): @@ -97,13 +102,14 @@ class Transaction(models.Model): tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True) installment_plan = models.ForeignKey( - InstallmentPlan, + "InstallmentPlan", on_delete=models.CASCADE, null=True, blank=True, related_name="transactions", verbose_name=_("Installment Plan"), ) + installment_id = models.PositiveIntegerField(null=True, blank=True) class Meta: verbose_name = _("Transaction") @@ -134,3 +140,186 @@ class Transaction(models.Model): } return None + + +class InstallmentPlan(models.Model): + class Recurrence(models.TextChoices): + YEARLY = "yearly", _("Yearly") + MONTHLY = "monthly", _("Monthly") + WEEKLY = "weekly", _("Weekly") + DAILY = "daily", _("Daily") + + account = models.ForeignKey( + "accounts.Account", on_delete=models.CASCADE, verbose_name=_("Account") + ) + type = models.CharField( + max_length=10, + choices=Transaction.Type, + verbose_name=_("Type"), + ) + description = models.CharField(max_length=500, verbose_name=_("Description")) + number_of_installments = models.PositiveIntegerField( + validators=[MinValueValidator(1)], + verbose_name=_("Number of Installments"), + default=1, + ) + installment_start = models.PositiveIntegerField( + validators=[MinValueValidator(1)], + verbose_name=_("Installment Start"), + help_text=_("The installment number to start counting from"), + blank=True, + default=1, + ) + installment_total_number = models.PositiveIntegerField() + start_date = models.DateField(verbose_name=_("Start Date")) + reference_date = models.DateField( + verbose_name=_("Reference Date"), null=True, blank=True + ) + end_date = models.DateField(verbose_name=_("End Date"), null=True, blank=True) + recurrence = models.CharField( + max_length=10, + choices=Recurrence, + default=Recurrence.MONTHLY, + verbose_name=_("Recurrence"), + ) + installment_amount = models.DecimalField( + max_digits=42, decimal_places=30, verbose_name=_("Installment Amount") + ) + category = models.ForeignKey( + "TransactionCategory", + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_("Category"), + ) + tags = models.ManyToManyField(TransactionTag, verbose_name=_("Tags"), blank=True) + + class Meta: + verbose_name = _("Installment Plan") + verbose_name_plural = _("Installment Plans") + + def __str__(self): + return self.description + + def save(self, *args, **kwargs): + if not self.reference_date: + self.reference_date = self.start_date.replace(day=1) + + if not self.installment_start: + self.installment_start = 1 + + self.end_date = self._calculate_end_date() + self.installment_total_number = self._calculate_installment_total_number() + + instance = super().save(*args, **kwargs) + return instance + + def _calculate_end_date(self): + if self.recurrence == self.Recurrence.YEARLY: + delta = relativedelta(years=self.number_of_installments - 1) + elif self.recurrence == self.Recurrence.MONTHLY: + delta = relativedelta(months=self.number_of_installments - 1) + elif self.recurrence == self.Recurrence.WEEKLY: + delta = relativedelta(weeks=self.number_of_installments - 1) + else: + delta = relativedelta(days=self.number_of_installments - 1) + + return self.start_date + delta + + def _calculate_installment_total_number(self): + return self.number_of_installments + (self.installment_start - 1) + + @transaction.atomic + def create_transactions(self): + self.transactions.all().delete() + + for i in range( + self.installment_start, + self.installment_total_number + 1, + ): + if self.recurrence == self.Recurrence.YEARLY: + delta = relativedelta(years=i - self.installment_start) + elif self.recurrence == self.Recurrence.MONTHLY: + delta = relativedelta(months=i - self.installment_start) + elif self.recurrence == self.Recurrence.WEEKLY: + delta = relativedelta(weeks=i - self.installment_start) + else: + delta = relativedelta(days=i - self.installment_start) + + transaction_date = self.start_date + delta + transaction_reference_date = (self.reference_date + delta).replace(day=1) + new_transaction = Transaction.objects.create( + account=self.account, + type=self.type, + date=transaction_date, + is_paid=False, + reference_date=transaction_reference_date, + amount=self.installment_amount, + description=self.description, + category=self.category, + installment_plan=self, + installment_id=i, + ) + new_transaction.tags.set(self.tags.all()) + + @transaction.atomic + def update_transactions(self): + existing_transactions = self.transactions.all().order_by("installment_id") + + for i in range(self.installment_start, self.installment_total_number + 1): + if self.recurrence == self.Recurrence.YEARLY: + delta = relativedelta(years=i - self.installment_start) + elif self.recurrence == self.Recurrence.MONTHLY: + delta = relativedelta(months=i - self.installment_start) + elif self.recurrence == self.Recurrence.WEEKLY: + delta = relativedelta(weeks=i - self.installment_start) + else: + delta = relativedelta(days=i - self.installment_start) + + transaction_date = self.start_date + delta + transaction_reference_date = (self.reference_date + delta).replace(day=1) + + # Get the existing transaction or None if it doesn't exist + existing_transaction = existing_transactions.filter( + installment_id=i + ).first() + + if existing_transaction: + # Update existing transaction + existing_transaction.account = self.account + existing_transaction.type = self.type + existing_transaction.date = transaction_date + existing_transaction.reference_date = transaction_reference_date + existing_transaction.amount = self.installment_amount + existing_transaction.description = self.description + existing_transaction.category = self.category + existing_transaction.save() + + # Update tags + existing_transaction.tags.set(self.tags.all()) + else: + # If the transaction doesn't exist, create a new one + new_transaction = Transaction.objects.create( + account=self.account, + type=self.type, + date=transaction_date, + is_paid=False, + reference_date=transaction_reference_date, + amount=self.installment_amount, + description=self.description, + category=self.category, + installment_plan=self, + installment_id=i, + ) + new_transaction.tags.set(self.tags.all()) + + # Remove any extra transactions that are no longer part of the plan + self.transactions.filter( + Q(installment_id__gt=self.installment_total_number) + | Q(installment_id__lt=self.installment_start) + ).delete() + + def delete(self, *args, **kwargs): + # Delete related transactions + self.transactions.all().delete() + super().delete(*args, **kwargs) diff --git a/app/apps/transactions/urls.py b/app/apps/transactions/urls.py index e9d1adc..b6444f4 100644 --- a/app/apps/transactions/urls.py +++ b/app/apps/transactions/urls.py @@ -42,12 +42,8 @@ urlpatterns = [ 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/", views.tags_index, name="tags_index"), + path("tags/list/", views.tags_list, name="tags_list"), path("tags/add/", views.tag_add, name="tag_add"), path( "tags//edit/", @@ -59,7 +55,8 @@ urlpatterns = [ views.tag_delete, name="tag_delete", ), - path("categories/", views.categories_list, name="categories_list"), + path("categories/", views.categories_index, name="categories_index"), + path("categories/list/", views.categories_list, name="categories_list"), path("categories/add/", views.category_add, name="category_add"), path( "categories//edit/", @@ -71,4 +68,39 @@ urlpatterns = [ views.category_delete, name="category_delete", ), + path( + "installment-plans/", + views.installment_plans_index, + name="installment_plans_index", + ), + path( + "installment-plans/list/", + views.installment_plans_list, + name="installment_plans_list", + ), + path( + "installment-plans/add/", + views.installment_plan_add, + name="installment_plan_add", + ), + path( + "installment-plans//transactions/", + views.installment_plan_transactions, + name="installment_plan_transactions", + ), + path( + "installment-plans//edit/", + views.installment_plan_edit, + name="installment_plan_edit", + ), + path( + "installment-plans//delete/", + views.installment_plan_delete, + name="installment_plan_delete", + ), + path( + "installment-plans//refresh/", + views.installment_plan_refresh, + name="installment_plan_refresh", + ), ] diff --git a/app/apps/transactions/views/__init__.py b/app/apps/transactions/views/__init__.py index 59f4f98..fa19c76 100644 --- a/app/apps/transactions/views/__init__.py +++ b/app/apps/transactions/views/__init__.py @@ -2,3 +2,4 @@ from .transactions import * from .tags import * from .categories import * from .actions import * +from .installment_plans import * diff --git a/app/apps/transactions/views/actions.py b/app/apps/transactions/views/actions.py index 76906b3..6fbe855 100644 --- a/app/apps/transactions/views/actions.py +++ b/app/apps/transactions/views/actions.py @@ -33,9 +33,7 @@ def bulk_unpay_transactions(request): @login_required def bulk_delete_transactions(request): selected_transactions = request.GET.getlist("transactions", []) - Transaction.objects.filter( - id__in=selected_transactions, installment_plan__isnull=True - ).delete() + Transaction.objects.filter(id__in=selected_transactions).delete() return HttpResponse( status=204, diff --git a/app/apps/transactions/views/categories.py b/app/apps/transactions/views/categories.py index 8ccff2b..a665623 100644 --- a/app/apps/transactions/views/categories.py +++ b/app/apps/transactions/views/categories.py @@ -12,11 +12,25 @@ from apps.transactions.forms import TransactionCategoryForm from apps.transactions.models import TransactionCategory +@login_required +@require_http_methods(["GET"]) +def categories_index(request): + return render( + request, + "categories/pages/index.html", + ) + + +@only_htmx @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}) + return render( + request, + "categories/fragments/list.html", + {"categories": categories}, + ) @only_htmx @@ -32,8 +46,7 @@ def category_add(request, **kwargs): return HttpResponse( status=204, headers={ - "HX-Location": reverse("categories_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -61,8 +74,7 @@ def category_edit(request, category_id): return HttpResponse( status=204, headers={ - "HX-Location": reverse("categories_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -88,5 +100,7 @@ def category_delete(request, category_id): return HttpResponse( status=204, - headers={"HX-Location": reverse("categories_list")}, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, ) diff --git a/app/apps/transactions/views/installment_plans.py b/app/apps/transactions/views/installment_plans.py new file mode 100644 index 0000000..d429cfc --- /dev/null +++ b/app/apps/transactions/views/installment_plans.py @@ -0,0 +1,139 @@ +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.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 InstallmentPlanForm +from apps.transactions.models import InstallmentPlan + + +@login_required +@require_http_methods(["GET"]) +def installment_plans_index(request): + return render( + request, + "installment_plans/pages/index.html", + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def installment_plans_list(request): + installment_plans = InstallmentPlan.objects.all().order_by("-end_date") + + return render( + request, + "installment_plans/fragments/list.html", + {"installment_plans": installment_plans}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def installment_plan_transactions(request, installment_plan_id): + installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id) + transactions = installment_plan.transactions.all().order_by("reference_date", "id") + print(transactions) + + return render( + request, + "installment_plans/fragments/list_transactions.html", + {"installment_plan": installment_plan, "transactions": transactions}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def installment_plan_add(request): + if request.method == "POST": + form = InstallmentPlanForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, _("Installment Plan added successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + else: + form = InstallmentPlanForm() + + return render( + request, + "installment_plans/fragments/add.html", + {"form": form}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def installment_plan_edit(request, installment_plan_id): + installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id) + + if request.method == "POST": + form = InstallmentPlanForm(request.POST, instance=installment_plan) + if form.is_valid(): + form.save() + messages.success(request, _("Installment Plan updated successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + else: + form = InstallmentPlanForm(instance=installment_plan) + + return render( + request, + "installment_plans/fragments/edit.html", + {"form": form, "installment_plan": installment_plan}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def installment_plan_refresh(request, installment_plan_id): + installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id) + installment_plan.update_transactions() + + messages.success(request, _("Installment Plan refreshed successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + + +@only_htmx +@login_required +@csrf_exempt +@require_http_methods(["DELETE"]) +def installment_plan_delete(request, installment_plan_id): + installment_plan = get_object_or_404(InstallmentPlan, id=installment_plan_id) + + installment_plan.delete() + + messages.success(request, _("Installment Plan deleted successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) diff --git a/app/apps/transactions/views/tags.py b/app/apps/transactions/views/tags.py index 26ba097..f4f3ae8 100644 --- a/app/apps/transactions/views/tags.py +++ b/app/apps/transactions/views/tags.py @@ -14,9 +14,23 @@ from apps.transactions.models import TransactionTag @login_required @require_http_methods(["GET"]) -def tag_list(request): +def tags_index(request): + return render( + request, + "tags/pages/index.html", + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def tags_list(request): tags = TransactionTag.objects.all().order_by("id") - return render(request, "tags/pages/list.html", {"tags": tags}) + return render( + request, + "tags/fragments/list.html", + {"tags": tags}, + ) @only_htmx @@ -32,8 +46,7 @@ def tag_add(request, **kwargs): return HttpResponse( status=204, headers={ - "HX-Location": reverse("tags_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -61,8 +74,7 @@ def tag_edit(request, tag_id): return HttpResponse( status=204, headers={ - "HX-Location": reverse("tags_list"), - "HX-Trigger": "hide_offcanvas, toasts", + "HX-Trigger": "updated, hide_offcanvas, toasts", }, ) else: @@ -88,5 +100,7 @@ def tag_delete(request, tag_id): return HttpResponse( status=204, - headers={"HX-Location": reverse("tags_list")}, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, ) diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index c987314..4532384 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -88,17 +88,9 @@ def transaction_edit(request, transaction_id, **kwargs): 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() + transaction.delete() - messages.success(request, _("Transaction deleted successfully")) + messages.success(request, _("Transaction deleted successfully")) return HttpResponse( status=204, @@ -152,30 +144,9 @@ def transaction_pay(request, transaction_id): response = render( request, "transactions/fragments/item.html", - context={"transaction": transaction}, + context={"transaction": transaction, **request.GET}, ) 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": "updated, hide_offcanvas, toast"}, - ) - - return render(request, self.template_name, {"form": form}) diff --git a/app/apps/users/managers.py b/app/apps/users/managers.py index b19dc79..5ff5635 100644 --- a/app/apps/users/managers.py +++ b/app/apps/users/managers.py @@ -10,7 +10,7 @@ class UserManager(BaseUserManager): email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) - user.save(using=self._db) + user.save() return user def create_user(self, email, password=None, **extra_fields): diff --git a/app/templates/account_groups/pages/list.html b/app/templates/account_groups/fragments/list.html similarity index 84% rename from app/templates/account_groups/pages/list.html rename to app/templates/account_groups/fragments/list.html index be65a8d..c28ee95 100644 --- a/app/templates/account_groups/pages/list.html +++ b/app/templates/account_groups/fragments/list.html @@ -1,9 +1,4 @@ -{% extends "layouts/base.html" %} {% load i18n %} - -{% block title %}{% translate 'Accounts' %}{% endblock %} - -{% block content %}
{% spaceless %} @@ -20,8 +15,8 @@ {% endspaceless %}
-
- +
+
@@ -32,14 +27,14 @@ {% for account_group in account_groups %}