feat(rules): add optional rules ordering

This commit is contained in:
Herculino Trotta
2025-08-31 09:06:48 -03:00
parent 670bee4325
commit 72904266bf
7 changed files with 211 additions and 91 deletions
+5 -1
View File
@@ -65,10 +65,11 @@ class TransactionRuleForm(forms.ModelForm):
class TransactionRuleActionForm(forms.ModelForm): class TransactionRuleActionForm(forms.ModelForm):
class Meta: class Meta:
model = TransactionRuleAction model = TransactionRuleAction
fields = ("value", "field") fields = ("value", "field", "order")
labels = { labels = {
"field": _("Set field"), "field": _("Set field"),
"value": _("To"), "value": _("To"),
"order": _("Order"),
} }
widgets = {"field": TomSelect(clear_button=False)} widgets = {"field": TomSelect(clear_button=False)}
@@ -82,6 +83,7 @@ class TransactionRuleActionForm(forms.ModelForm):
self.helper.form_method = "post" self.helper.form_method = "post"
# TO-DO: Add helper with available commands # TO-DO: Add helper with available commands
self.helper.layout = Layout( self.helper.layout = Layout(
"order",
"field", "field",
"value", "value",
) )
@@ -150,6 +152,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
} }
labels = { labels = {
"order": _("Order"),
"search_account_operator": _("Operator"), "search_account_operator": _("Operator"),
"search_type_operator": _("Operator"), "search_type_operator": _("Operator"),
"search_is_paid_operator": _("Operator"), "search_is_paid_operator": _("Operator"),
@@ -200,6 +203,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
self.helper.form_method = "post" self.helper.form_method = "post"
self.helper.layout = Layout( self.helper.layout = Layout(
"order",
BS5Accordion( BS5Accordion(
AccordionGroup( AccordionGroup(
_("Search Criteria"), _("Search Criteria"),
@@ -0,0 +1,39 @@
# Generated by Django 5.2 on 2025-08-30 18:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rules", "0014_alter_transactionrule_owner_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="transactionruleaction",
options={
"ordering": ["order"],
"verbose_name": "Edit transaction action",
"verbose_name_plural": "Edit transaction actions",
},
),
migrations.AlterModelOptions(
name="updateorcreatetransactionruleaction",
options={
"ordering": ["order"],
"verbose_name": "Update or create transaction action",
"verbose_name_plural": "Update or create transaction actions",
},
),
migrations.AddField(
model_name="transactionruleaction",
name="order",
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
),
migrations.AddField(
model_name="updateorcreatetransactionruleaction",
name="order",
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
),
]
+12
View File
@@ -51,6 +51,7 @@ class TransactionRuleAction(models.Model):
verbose_name=_("Field"), verbose_name=_("Field"),
) )
value = models.TextField(verbose_name=_("Value")) value = models.TextField(verbose_name=_("Value"))
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
def __str__(self): def __str__(self):
return f"{self.rule} - {self.field} - {self.value}" return f"{self.rule} - {self.field} - {self.value}"
@@ -59,6 +60,11 @@ class TransactionRuleAction(models.Model):
verbose_name = _("Edit transaction action") verbose_name = _("Edit transaction action")
verbose_name_plural = _("Edit transaction actions") verbose_name_plural = _("Edit transaction actions")
unique_together = (("rule", "field"),) unique_together = (("rule", "field"),)
ordering = ["order"]
@property
def action_type(self):
return "edit_transaction"
class UpdateOrCreateTransactionRuleAction(models.Model): class UpdateOrCreateTransactionRuleAction(models.Model):
@@ -290,10 +296,16 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name=_("Tags"), verbose_name=_("Tags"),
blank=True, blank=True,
) )
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
class Meta: class Meta:
verbose_name = _("Update or create transaction action") verbose_name = _("Update or create transaction action")
verbose_name_plural = _("Update or create transaction actions") verbose_name_plural = _("Update or create transaction actions")
ordering = ["order"]
@property
def action_type(self):
return "update_or_create_transaction"
def __str__(self): def __str__(self):
return f"Update or create transaction action for {self.rule}" return f"Update or create transaction action for {self.rule}"
+50 -4
View File
@@ -2,6 +2,7 @@ import decimal
import logging import logging
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal from decimal import Decimal
from itertools import chain
from cachalot.api import cachalot_disabled from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -87,7 +88,9 @@ def check_for_transaction_rules(
# For deleted transactions, we might want to limit what actions can be performed # For deleted transactions, we might want to limit what actions can be performed
if signal == "transaction_deleted": if signal == "transaction_deleted":
# Process only create/update actions, not edit actions # Process only create/update actions, not edit actions
for action in rule.update_or_create_transaction_actions.all(): for (
action
) in rule.update_or_create_transaction_actions.all():
try: try:
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
action=action, simple_eval=simple action=action, simple_eval=simple
@@ -99,10 +102,53 @@ def check_for_transaction_rules(
) )
else: else:
# Normal processing for non-deleted transactions # Normal processing for non-deleted transactions
for action in rule.transaction_actions.all(): edit_actions = list(rule.transaction_actions.all())
update_or_create_actions = list(
rule.update_or_create_transaction_actions.all()
)
# Check if any action has a non-zero order
has_custom_order = any(
a.order > 0 for a in edit_actions
) or any(a.order > 0 for a in update_or_create_actions)
if has_custom_order:
# Combine and sort actions by order
all_actions = sorted(
chain(edit_actions, update_or_create_actions),
key=lambda a: a.order,
)
for action in all_actions:
try:
if isinstance(action, TransactionRuleAction):
instance = _process_edit_transaction_action(
instance=instance,
action=action,
simple_eval=simple,
)
# Update names for next actions
simple.names.update(_get_names(instance))
else:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing action {action.id}",
exc_info=True,
)
# Save at the end
if signal != "transaction_deleted":
instance.save()
else:
# Original behavior
for action in edit_actions:
try: try:
instance = _process_edit_transaction_action( instance = _process_edit_transaction_action(
instance=instance, action=action, simple_eval=simple instance=instance,
action=action,
simple_eval=simple,
) )
except Exception as e: except Exception as e:
logger.error( logger.error(
@@ -114,7 +160,7 @@ def check_for_transaction_rules(
if signal != "transaction_deleted": if signal != "transaction_deleted":
instance.save() instance.save()
for action in rule.update_or_create_transaction_actions.all(): for action in update_or_create_actions:
try: try:
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
action=action, simple_eval=simple action=action, simple_eval=simple
+11 -1
View File
@@ -1,3 +1,5 @@
from itertools import chain
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpResponse from django.http import HttpResponse
@@ -140,10 +142,18 @@ def transaction_rule_edit(request, transaction_rule_id):
def transaction_rule_view(request, transaction_rule_id): def transaction_rule_view(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
edit_actions = transaction_rule.transaction_actions.all()
update_or_create_actions = transaction_rule.update_or_create_transaction_actions.all()
all_actions = sorted(
chain(edit_actions, update_or_create_actions),
key=lambda a: a.order,
)
return render( return render(
request, request,
"rules/fragments/transaction_rule/view.html", "rules/fragments/transaction_rule/view.html",
{"transaction_rule": transaction_rule}, {"transaction_rule": transaction_rule, "all_actions": all_actions},
) )
+1 -1
View File
@@ -175,6 +175,6 @@ class RecurringTransactionTests(TestCase):
recurrence_type=RecurringTransaction.RecurrenceType.MONTH, recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
recurrence_interval=1, recurrence_interval=1,
) )
self.assertFalse(recurring.paused) self.assertFalse(recurring.is_paused)
self.assertEqual(recurring.recurrence_interval, 1) self.assertEqual(recurring.recurrence_interval, 1)
self.assertEqual(recurring.account.currency.code, "USD") self.assertEqual(recurring.account.currency.code, "USD")
@@ -30,21 +30,27 @@
<div class="my-3"> <div class="my-3">
<div class="tw:text-xl mb-2">{% translate 'Then...' %}</div> <div class="tw:text-xl mb-2">{% translate 'Then...' %}</div>
{% for action in transaction_rule.transaction_actions.all %} {% for action in all_actions %}
{% if action.action_type == "edit_transaction" %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div> <div>
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
<span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div>{% translate 'Set' %} <span <div>
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div> {% translate 'Set' %} <span
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}
</div>
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div> <div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
</div> </div>
<div class="card-footer text-end"> <div class="card-footer text-end">
<a class="text-decoration-none tw:text-gray-400 p-1" <a class="text-decoration-none tw:text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate 'Edit' %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}" hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i> <i class="fa-solid fa-pencil fa-fw"></i>
@@ -52,23 +58,25 @@
<a class="text-danger text-decoration-none p-1" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate 'Delete' %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}" hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate 'Are you sure?' %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i> <i class="fa-solid fa-trash fa-fw"></i>
</a> </a>
</div> </div>
</div> </div>
{% endfor %} {% elif action.action_type == "update_or_create_transaction" %}
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div> <div>
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
<span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div>{% trans 'Edit to view' %}</div> <div>{% trans 'Edit to view' %}</div>
@@ -77,7 +85,7 @@
<a class="text-decoration-none tw:text-gray-400 p-1" <a class="text-decoration-none tw:text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate 'Edit' %}"
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}" hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i> <i class="fa-solid fa-pencil fa-fw"></i>
@@ -85,20 +93,21 @@
<a class="text-danger text-decoration-none p-1" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate 'Delete' %}"
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}" hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate 'Are you sure?' %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i> <i class="fa-solid fa-trash fa-fw"></i>
</a> </a>
</div> </div>
</div> </div>
{% endif %}
{% endfor %} {% endfor %}
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %} {% if not all_actions %}
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
{% translate 'This rule has no actions' %} {% translate 'This rule has no actions' %}