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

View File

@@ -69,6 +69,7 @@ INSTALLED_APPS = [
"rest_framework",
"drf_spectacular",
"django_cotton",
"apps.rules.apps.RulesConfig",
]
MIDDLEWARE = [

View File

@@ -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")),
]

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]

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
app/apps/rules/admin.py Normal file
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
app/apps/rules/apps.py Normal file
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
app/apps/rules/forms.py Normal file
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"
),
),
)

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')),
],
),
]

View File

@@ -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'),
),
]

View File

44
app/apps/rules/models.py Normal file
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
app/apps/rules/signals.py Normal file
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
app/apps/rules/tasks.py Normal file
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
app/apps/rules/urls.py Normal file
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
app/apps/rules/views.py Normal file
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",
},
)

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(

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

View File

@@ -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'),
),
]

View File

@@ -51,7 +51,7 @@
</ul>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {% active_link views='tags_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index' %}"
<a class="nav-link dropdown-toggle {% active_link views='tags_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index' %}"
href="#" role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
@@ -82,6 +82,12 @@
<li>
<hr class="dropdown-divider">
</li>
<li><h6 class="dropdown-header">{% trans 'Automation' %}</h6></li>
<li><a class="dropdown-item {% active_link views='rules_index' %}"
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li>
<a class="dropdown-item"
href="{% url 'admin:index' %}"

View File

@@ -19,6 +19,7 @@
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
on htmx:beforeOnLoad[detail.boosted] call bootstrap.Offcanvas.getOrCreateInstance(me).hide()
on force_hide_offcanvas call bootstrap.Offcanvas.getOrCreateInstance(me).hide() end
on hidden.bs.offcanvas set my innerHTML to '' end">
</div>
<div id="persistent-generic-offcanvas-left" class="offcanvas offcanvas-start offcanvas-size-xl"
@@ -26,5 +27,6 @@
tabindex="-1"
_="on htmx:afterSettle call bootstrap.Offcanvas.getOrCreateInstance(me).show() end
on htmx:beforeOnLoad[detail.boosted] call bootstrap.Offcanvas.getOrCreateInstance(me).hide()
on force_hide_offcanvas call bootstrap.Offcanvas.getOrCreateInstance(me).hide() end
on hidden.bs.offcanvas set my innerHTML to '' end">
</div>

View File

@@ -0,0 +1,73 @@
{% load i18n %}
<div class="container px-md-3 py-3 column-gap-5">
<div class="tw-text-3xl fw-bold font-monospace tw-w-full mb-3">
{% spaceless %}
<div>{% translate 'Rules' %}<span>
<a class="text-decoration-none tw-text-2xl p-1 category-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Add" %}"
hx-get="{% url 'transaction_rule_add' %}"
hx-target="#generic-offcanvas"
_="">
<i class="fa-solid fa-circle-plus fa-fw"></i></a>
</span></div>
{% endspaceless %}
</div>
<div class="border p-3 rounded-3 table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col-auto"></th>
<th scope="col" class="col">{% translate 'Name' %}</th>
</tr>
</thead>
<tbody>
{% for rule in transaction_rules %}
<tr class="transaction_rule">
<td class="col-auto">
{# <a class="text-decoration-none tw-text-gray-400 p-1"#}
{# role="button"#}
{# data-bs-toggle="tooltip"#}
{# data-bs-title="{% translate "Edit" %}"#}
{# hx-get="{% url 'transaction_rule_edit' transaction_rule_id=rule.id %}"#}
{# hx-target="#generic-offcanvas">#}
{# <i class="fa-solid fa-pencil fa-fw"></i></a>#}
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "View" %}"
hx-get="{% url 'transaction_rule_view' transaction_rule_id=rule.id %}"
hx-target="#persistent-generic-offcanvas-left">
<i class="fa-solid fa-eye fa-fw"></i></a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_delete' transaction_rule_id=rule.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</td>
<td class="col-auto">
<a class="text-decoration-none"
role="button"
hx-get="{% url 'transaction_rule_toggle_activity' transaction_rule_id=rule.id %}">
{% if rule.active %}<i class="fa-solid fa-toggle-on tw-text-green-400"></i>{% else %}<i class="fa-solid fa-toggle-off tw-text-red-400"></i>{% endif %}
</a>
</td>
<td class="col">
<div>{{ rule.name }}</div>
<div class="tw-text-gray-400 ps-2">{{ rule.description }}</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Add transaction rule' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_rule_add' %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit transaction rule' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_rule_edit' transaction_rule_id=transaction_rule.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -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 %}
<form hx-post="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule_id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit transaction rule action' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_rule_action_edit' transaction_rule_action_id=transaction_rule_action.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,79 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Transaction Rule' %}{% endblock %}
{% block body %}
<div hx-get="{% url 'transaction_rule_view' transaction_rule_id=transaction_rule.id %}" hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading">
<div class="tw-text-2xl">{{ transaction_rule.name }}</div>
<div class="tw-text-base tw-text-gray-400 ps-1">{{ transaction_rule.description }}</div>
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_edit' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<hr>
<div class="my-3">
<div class="tw-text-xl">If transaction...</div>
<div class="card">
<div class="card-body">
{{ transaction_rule.trigger }}
</div>
</div>
</div>
<div class="my-3">
<div class="tw-text-xl">Then...</div>
{% for action in transaction_rule.actions.all %}
<div class="card mb-3">
<div class="card-body">
<div class="mb-3">{% translate 'Set' %}</div>
<div class="mb-3">{{ action.get_field_display }}</div>
<div class="mb-3">{% translate 'to' %}</div>
<div class="mb-3">{{ action.value }}</div>
</div>
<div class="card-footer">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
{% translate 'This rule has no actions' %}
</div>
</div>
{% endfor %}
<a class="card"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
role="button"
hx-target="#generic-offcanvas">
<div class="card-body text-center">
{% translate 'Add new' %}
</div>
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Rules' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'rules_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}

View File

@@ -114,11 +114,11 @@
</div>
{# Item actions dropdown fallback for mobile#}
<div class="dropdown !tw-absolute tw-top-0 tw-right-0 xl:tw-invisible">
<a class="btn tw-text-2xl lg:tw-text-sm lg:tw-border-none text-light" type="button"
<button class="btn tw-text-2xl lg:tw-text-sm lg:tw-border-none text-light"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-ellipsis fa-fw"></i>
</a>
</button>
<ul class="dropdown-menu tw-text-base">
<li><a class="dropdown-item"
role="button"

View File

@@ -26,3 +26,4 @@ requests~=2.32.3
pytz~=2024.2
python-dateutil~=2.9.0.post0
simpleeval~=1.0.0