Merge pull request #206

feat(rules): trigger transaction rules on delete
This commit is contained in:
Herculino Trotta
2025-03-09 01:54:28 -03:00
committed by GitHub
6 changed files with 217 additions and 69 deletions

View File

@@ -20,6 +20,7 @@ class TransactionRuleForm(forms.ModelForm):
labels = { labels = {
"on_create": _("Run on creation"), "on_create": _("Run on creation"),
"on_update": _("Run on update"), "on_update": _("Run on update"),
"on_delete": _("Run on delete"),
"trigger": _("If..."), "trigger": _("If..."),
} }
widgets = {"description": forms.widgets.TextInput} widgets = {"description": forms.widgets.TextInput}
@@ -34,7 +35,11 @@ class TransactionRuleForm(forms.ModelForm):
self.helper.layout = Layout( self.helper.layout = Layout(
Switch("active"), Switch("active"),
"name", "name",
Row(Column(Switch("on_update")), Column(Switch("on_create"))), Row(
Column(Switch("on_update")),
Column(Switch("on_create")),
Column(Switch("on_delete")),
),
"description", "description",
"trigger", "trigger",
) )

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.7 on 2025-03-09 03:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0012_transactionrule_owner_transactionrule_shared_with_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='on_delete',
field=models.BooleanField(default=False),
),
]

View File

@@ -9,6 +9,7 @@ class TransactionRule(SharedObject):
active = models.BooleanField(default=True) active = models.BooleanField(default=True)
on_update = models.BooleanField(default=False) on_update = models.BooleanField(default=False)
on_create = models.BooleanField(default=True) on_create = models.BooleanField(default=True)
on_delete = models.BooleanField(default=False)
name = models.CharField(max_length=100, verbose_name=_("Name")) name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description")) description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger")) trigger = models.TextField(verbose_name=_("Trigger"))

View File

@@ -1,9 +1,11 @@
from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from apps.transactions.models import ( from apps.transactions.models import (
Transaction, Transaction,
transaction_created, transaction_created,
transaction_updated, transaction_updated,
transaction_deleted,
) )
from apps.rules.tasks import check_for_transaction_rules from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user from apps.common.middleware.thread_local import get_current_user
@@ -11,7 +13,45 @@ from apps.common.middleware.thread_local import get_current_user
@receiver(transaction_created) @receiver(transaction_created)
@receiver(transaction_updated) @receiver(transaction_updated)
@receiver(transaction_deleted)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs): def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
if signal is transaction_deleted:
# Serialize transaction data for processing
transaction_data = {
"id": sender.id,
"account": (sender.account.id, sender.account.name),
"account_group": (
sender.account.group.id if sender.account.group else None,
sender.account.group.name if sender.account.group else None,
),
"type": str(sender.type),
"is_paid": sender.is_paid,
"is_asset": sender.account.is_asset,
"is_archived": sender.account.is_archived,
"category": (
sender.category.id if sender.category else None,
sender.category.name if sender.category else None,
),
"date": sender.date.isoformat(),
"reference_date": sender.reference_date.isoformat(),
"amount": str(sender.amount),
"description": sender.description,
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"deleted": True,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,
}
check_for_transaction_rules.defer(
transaction_data=transaction_data,
user_id=get_current_user().id,
signal="transaction_deleted",
is_hard_deleted=kwargs.get("hard_delete", not settings.ENABLE_SOFT_DELETE),
)
return
for dca_entry in sender.dca_expense_entries.all(): for dca_entry in sender.dca_expense_entries.all():
dca_entry.amount_paid = sender.amount dca_entry.amount_paid = sender.amount
dca_entry.save() dca_entry.save()

View File

