From b9a9e279dc00b97e794f47bac439201664adbd22 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Wed, 23 Oct 2024 00:39:14 -0300 Subject: [PATCH] feat: add rules for transactions --- app/WYGIWYH/settings.py | 1 + app/WYGIWYH/urls.py | 1 + app/apps/api/serializers/transactions.py | 5 +- app/apps/api/views/transactions.py | 9 + app/apps/rules/__init__.py | 0 app/apps/rules/admin.py | 7 + app/apps/rules/apps.py | 9 + app/apps/rules/forms.py | 95 ++++++++ app/apps/rules/migrations/0001_initial.py | 33 +++ ...tive_transactionrule_on_create_and_more.py | 39 ++++ app/apps/rules/migrations/__init__.py | 0 app/apps/rules/models.py | 44 ++++ app/apps/rules/signals.py | 20 ++ app/apps/rules/tasks.py | 119 ++++++++++ app/apps/rules/urls.py | 70 ++++++ app/apps/rules/views.py | 216 ++++++++++++++++++ app/apps/transactions/forms.py | 13 +- app/apps/transactions/views/tags.py | 1 - .../0009_alter_usersettings_start_page.py | 18 ++ app/templates/includes/navbar.html | 8 +- app/templates/includes/offcanvas.html | 2 + app/templates/rules/fragments/list.html | 73 ++++++ .../rules/fragments/transaction_rule/add.html | 11 + .../fragments/transaction_rule/edit.html | 11 + .../transaction_rule_action/add.html | 11 + .../transaction_rule_action/edit.html | 11 + .../fragments/transaction_rule/view.html | 79 +++++++ app/templates/rules/pages/index.html | 8 + .../transactions/fragments/item.html | 4 +- requirements.txt | 1 + 30 files changed, 913 insertions(+), 6 deletions(-) create mode 100644 app/apps/rules/__init__.py create mode 100644 app/apps/rules/admin.py create mode 100644 app/apps/rules/apps.py create mode 100644 app/apps/rules/forms.py create mode 100644 app/apps/rules/migrations/0001_initial.py create mode 100644 app/apps/rules/migrations/0002_transactionrule_active_transactionrule_on_create_and_more.py create mode 100644 app/apps/rules/migrations/__init__.py create mode 100644 app/apps/rules/models.py create mode 100644 app/apps/rules/signals.py create mode 100644 app/apps/rules/tasks.py create mode 100644 app/apps/rules/urls.py create mode 100644 app/apps/rules/views.py create mode 100644 app/apps/users/migrations/0009_alter_usersettings_start_page.py create mode 100644 app/templates/rules/fragments/list.html create mode 100644 app/templates/rules/fragments/transaction_rule/add.html create mode 100644 app/templates/rules/fragments/transaction_rule/edit.html create mode 100644 app/templates/rules/fragments/transaction_rule/transaction_rule_action/add.html create mode 100644 app/templates/rules/fragments/transaction_rule/transaction_rule_action/edit.html create mode 100644 app/templates/rules/fragments/transaction_rule/view.html create mode 100644 app/templates/rules/pages/index.html diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index 94a1abd..e5709d7 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -69,6 +69,7 @@ INSTALLED_APPS = [ "rest_framework", "drf_spectacular", "django_cotton", + "apps.rules.apps.RulesConfig", ] MIDDLEWARE = [ diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index 6324361..df82b58 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -43,4 +43,5 @@ urlpatterns = [ path("", include("apps.monthly_overview.urls")), path("", include("apps.yearly_overview.urls")), path("", include("apps.currencies.urls")), + path("", include("apps.rules.urls")), ] diff --git a/app/apps/api/serializers/transactions.py b/app/apps/api/serializers/transactions.py index 89a5e8e..0f0cf9d 100644 --- a/app/apps/api/serializers/transactions.py +++ b/app/apps/api/serializers/transactions.py @@ -1,4 +1,7 @@ from django.utils.translation import gettext_lazy as _ +from drf_spectacular import openapi +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.permissions import IsAuthenticated @@ -54,7 +57,7 @@ class TransactionSerializer(serializers.ModelSerializer): ) reference_date = serializers.DateField( - required=False, input_formats=["iso-8601", "%Y-%m"] + required=False, input_formats=["iso-8601", "%Y-%m"], format="%Y-%m" ) permission_classes = [IsAuthenticated] diff --git a/app/apps/api/views/transactions.py b/app/apps/api/views/transactions.py index b15b443..336fa29 100644 --- a/app/apps/api/views/transactions.py +++ b/app/apps/api/views/transactions.py @@ -12,12 +12,21 @@ from apps.transactions.models import ( TransactionTag, InstallmentPlan, ) +from apps.rules.signals import transaction_updated, transaction_created class TransactionViewSet(viewsets.ModelViewSet): queryset = Transaction.objects.all() serializer_class = TransactionSerializer + def perform_create(self, serializer): + instance = serializer.save() + transaction_created.send(sender=instance) + + def perform_update(self, serializer): + instance = serializer.save() + transaction_updated.send(sender=instance) + class TransactionCategoryViewSet(viewsets.ModelViewSet): queryset = TransactionCategory.objects.all() diff --git a/app/apps/rules/__init__.py b/app/apps/rules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/rules/admin.py b/app/apps/rules/admin.py new file mode 100644 index 0000000..d89b498 --- /dev/null +++ b/app/apps/rules/admin.py @@ -0,0 +1,7 @@ +from django.contrib import admin + +from apps.rules.models import TransactionRule, TransactionRuleAction + +# Register your models here. +admin.site.register(TransactionRule) +admin.site.register(TransactionRuleAction) diff --git a/app/apps/rules/apps.py b/app/apps/rules/apps.py new file mode 100644 index 0000000..e9fb6aa --- /dev/null +++ b/app/apps/rules/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class RulesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.rules" + + def ready(self): + import apps.rules.signals diff --git a/app/apps/rules/forms.py b/app/apps/rules/forms.py new file mode 100644 index 0000000..f86134d --- /dev/null +++ b/app/apps/rules/forms.py @@ -0,0 +1,95 @@ +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, Row, Column +from django import forms +from django.utils.translation import gettext_lazy as _ + +from apps.rules.models import TransactionRule +from apps.rules.models import TransactionRuleAction +from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.tom_select import TomSelect + + +class TransactionRuleForm(forms.ModelForm): + class Meta: + model = TransactionRule + fields = "__all__" + labels = { + "on_create": _("Run on creation"), + "on_update": _("Run on update"), + "trigger": _("If..."), + } + widgets = {"description": forms.widgets.TextInput} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.form_method = "post" + # TO-DO: Add helper with available commands + self.helper.layout = Layout( + Switch("active"), + "name", + Row(Column(Switch("on_update")), Column(Switch("on_create"))), + "description", + "trigger", + ) + + 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 TransactionRuleActionForm(forms.ModelForm): + class Meta: + model = TransactionRuleAction + fields = ("value", "field") + labels = { + "field": _("Set field"), + "value": _("To"), + } + widgets = {"field": TomSelect(clear_button=False)} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.form_method = "post" + # TO-DO: Add helper with available commands + self.helper.layout = Layout( + "field", + "value", + ) + + 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" + ), + ), + ) diff --git a/app/apps/rules/migrations/0001_initial.py b/app/apps/rules/migrations/0001_initial.py new file mode 100644 index 0000000..48fdcf0 --- /dev/null +++ b/app/apps/rules/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.2 on 2024-10-22 15:37 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TransactionRule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('trigger', models.TextField(verbose_name='Trigger')), + ], + ), + migrations.CreateModel( + name='TransactionRuleAction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('field', models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags')], max_length=50, verbose_name='Field')), + ('value', models.TextField(verbose_name='Action')), + ('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='rules.transactionrule')), + ], + ), + ] diff --git a/app/apps/rules/migrations/0002_transactionrule_active_transactionrule_on_create_and_more.py b/app/apps/rules/migrations/0002_transactionrule_active_transactionrule_on_create_and_more.py new file mode 100644 index 0000000..c677614 --- /dev/null +++ b/app/apps/rules/migrations/0002_transactionrule_active_transactionrule_on_create_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.1.2 on 2024-10-22 17:52 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rules', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='transactionrule', + name='active', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='transactionrule', + name='on_create', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='transactionrule', + name='on_update', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='transactionruleaction', + name='rule', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to='rules.transactionrule', verbose_name='Rule'), + ), + migrations.AlterField( + model_name='transactionruleaction', + name='value', + field=models.TextField(verbose_name='Value'), + ), + ] diff --git a/app/apps/rules/migrations/__init__.py b/app/apps/rules/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/rules/models.py b/app/apps/rules/models.py new file mode 100644 index 0000000..80f2fe0 --- /dev/null +++ b/app/apps/rules/models.py @@ -0,0 +1,44 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class TransactionRule(models.Model): + active = models.BooleanField(default=True) + on_update = models.BooleanField(default=False) + on_create = models.BooleanField(default=True) + name = models.CharField(max_length=100, verbose_name=_("Name")) + description = models.TextField(blank=True, null=True, verbose_name=_("Description")) + trigger = models.TextField(verbose_name=_("Trigger")) + + def __str__(self): + return self.name + + +class TransactionRuleAction(models.Model): + class Field(models.TextChoices): + account = "account", _("Account") + type = "type", _("Type") + is_paid = "is_paid", _("Paid") + date = "date", _("Date") + reference_date = "reference_date", _("Reference Date") + amount = "amount", _("Amount") + description = "description", _("Description") + notes = "notes", _("Notes") + category = "category", _("Category") + tags = "tags", _("Tags") + + rule = models.ForeignKey( + TransactionRule, + on_delete=models.CASCADE, + related_name="actions", + verbose_name=_("Rule"), + ) + field = models.CharField( + max_length=50, + choices=Field, + verbose_name=_("Field"), + ) + value = models.TextField(verbose_name=_("Value")) + + def __str__(self): + return f"{self.rule} - {self.field} - {self.value}" diff --git a/app/apps/rules/signals.py b/app/apps/rules/signals.py new file mode 100644 index 0000000..c96fbd2 --- /dev/null +++ b/app/apps/rules/signals.py @@ -0,0 +1,20 @@ +from django.dispatch import Signal, receiver + +from apps.transactions.models import Transaction +from apps.rules.tasks import check_for_transaction_rules + +transaction_created = Signal() +transaction_updated = Signal() + + +@receiver(transaction_created) +@receiver(transaction_updated) +def transaction_changed_receiver(sender: Transaction, signal, **kwargs): + check_for_transaction_rules.defer( + instance_id=sender.id, + signal=( + "transaction_created" + if signal is transaction_created + else "transaction_updated" + ), + ) diff --git a/app/apps/rules/tasks.py b/app/apps/rules/tasks.py new file mode 100644 index 0000000..a00b9f2 --- /dev/null +++ b/app/apps/rules/tasks.py @@ -0,0 +1,119 @@ +from cachalot.api import cachalot_disabled, invalidate +from dateutil.relativedelta import relativedelta +from procrastinate.contrib.django import app +from simpleeval import EvalWithCompoundTypes + +from apps.accounts.models import Account +from apps.rules.models import TransactionRule, TransactionRuleAction +from apps.transactions.models import Transaction, TransactionCategory, TransactionTag + + +@app.task +def check_for_transaction_rules( + instance_id: int, + signal, +): + with cachalot_disabled(): + + instance = Transaction.objects.get(id=instance_id) + + context = { + "account_name": instance.account.name, + "account_id": instance.account.id, + "account_group_name": ( + instance.account.group.name if instance.account.group else None + ), + "account_group_id": ( + instance.account.group.id if instance.account.group else None + ), + "category_name": instance.category.name if instance.category else None, + "category_id": instance.category.id if instance.category else None, + "tag_names": [x.name for x in instance.tags.all()], + "tag_ids": [x.id for x in instance.tags.all()], + "is_expense": instance.type == Transaction.Type.EXPENSE, + "is_income": instance.type == Transaction.Type.INCOME, + "is_paid": instance.is_paid, + "description": instance.description, + "amount": instance.amount, + "notes": instance.notes, + "date": instance.date, + "reference_date": instance.reference_date, + } + + functions = {"relative_delta": relativedelta} + + simple = EvalWithCompoundTypes(names=context, functions=functions) + + if signal == "transaction_created": + rules = TransactionRule.objects.filter(active=True, on_create=True) + elif signal == "transaction_updated": + rules = TransactionRule.objects.filter(active=True, on_update=True) + else: + rules = TransactionRule.objects.filter(active=True) + + for rule in rules: + if simple.eval(rule.trigger): + print("True!") + for action in rule.actions.all(): + if action.field in [ + TransactionRuleAction.Field.type, + TransactionRuleAction.Field.is_paid, + TransactionRuleAction.Field.date, + TransactionRuleAction.Field.reference_date, + TransactionRuleAction.Field.amount, + TransactionRuleAction.Field.description, + TransactionRuleAction.Field.notes, + ]: + print(1) + setattr( + instance, + action.field, + simple.eval(action.value), + ) + + elif action.field == TransactionRuleAction.Field.account: + print(2) + value = simple.eval(action.value) + if isinstance(value, int): + account = Account.objects.get(id=value) + instance.account = account + elif isinstance(value, str): + account = Account.objects.filter(name=value).first() + instance.account = account + + elif action.field == TransactionRuleAction.Field.category: + print(3) + value = simple.eval(action.value) + if isinstance(value, int): + category = TransactionCategory.objects.get(id=value) + instance.category = category + elif isinstance(value, str): + category = TransactionCategory.objects.get(name=value) + instance.category = category + + elif action.field == TransactionRuleAction.Field.tags: + print(4) + value = simple.eval(action.value) + print(value, action.value) + if isinstance(value, list): + # Clear existing tags + instance.tags.clear() + for tag_value in value: + if isinstance(tag_value, int): + tag = TransactionTag.objects.get(id=tag_value) + instance.tags.add(tag) + elif isinstance(tag_value, str): + tag = TransactionTag.objects.get(name=tag_value) + instance.tags.add(tag) + + elif isinstance(value, (int, str)): + # If a single value is provided, treat it as a single tag + instance.tags.clear() + if isinstance(value, int): + tag = TransactionTag.objects.get(id=value) + else: + tag = TransactionTag.objects.get(name=value) + + instance.tags.add(tag) + + instance.save() diff --git a/app/apps/rules/urls.py b/app/apps/rules/urls.py new file mode 100644 index 0000000..7dc9a1c --- /dev/null +++ b/app/apps/rules/urls.py @@ -0,0 +1,70 @@ +from django.urls import path +import apps.rules.views as views + +urlpatterns = [ + path( + "rules/", + views.rules_index, + name="rules_index", + ), + path( + "rules/list/", + views.rules_list, + name="rules_list", + ), + path( + "rules/transaction//view/", + views.transaction_rule_view, + name="transaction_rule_view", + ), + path( + "rules/transaction/add/", + views.transaction_rule_add, + name="transaction_rule_add", + ), + path( + "rules/transaction//edit/", + views.transaction_rule_edit, + name="transaction_rule_edit", + ), + path( + "rules/transaction//toggle-active/", + views.transaction_rule_toggle_activity, + name="transaction_rule_toggle_activity", + ), + path( + "rules/transaction//delete/", + views.transaction_rule_delete, + name="transaction_rule_delete", + ), + path( + "rules/transaction//action/add/", + views.transaction_rule_action_add, + name="transaction_rule_action_add", + ), + path( + "rules/transaction/action//edit/", + views.transaction_rule_action_edit, + name="transaction_rule_action_edit", + ), + path( + "rules/transaction/action//delete/", + views.transaction_rule_action_delete, + name="transaction_rule_action_delete", + ), + # path( + # "rules//transactions/", + # views.installment_plan_transactions, + # name="rule_view", + # ), + # path( + # "rules//edit/", + # views.installment_plan_edit, + # name="rule_edit", + # ), + # path( + # "rules//delete/", + # views.installment_plan_delete, + # name="rule_delete", + # ), +] diff --git a/app/apps/rules/views.py b/app/apps/rules/views.py new file mode 100644 index 0000000..9e1ea9c --- /dev/null +++ b/app/apps/rules/views.py @@ -0,0 +1,216 @@ +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, redirect +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.rules.forms import TransactionRuleForm, TransactionRuleActionForm +from apps.rules.models import TransactionRule, TransactionRuleAction + + +@login_required +@require_http_methods(["GET"]) +def rules_index(request): + return render( + request, + "rules/pages/index.html", + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def rules_list(request): + transaction_rules = TransactionRule.objects.all().order_by("id") + return render( + request, + "rules/fragments/list.html", + {"transaction_rules": transaction_rules}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_rule_toggle_activity(request, transaction_rule_id, **kwargs): + transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) + current_active = transaction_rule.active + transaction_rule.active = not current_active + transaction_rule.save(update_fields=["active"]) + + if current_active: + messages.success(request, _("Rule deactivated successfully")) + else: + messages.success(request, _("Rule activated successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_rule_add(request, **kwargs): + if request.method == "POST": + form = TransactionRuleForm(request.POST) + if form.is_valid(): + instance = form.save() + messages.success(request, _("Rule added successfully")) + + return redirect("transaction_rule_action_add", instance.id) + else: + form = TransactionRuleForm() + + return render( + request, + "rules/fragments/transaction_rule/add.html", + {"form": form}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_rule_edit(request, transaction_rule_id): + transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) + + if request.method == "POST": + form = TransactionRuleForm(request.POST, instance=transaction_rule) + if form.is_valid(): + form.save() + messages.success(request, _("Rule updated successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + else: + form = TransactionRuleForm(instance=transaction_rule) + + return render( + request, + "rules/fragments/transaction_rule/edit.html", + {"form": form, "transaction_rule": transaction_rule}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_rule_view(request, transaction_rule_id): + transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) + + return render( + request, + "rules/fragments/transaction_rule/view.html", + {"transaction_rule": transaction_rule}, + ) + + +@only_htmx +@login_required +@csrf_exempt +@require_http_methods(["DELETE"]) +def transaction_rule_delete(request, transaction_rule_id): + transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) + + transaction_rule.delete() + + messages.success(request, _("Rule deleted successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_rule_action_add(request, transaction_rule_id): + transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) + + if request.method == "POST": + form = TransactionRuleActionForm(request.POST) + if form.is_valid(): + action = form.save(commit=False) + action.rule = transaction_rule + action.save() + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + else: + form = TransactionRuleActionForm() + + return render( + request, + "rules/fragments/transaction_rule/transaction_rule_action/add.html", + {"form": form, "transaction_rule_id": transaction_rule_id}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def transaction_rule_action_edit(request, transaction_rule_action_id): + transaction_rule_action = get_object_or_404( + TransactionRuleAction, id=transaction_rule_action_id + ) + + if request.method == "POST": + form = TransactionRuleActionForm(request.POST, instance=transaction_rule_action) + if form.is_valid(): + action = form.save(commit=False) + action.rule = transaction_rule_action.rule + action.save() + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) + else: + form = TransactionRuleActionForm(instance=transaction_rule_action) + + return render( + request, + "rules/fragments/transaction_rule/transaction_rule_action/edit.html", + {"form": form, "transaction_rule_action": transaction_rule_action}, + ) + + +@only_htmx +@login_required +@csrf_exempt +@require_http_methods(["DELETE"]) +def transaction_rule_action_delete(request, transaction_rule_action_id): + transaction_rule_action = get_object_or_404( + TransactionRuleAction, id=transaction_rule_action_id + ) + + transaction_rule_action.delete() + + messages.success(request, _("Action deleted successfully")) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas, toasts", + }, + ) diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py index 1ffa242..3379abf 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -15,7 +15,6 @@ from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, ) -from apps.common.fields.forms.grouped_select import GroupedModelChoiceField from apps.common.fields.month_year import MonthYearFormField from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput @@ -27,6 +26,7 @@ from apps.transactions.models import ( InstallmentPlan, RecurringTransaction, ) +from apps.rules.signals import transaction_created, transaction_updated class TransactionForm(forms.ModelForm): @@ -126,6 +126,17 @@ class TransactionForm(forms.ModelForm): return cleaned_data + def save(self, **kwargs): + is_new = not self.instance.id + + instance = super().save(**kwargs) + if is_new: + transaction_created.send(sender=instance) + else: + transaction_updated.send(sender=instance) + + return instance + class TransferForm(forms.Form): from_account = forms.ModelChoiceField( diff --git a/app/apps/transactions/views/tags.py b/app/apps/transactions/views/tags.py index f4f3ae8..7cbabfa 100644 --- a/app/apps/transactions/views/tags.py +++ b/app/apps/transactions/views/tags.py @@ -2,7 +2,6 @@ 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 diff --git a/app/apps/users/migrations/0009_alter_usersettings_start_page.py b/app/apps/users/migrations/0009_alter_usersettings_start_page.py new file mode 100644 index 0000000..1448678 --- /dev/null +++ b/app/apps/users/migrations/0009_alter_usersettings_start_page.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-10-22 15:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0008_usersettings_start_page'), + ] + + operations = [ + migrations.AlterField( + model_name='usersettings', + name='start_page', + field=models.CharField(choices=[('MONTHLY_OVERVIEW', 'Monthly Overview'), ('YEARLY_OVERVIEW', 'Yearly Overview'), ('NETWORTH', 'Net Worth')], default='MONTHLY_OVERVIEW', max_length=255, verbose_name='Start page'), + ), + ] diff --git a/app/templates/includes/navbar.html b/app/templates/includes/navbar.html index 8cee77d..80f5973 100644 --- a/app/templates/includes/navbar.html +++ b/app/templates/includes/navbar.html @@ -51,7 +51,7 @@
  • {% translate 'Rules' %}
  • +
  • + +
  • diff --git a/app/templates/rules/fragments/list.html b/app/templates/rules/fragments/list.html new file mode 100644 index 0000000..3b7e5e9 --- /dev/null +++ b/app/templates/rules/fragments/list.html @@ -0,0 +1,73 @@ +{% load i18n %} +
    + + +
    + + + + + + + + + + {% for rule in transaction_rules %} + + + + + + {% endfor %} + +
    {% translate 'Name' %}
    +{# #} +{# #} + + + + + + {% if rule.active %}{% else %}{% endif %} + + +
    {{ rule.name }}
    +
    {{ rule.description }}
    +
    +
    +
    diff --git a/app/templates/rules/fragments/transaction_rule/add.html b/app/templates/rules/fragments/transaction_rule/add.html new file mode 100644 index 0000000..27e1b9e --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/add.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Add transaction rule' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/edit.html b/app/templates/rules/fragments/transaction_rule/edit.html new file mode 100644 index 0000000..078a3bd --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/edit.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Edit transaction rule' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/transaction_rule_action/add.html b/app/templates/rules/fragments/transaction_rule/transaction_rule_action/add.html new file mode 100644 index 0000000..c47ffcc --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/transaction_rule_action/add.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Add action to transaction rule' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/transaction_rule_action/edit.html b/app/templates/rules/fragments/transaction_rule/transaction_rule_action/edit.html new file mode 100644 index 0000000..bb728c3 --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/transaction_rule_action/edit.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Edit transaction rule action' %}{% endblock %} + +{% block body %} +
    + {% crispy form %} +
    +{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/view.html b/app/templates/rules/fragments/transaction_rule/view.html new file mode 100644 index 0000000..8f9f783 --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/view.html @@ -0,0 +1,79 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Transaction Rule' %}{% endblock %} + +{% block body %} +
    +
    {{ transaction_rule.name }}
    +
    {{ transaction_rule.description }}
    + + +
    +
    +
    If transaction...
    +
    +
    + {{ transaction_rule.trigger }} +
    +
    +
    + +
    +
    Then...
    + {% for action in transaction_rule.actions.all %} +
    +
    +
    {% translate 'Set' %}
    +
    {{ action.get_field_display }}
    +
    {% translate 'to' %}
    +
    {{ action.value }}
    +
    + +
    + {% empty %} +
    +
    + {% translate 'This rule has no actions' %} +
    +
    + {% endfor %} + +
    + {% translate 'Add new' %} +
    +
    +
    +
    +{% endblock %} diff --git a/app/templates/rules/pages/index.html b/app/templates/rules/pages/index.html new file mode 100644 index 0000000..ec0ebbf --- /dev/null +++ b/app/templates/rules/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Rules' %}{% endblock %} + +{% block content %} +
    +{% endblock %} diff --git a/app/templates/transactions/fragments/item.html b/app/templates/transactions/fragments/item.html index 47f2a76..9ed9a40 100644 --- a/app/templates/transactions/fragments/item.html +++ b/app/templates/transactions/fragments/item.html @@ -114,11 +114,11 @@ {# Item actions dropdown fallback for mobile#}