feat: add rules for transactions

This commit is contained in:
Herculino Trotta
2024-10-23 00:39:14 -03:00
parent 60017ac834
commit b9a9e279dc
30 changed files with 913 additions and 6 deletions
+4 -1
View File
@@ -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]
+9
View File
@@ -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()
View File
+7
View File
@@ -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)
+9
View File
@@ -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
+95
View File
@@ -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"
),
),
)
+33
View File
@@ -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')),
],
),
]
@@ -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'),
),
]
+44
View File
@@ -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}"
+20
View File
@@ -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"
),
)
+119
View File
@@ -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()
+70
View File
@@ -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/<int:transaction_rule_id>/view/",
views.transaction_rule_view,
name="transaction_rule_view",
),
path(
"rules/transaction/add/",
views.transaction_rule_add,
name="transaction_rule_add",
),
path(
"rules/transaction/<int:transaction_rule_id>/edit/",
views.transaction_rule_edit,
name="transaction_rule_edit",
),
path(
"rules/transaction/<int:transaction_rule_id>/toggle-active/",
views.transaction_rule_toggle_activity,
name="transaction_rule_toggle_activity",
),
path(
"rules/transaction/<int:transaction_rule_id>/delete/",
views.transaction_rule_delete,
name="transaction_rule_delete",
),
path(
"rules/transaction/<int:transaction_rule_id>/action/add/",
views.transaction_rule_action_add,
name="transaction_rule_action_add",
),
path(
"rules/transaction/action/<int:transaction_rule_action_id>/edit/",
views.transaction_rule_action_edit,
name="transaction_rule_action_edit",
),
path(
"rules/transaction/action/<int:transaction_rule_action_id>/delete/",
views.transaction_rule_action_delete,
name="transaction_rule_action_delete",
),
# path(
# "rules/<int:installment_plan_id>/transactions/",
# views.installment_plan_transactions,
# name="rule_view",
# ),
# path(
# "rules/<int:installment_plan_id>/edit/",
# views.installment_plan_edit,
# name="rule_edit",
# ),
# path(
# "rules/<int:installment_plan_id>/delete/",
# views.installment_plan_delete,
# name="rule_delete",
# ),
]
+216
View File
@@ -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",
},
)
+12 -1
View File
@@ -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(
-1
View File
@@ -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
@@ -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'),
),
]