From f6f06f4d659c4cc0d4b519be45ee5f6897114674 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sun, 9 Mar 2025 01:54:03 -0300 Subject: [PATCH] feat(rules): trigger transaction rules on delete --- app/apps/rules/forms.py | 7 +- .../0013_transactionrule_on_delete.py | 18 ++ app/apps/rules/models.py | 1 + app/apps/rules/signals.py | 40 ++++ app/apps/rules/tasks.py | 187 ++++++++++++------ app/apps/transactions/models.py | 33 +++- 6 files changed, 217 insertions(+), 69 deletions(-) create mode 100644 app/apps/rules/migrations/0013_transactionrule_on_delete.py diff --git a/app/apps/rules/forms.py b/app/apps/rules/forms.py index 7067490..f552beb 100644 --- a/app/apps/rules/forms.py +++ b/app/apps/rules/forms.py @@ -20,6 +20,7 @@ class TransactionRuleForm(forms.ModelForm): labels = { "on_create": _("Run on creation"), "on_update": _("Run on update"), + "on_delete": _("Run on delete"), "trigger": _("If..."), } widgets = {"description": forms.widgets.TextInput} @@ -34,7 +35,11 @@ class TransactionRuleForm(forms.ModelForm): self.helper.layout = Layout( Switch("active"), "name", - Row(Column(Switch("on_update")), Column(Switch("on_create"))), + Row( + Column(Switch("on_update")), + Column(Switch("on_create")), + Column(Switch("on_delete")), + ), "description", "trigger", ) diff --git a/app/apps/rules/migrations/0013_transactionrule_on_delete.py b/app/apps/rules/migrations/0013_transactionrule_on_delete.py new file mode 100644 index 0000000..a6e121b --- /dev/null +++ b/app/apps/rules/migrations/0013_transactionrule_on_delete.py @@ -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), + ), + ] diff --git a/app/apps/rules/models.py b/app/apps/rules/models.py index ad094a0..5c520e0 100644 --- a/app/apps/rules/models.py +++ b/app/apps/rules/models.py @@ -9,6 +9,7 @@ class TransactionRule(SharedObject): active = models.BooleanField(default=True) on_update = models.BooleanField(default=False) on_create = models.BooleanField(default=True) + on_delete = models.BooleanField(default=False) 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")) diff --git a/app/apps/rules/signals.py b/app/apps/rules/signals.py index e0589c3..c9986d6 100644 --- a/app/apps/rules/signals.py +++ b/app/apps/rules/signals.py @@ -1,9 +1,11 @@ +from django.conf import settings from django.dispatch import receiver from apps.transactions.models import ( Transaction, transaction_created, transaction_updated, + transaction_deleted, ) from apps.rules.tasks import check_for_transaction_rules 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_updated) +@receiver(transaction_deleted) 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(): dca_entry.amount_paid = sender.amount dca_entry.save() diff --git a/app/apps/rules/tasks.py b/app/apps/rules/tasks.py index 1119cc6..d222324 100644 --- a/app/apps/rules/tasks.py +++ b/app/apps/rules/tasks.py @@ -1,6 +1,8 @@ import decimal import logging from datetime import datetime, date +from decimal import Decimal +from typing import Any from cachalot.api import cachalot_disabled from dateutil.relativedelta import relativedelta @@ -26,16 +28,27 @@ logger = logging.getLogger(__name__) @app.task(name="check_for_transaction_rules") def check_for_transaction_rules( - instance_id: int, - user_id: int, - signal, + instance_id=None, + transaction_data=None, + user_id=None, + signal=None, + is_hard_deleted=False, ): user = get_user_model().objects.get(id=user_id) write_current_user(user) try: 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 = { "relativedelta": relativedelta, @@ -47,10 +60,11 @@ def check_for_transaction_rules( "date": date, } - simple = EvalWithCompoundTypes( - names=_get_names(instance), functions=functions - ) + names = _get_names(instance) + simple = EvalWithCompoundTypes(names=names, functions=functions) + + # Select rules based on the signal type if signal == "transaction_created": rules = TransactionRule.objects.filter( active=True, on_create=True @@ -59,39 +73,56 @@ def check_for_transaction_rules( rules = TransactionRule.objects.filter( active=True, on_update=True ).order_by("id") + elif signal == "transaction_deleted": + rules = TransactionRule.objects.filter( + active=True, on_delete=True + ).order_by("id") else: rules = TransactionRule.objects.filter(active=True).order_by("id") + # Process the rules as before for rule in rules: if simple.eval(rule.trigger): - 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, - ) - # else: - # simple.names.update(_get_names(instance)) - # instance.save() + # For deleted transactions, we might want to limit what actions can be performed + if signal == "transaction_deleted": + # Process only create/update actions, not edit actions + 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} on deletion", + exc_info=True, + ) + 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)) - 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, - ) + simple.names.update(_get_names(instance)) + 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, + ) except Exception as e: logger.error( "Error while executing 'check_for_transaction_rules' task", @@ -99,40 +130,68 @@ def check_for_transaction_rules( ) delete_current_user() raise e - delete_current_user() -def _get_names(instance): - return { - "id": instance.id, - "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 - ), - "is_asset_account": instance.account.is_asset, - "is_archived_account": instance.account.is_archived, - "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()], - "entities_names": [x.name for x in instance.entities.all()], - "entities_ids": [x.id for x in instance.entities.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, - "internal_note": instance.internal_note, - "internal_id": instance.internal_id, - } +def _get_names(instance: Transaction | dict): + if isinstance(instance, Transaction): + return { + "id": instance.id, + "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 + ), + "is_asset_account": instance.account.is_asset, + "is_archived_account": instance.account.is_archived, + "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()], + "entities_names": [x.name for x in instance.entities.all()], + "entities_ids": [x.id for x in instance.entities.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, + "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): diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 5435056..16efed6 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -23,6 +23,7 @@ logger = logging.getLogger() transaction_created = Signal() transaction_updated = Signal() +transaction_deleted = Signal() class SoftDeleteQuerySet(models.QuerySet): @@ -65,8 +66,14 @@ class SoftDeleteQuerySet(models.QuerySet): def delete(self): if not settings.ENABLE_SOFT_DELETE: - # If soft deletion is disabled, perform a normal delete - return super().delete() + # Get instances before hard 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 already_deleted = self.filter(deleted=True) @@ -74,14 +81,28 @@ class SoftDeleteQuerySet(models.QuerySet): # Use a transaction to ensure atomicity 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 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 soft_deleted_count = not_deleted.update( 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 ( hard_deleted_count + soft_deleted_count, @@ -358,10 +379,14 @@ class Transaction(OwnedObject): self.deleted = True self.deleted_at = timezone.now() self.save() + transaction_deleted.send(sender=self) # Emit signal for soft delete else: - super().delete(*args, **kwargs) + result = super().delete(*args, **kwargs) + return result 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): super().delete(*args, **kwargs)