@@ -1,6 +1,8 @@
import decimal import decimal
import logging import logging
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal
from typing import Any
from cachalot.api import cachalot_disabled from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
@@ -26,16 +28,27 @@ logger = logging.getLogger(__name__)
@app.task(name="check_for_transaction_rules") @app.task(name="check_for_transaction_rules")
def check_for_transaction_rules( def check_for_transaction_rules(
instance_id: int, instance_id=None,
user_id: int, transaction_data=None,
signal, user_id=None,
signal=None,
is_hard_deleted=False,
): ):
user = get_user_model().objects.get(id=user_id) user = get_user_model().objects.get(id=user_id)
write_current_user(user) write_current_user(user)
try: try:
with cachalot_disabled(): with cachalot_disabled():
instance = Transaction.objects.get(id=instance_id) # For deleted transactions
if signal == "transaction_deleted" and transaction_data:
# Create a transaction-like object from the serialized data
if is_hard_deleted:
instance = transaction_data
else:
instance = Transaction.deleted_objects.get(id=instance_id)
else:
# Regular transaction processing for creates and updates
instance = Transaction.objects.get(id=instance_id)
functions = { functions = {
"relativedelta": relativedelta, "relativedelta": relativedelta,
@@ -47,10 +60,11 @@ def check_for_transaction_rules(
"date": date, "date": date,
} }
simple = EvalWithCompoundTypes( names = _get_names(instance)
names=_get_names(instance), functions=functions
)
simple = EvalWithCompoundTypes(names=names, functions=functions)
# Select rules based on the signal type
if signal == "transaction_created": if signal == "transaction_created":
rules = TransactionRule.objects.filter( rules = TransactionRule.objects.filter(
active=True, on_create=True active=True, on_create=True
@@ -59,39 +73,56 @@ def check_for_transaction_rules(
rules = TransactionRule.objects.filter( rules = TransactionRule.objects.filter(
active=True, on_update=True active=True, on_update=True
).order_by("id") ).order_by("id")
elif signal == "transaction_deleted":
rules = TransactionRule.objects.filter(
active=True, on_delete=True
).order_by("id")
else: else:
rules = TransactionRule.objects.filter(active=True).order_by("id") rules = TransactionRule.objects.filter(active=True).order_by("id")
# Process the rules as before
for rule in rules: for rule in rules:
if simple.eval(rule.trigger): if simple.eval(rule.trigger):
for action in rule.transaction_actions.all(): # For deleted transactions, we might want to limit what actions can be performed
try: if signal == "transaction_deleted":
instance = _process_edit_transaction_action( # Process only create/update actions, not edit actions
instance=instance, action=action, simple_eval=simple for action in rule.update_or_create_transaction_actions.all():
) try:
except Exception as e: _process_update_or_create_transaction_action(
logger.error( action=action, simple_eval=simple
f"Error processing edit transaction action {action.id}", )
exc_info=True, except Exception as e:
) logger.error(
# else: f"Error processing update or create transaction action {action.id} on deletion",
# simple.names.update(_get_names(instance)) exc_info=True,
# instance.save() )
else:
# Normal processing for non-deleted transactions
for action in rule.transaction_actions.all():
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)) simple.names.update(_get_names(instance))
instance.save() if signal != "transaction_deleted":
instance.save()
for action in rule.update_or_create_transaction_actions.all():
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,
)
for action in rule.update_or_create_transaction_actions.all():
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",
@@ -99,40 +130,68 @@ def check_for_transaction_rules(
) )
delete_current_user() delete_current_user()
raise e raise e
delete_current_user() delete_current_user()
def _get_names(instance): def _get_names(instance: Transaction | dict):
return { if isinstance(instance, Transaction):
"id": instance.id, return {
"account_name": instance.account.name, "id": instance.id,
"account_id": instance.account.id, "account_name": instance.account.name,
"account_group_name": ( "account_id": instance.account.id,
instance.account.group.name if instance.account.group else None "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 "account_group_id": (
), instance.account.group.id if instance.account.group else None
"is_asset_account": instance.account.is_asset, ),
"is_archived_account": instance.account.is_archived, "is_asset_account": instance.account.is_asset,
"category_name": instance.category.name if instance.category else None, "is_archived_account": instance.account.is_archived,
"category_id": instance.category.id if instance.category else None, "category_name": instance.category.name if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()], "category_id": instance.category.id if instance.category else None,
"tag_ids": [x.id for x in instance.tags.all()], "tag_names": [x.name for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()], "tag_ids": [x.id for x in instance.tags.all()],
"entities_ids": [x.id for x in instance.entities.all()], "entities_names": [x.name for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE, "entities_ids": [x.id for x in instance.entities.all()],
"is_income": instance.type == Transaction.Type.INCOME, "is_expense": instance.type == Transaction.Type.EXPENSE,
"is_paid": instance.is_paid, "is_income": instance.type == Transaction.Type.INCOME,
"description": instance.description, "is_paid": instance.is_paid,
"amount": instance.amount, "description": instance.description,
"notes": instance.notes, "amount": instance.amount,
"date": instance.date, "notes": instance.notes,
"reference_date": instance.reference_date, "date": instance.date,
"internal_note": instance.internal_note, "reference_date": instance.reference_date,
"internal_id": instance.internal_id, "internal_note": instance.internal_note,
} "internal_id": instance.internal_id,
"is_deleted": instance.deleted,
}
else:
return {
"id": instance.get("id"),
"account_name": instance.get("account", (None, None))[1],
"account_id": instance.get("account", (None, None))[0],
"account_group_name": instance.get("account_group", (None, None))[1],
"account_group_id": instance.get("account_group", (None, None))[0],
"is_asset_account": instance.get("is_asset"),
"is_archived_account": instance.get("is_archived"),
"category_name": instance.get("category", (None, None))[1],
"category_id": instance.get("category", (None, None))[0],
"tag_names": [x[1] for x in instance.get("tags", [])],
"tag_ids": [x[0] for x in instance.get("tags", [])],
"entities_names": [x[1] for x in instance.get("entities", [])],
"entities_ids": [x[0] for x in instance.get("entities", [])],
"is_expense": instance.get("type") == Transaction.Type.EXPENSE,
"is_income": instance.get("type") == Transaction.Type.INCOME,
"is_paid": instance.get("is_paid"),
"description": instance.get("description", ""),
"amount": Decimal(instance.get("amount")),
"notes": instance.get("notes", ""),
"date": datetime.fromisoformat(instance.get("date")),
"reference_date": datetime.fromisoformat(instance.get("reference_date")),
"internal_note": instance.get("internal_note", ""),
"internal_id": instance.get("internal_id", ""),
"is_deleted": instance.get("deleted", True),
}
def _process_update_or_create_transaction_action(action, simple_eval): def _process_update_or_create_transaction_action(action, simple_eval):

