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

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

View File

@@ -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()

View File

@@ -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):

View File

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