mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-04-23 17:18:44 +02:00
Merge pull request #206
feat(rules): trigger transaction rules on delete
This commit is contained in:
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
18
app/apps/rules/migrations/0013_transactionrule_on_delete.py
Normal file
18
app/apps/rules/migrations/0013_transactionrule_on_delete.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user