View File

@@ -23,6 +23,7 @@ logger = logging.getLogger()
transaction_created = Signal() transaction_created = Signal()
transaction_updated = Signal() transaction_updated = Signal()
transaction_deleted = Signal()
class SoftDeleteQuerySet(models.QuerySet): class SoftDeleteQuerySet(models.QuerySet):
@@ -65,8 +66,14 @@ class SoftDeleteQuerySet(models.QuerySet):
def delete(self): def delete(self):
if not settings.ENABLE_SOFT_DELETE: if not settings.ENABLE_SOFT_DELETE:
# If soft deletion is disabled, perform a normal delete # Get instances before hard delete
return super().delete() instances = list(self)
# Send signals for each instance before deletion
for instance in instances:
transaction_deleted.send(sender=instance)
# Perform hard delete
result = super().delete()
return result
# Separate the queryset into already deleted and not deleted objects # Separate the queryset into already deleted and not deleted objects
already_deleted = self.filter(deleted=True) already_deleted = self.filter(deleted=True)
@@ -74,14 +81,28 @@ class SoftDeleteQuerySet(models.QuerySet):
# Use a transaction to ensure atomicity # Use a transaction to ensure atomicity
with transaction.atomic(): with transaction.atomic():
# Get instances for hard delete before they're gone
already_deleted_instances = list(already_deleted)
for instance in already_deleted_instances:
transaction_deleted.send(sender=instance)
# Perform hard delete on already deleted objects # Perform hard delete on already deleted objects
hard_deleted_count = already_deleted._raw_delete(already_deleted.db) hard_deleted_count = already_deleted._raw_delete(already_deleted.db)
# Get instances for soft delete
instances_to_soft_delete = list(not_deleted)
# Perform soft delete on not deleted objects # Perform soft delete on not deleted objects
soft_deleted_count = not_deleted.update( soft_deleted_count = not_deleted.update(
deleted=True, deleted_at=timezone.now() deleted=True, deleted_at=timezone.now()
) )
# Send signals for soft deleted instances
for instance in instances_to_soft_delete:
instance.deleted = True
instance.deleted_at = timezone.now()
transaction_deleted.send(sender=instance)
# Return a tuple of counts as expected by Django's delete method # Return a tuple of counts as expected by Django's delete method
return ( return (
hard_deleted_count + soft_deleted_count, hard_deleted_count + soft_deleted_count,
@@ -358,10 +379,14 @@ class Transaction(OwnedObject):
self.deleted = True self.deleted = True
self.deleted_at = timezone.now() self.deleted_at = timezone.now()
self.save() self.save()
transaction_deleted.send(sender=self) # Emit signal for soft delete
else: else:
super().delete(*args, **kwargs) result = super().delete(*args, **kwargs)
return result
else: else:
super().delete(*args, **kwargs) # For hard delete mode
transaction_deleted.send(sender=self) # Emit signal before hard delete
return super().delete(*args, **kwargs)
def hard_delete(self, *args, **kwargs): def hard_delete(self, *args, **kwargs):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)