Merge pull request #356

feat(rules): add optional rules ordering
This commit is contained in:
Herculino Trotta
2025-08-31 09:07:07 -03:00
committed by GitHub
7 changed files with 211 additions and 91 deletions

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

View File

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

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}"

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,31 +102,74 @@ 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())
try: update_or_create_actions = list(
instance = _process_edit_transaction_action( rule.update_or_create_transaction_actions.all()
instance=instance, action=action, simple_eval=simple )
)
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
)
simple.names.update(_get_names(instance)) # Check if any action has a non-zero order
if signal != "transaction_deleted": has_custom_order = any(
instance.save() a.order > 0 for a in edit_actions
) or any(a.order > 0 for a in update_or_create_actions)
for action in rule.update_or_create_transaction_actions.all(): if has_custom_order:
try: # Combine and sort actions by order
_process_update_or_create_transaction_action( all_actions = sorted(
action=action, simple_eval=simple chain(edit_actions, update_or_create_actions),
) key=lambda a: a.order,
except Exception as e: )
logger.error(
f"Error processing update or create transaction action {action.id}", for action in all_actions:
exc_info=True, 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:
instance = _process_edit_transaction_action(
instance=instance,
action=action,
simple_eval=simple,
)
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
)
simple.names.update(_get_names(instance))
if signal != "transaction_deleted":
instance.save()
for action in update_or_create_actions:
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id}",
exc_info=True,
)
except Exception as e: except Exception as e:
logger.error( logger.error(
"Error while executing 'check_for_transaction_rules' task", "Error while executing 'check_for_transaction_rules' task",

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},
) )

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")

View File

@@ -30,75 +30,84 @@
<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 %}
<div class="card mb-3"> {% if action.action_type == "edit_transaction" %}
<div class="card-header"> <div class="card mb-3">
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div> <div class="card-header">
</div> <div>
<div class="card-body"> {% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
<div>{% translate 'Set' %} <span <span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span>
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div> </div>
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div> </div>
</div> <div class="card-body">
<div class="card-footer text-end"> <div>
<a class="text-decoration-none tw:text-gray-400 p-1" {% translate 'Set' %} <span
role="button" class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}
data-bs-toggle="tooltip" </div>
data-bs-title="{% translate "Edit" %}" <div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}" </div>
hx-target="#generic-offcanvas"> <div class="card-footer text-end">
<i class="fa-solid fa-pencil fa-fw"></i> <a class="text-decoration-none tw:text-gray-400 p-1"
</a> role="button"
<a class="text-danger text-decoration-none p-1" data-bs-toggle="tooltip"
role="button" data-bs-title="{% translate 'Edit' %}"
data-bs-toggle="tooltip" hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
data-bs-title="{% translate "Delete" %}" hx-target="#generic-offcanvas">
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}" <i class="fa-solid fa-pencil fa-fw"></i>
hx-trigger='confirmed' </a>
data-bypass-on-ctrl="true" <a class="text-danger text-decoration-none p-1"
data-title="{% translate "Are you sure?" %}" role="button"
data-text="{% translate "You won't be able to revert this!" %}" data-bs-toggle="tooltip"
data-confirm-text="{% translate "Yes, delete it!" %}" data-bs-title="{% translate 'Delete' %}"
_="install prompt_swal"> 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> <i class="fa-solid fa-trash fa-fw"></i>
</a> </a>
</div>
</div> </div>
</div> {% elif action.action_type == "update_or_create_transaction" %}
{% endfor %} <div class="card mb-3">
{% for action in transaction_rule.update_or_create_transaction_actions.all %} <div class="card-header">
<div class="card mb-3"> <div>
<div class="card-header"> {% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div> <span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span>
</div> </div>
<div class="card-body"> </div>
<div>{% trans 'Edit to view' %}</div> <div class="card-body">
</div> <div>{% trans 'Edit to view' %}</div>
<div class="card-footer text-end"> </div>
<a class="text-decoration-none tw:text-gray-400 p-1" <div class="card-footer text-end">
role="button" <a class="text-decoration-none tw:text-gray-400 p-1"
data-bs-toggle="tooltip" role="button"
data-bs-title="{% translate "Edit" %}" data-bs-toggle="tooltip"
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}" data-bs-title="{% translate 'Edit' %}"
hx-target="#generic-offcanvas"> hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
<i class="fa-solid fa-pencil fa-fw"></i> hx-target="#generic-offcanvas">
</a> <i class="fa-solid fa-pencil fa-fw"></i>
<a class="text-danger text-decoration-none p-1" </a>
role="button" <a class="text-danger text-decoration-none p-1"
data-bs-toggle="tooltip" role="button"
data-bs-title="{% translate "Delete" %}" data-bs-toggle="tooltip"
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}" data-bs-title="{% translate 'Delete' %}"
hx-trigger='confirmed' hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
data-bypass-on-ctrl="true" hx-trigger='confirmed'
data-title="{% translate "Are you sure?" %}" data-bypass-on-ctrl="true"
data-text="{% translate "You won't be able to revert this!" %}" data-title="{% translate 'Are you sure?' %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-text="{% translate "You won't be able to revert this!" %}"
_="install prompt_swal"> data-confirm-text="{% translate 'Yes, delete it!' %}"
_="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' %}