"))
+
+ # Change submit button
+ self.helper.layout[-1] = FormActions(
+ NoClassSubmit(
+ "submit", _("Test"), css_class="btn btn-outline-primary w-100"
+ )
+ )
+
+ if self.data.get("transaction"):
+ try:
+ transaction = Transaction.objects.get(id=self.data.get("transaction"))
+ except Transaction.DoesNotExist:
+ transaction = None
+
+ if transaction:
+ self.fields["transaction"].queryset = Transaction.objects.filter(
+ id=transaction.id
+ )
diff --git a/app/apps/rules/migrations/0016_transactionrule_sequenced.py b/app/apps/rules/migrations/0016_transactionrule_sequenced.py
new file mode 100644
index 0000000..76d7e25
--- /dev/null
+++ b/app/apps/rules/migrations/0016_transactionrule_sequenced.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.5 on 2025-08-31 18:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rules', '0015_alter_transactionruleaction_options_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='transactionrule',
+ name='sequenced',
+ field=models.BooleanField(default=False, verbose_name='Sequenced'),
+ ),
+ ]
diff --git a/app/apps/rules/migrations/0017_updateorcreatetransactionruleaction_search_mute_and_more.py b/app/apps/rules/migrations/0017_updateorcreatetransactionruleaction_search_mute_and_more.py
new file mode 100644
index 0000000..139d05b
--- /dev/null
+++ b/app/apps/rules/migrations/0017_updateorcreatetransactionruleaction_search_mute_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.5 on 2025-08-31 19:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rules', '0016_transactionrule_sequenced'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='updateorcreatetransactionruleaction',
+ name='search_mute',
+ field=models.TextField(blank=True, verbose_name='Search Mute'),
+ ),
+ migrations.AddField(
+ model_name='updateorcreatetransactionruleaction',
+ name='search_mute_operator',
+ field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Mute Operator'),
+ ),
+ migrations.AddField(
+ model_name='updateorcreatetransactionruleaction',
+ name='set_mute',
+ field=models.TextField(blank=True, verbose_name='Mute'),
+ ),
+ migrations.AlterField(
+ model_name='transactionruleaction',
+ name='field',
+ field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
+ ),
+ ]
diff --git a/app/apps/rules/migrations/0018_transactionrule_order.py b/app/apps/rules/migrations/0018_transactionrule_order.py
new file mode 100644
index 0000000..4f64f6a
--- /dev/null
+++ b/app/apps/rules/migrations/0018_transactionrule_order.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.5 on 2025-09-02 14:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='transactionrule',
+ name='order',
+ field=models.PositiveIntegerField(default=0, verbose_name='Order'),
+ ),
+ ]
diff --git a/app/apps/rules/models.py b/app/apps/rules/models.py
index edaebe7..653c87c 100644
--- a/app/apps/rules/models.py
+++ b/app/apps/rules/models.py
@@ -13,6 +13,11 @@ class TransactionRule(SharedObject):
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"))
+ sequenced = models.BooleanField(
+ verbose_name=_("Sequenced"),
+ default=False,
+ )
+ order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
@@ -32,12 +37,15 @@ class TransactionRuleAction(models.Model):
is_paid = "is_paid", _("Paid")
date = "date", _("Date")
reference_date = "reference_date", _("Reference Date")
+ mute = "mute", _("Mute")
amount = "amount", _("Amount")
description = "description", _("Description")
notes = "notes", _("Notes")
category = "category", _("Category")
tags = "tags", _("Tags")
entities = "entities", _("Entities")
+ internal_note = "internal_nome", _("Internal Note")
+ internal_id = "internal_id", _("Internal ID")
rule = models.ForeignKey(
TransactionRule,
@@ -243,6 +251,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name="Internal ID Operator",
)
+ search_mute = models.TextField(
+ verbose_name="Search Mute",
+ blank=True,
+ )
+ search_mute_operator = models.CharField(
+ max_length=10,
+ choices=SearchOperator.choices,
+ default=SearchOperator.EXACT,
+ verbose_name="Mute Operator",
+ )
+
# Set fields
set_account = models.TextField(
verbose_name=_("Account"),
@@ -296,6 +315,11 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name=_("Tags"),
blank=True,
)
+ set_mute = models.TextField(
+ verbose_name=_("Mute"),
+ blank=True,
+ )
+
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
class Meta:
@@ -337,6 +361,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
value = simple.eval(self.search_is_paid)
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
+ if self.search_mute:
+ value = simple.eval(self.search_mute)
+ search_query &= add_to_query("mute", value, self.search_mute_operator)
+
if self.search_date:
value = simple.eval(self.search_date)
search_query &= add_to_query("date", value, self.search_date_operator)
diff --git a/app/apps/rules/signals.py b/app/apps/rules/signals.py
index c9986d6..e390b12 100644
--- a/app/apps/rules/signals.py
+++ b/app/apps/rules/signals.py
@@ -9,40 +9,17 @@ from apps.transactions.models import (
)
from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
+from apps.rules.utils.transactions import serialize_transaction
@receiver(transaction_created)
@receiver(transaction_updated)
@receiver(transaction_deleted)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
+ old_data = kwargs.get("old_data")
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,
- }
+ transaction_data = serialize_transaction(sender, deleted=True)
check_for_transaction_rules.defer(
transaction_data=transaction_data,
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
dca_entry.amount_received = sender.amount
dca_entry.save()
+ if signal is transaction_updated and old_data:
+ old_data = serialize_transaction(old_data, deleted=False)
+
check_for_transaction_rules.defer(
instance_id=sender.id,
user_id=get_current_user().id,
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
if signal is transaction_created
else "transaction_updated"
),
+ old_data=old_data,
)
diff --git a/app/apps/rules/tasks.py b/app/apps/rules/tasks.py
index c8dca41..ca06b7e 100644
--- a/app/apps/rules/tasks.py
+++ b/app/apps/rules/tasks.py
@@ -1,12 +1,17 @@
import decimal
import logging
+import traceback
from datetime import datetime, date
from decimal import Decimal
from itertools import chain
+from pprint import pformat
+from random import randint, random
+from typing import Literal
from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta
from django.contrib.auth import get_user_model
+from django.forms import model_to_dict
from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes
@@ -27,16 +32,467 @@ from apps.rules.utils import transactions
logger = logging.getLogger(__name__)
+class DryRunResults:
+ def __init__(self):
+ self.results = []
+
+ def header(self, header: str, action):
+ result = {"type": "header", "header_type": header, "action": action}
+ self.results.append(result)
+
+ def triggering_transaction(self, instance):
+ result = {
+ "type": "triggering_transaction",
+ "transaction": instance,
+ }
+ self.results.append(result)
+
+ def edit_transaction(
+ self, instance, action, old_value, new_value, field, tags, entities
+ ):
+ result = {
+ "type": "edit_transaction",
+ "transaction": instance.deepcopy(),
+ "action": action,
+ "old_value": old_value,
+ "new_value": new_value,
+ "field": field,
+ "tags": tags,
+ "entities": entities,
+ }
+ self.results.append(result)
+
+ def update_or_create_transaction(
+ self,
+ updated: bool,
+ action,
+ query,
+ tags,
+ entities,
+ start_instance=None,
+ end_instance=None,
+ ):
+ result = {
+ "type": "update_or_create_transaction",
+ "start_transaction": start_instance,
+ "end_transaction": end_instance,
+ "updated": updated,
+ "action": action,
+ "query": query,
+ "tags": tags,
+ "entities": entities,
+ }
+ self.results.append(result)
+
+ def error(self, error, level: Literal["error", "warning", "info"] = "error"):
+ result = {
+ "type": "error",
+ "error": error,
+ "traceback": traceback.format_exc(),
+ "level": level,
+ }
+ self.results.append(result)
+
+
@app.task(name="check_for_transaction_rules")
def check_for_transaction_rules(
instance_id=None,
transaction_data=None,
+ old_data=None,
user_id=None,
signal=None,
is_hard_deleted=False,
+ dry_run=False,
+ rule_id=None,
):
+ def _log(message: str, level="info"):
+ if dry_run:
+ if logs is not None:
+ logs.append(message)
+ if level == "error":
+ logs.append(traceback.format_exc())
+ else:
+ if level == "info":
+ logger.info(message)
+ elif level == "error":
+ logger.error(message, exc_info=True)
+
+ def _clear_names(prefix: str):
+ for k in list(simple.names.keys()):
+ if k.startswith(prefix):
+ del simple.names[k]
+
+ def _get_names(transaction: Transaction | dict, prefix: str = ""):
+ if isinstance(transaction, Transaction):
+ return {
+ "is_on_create": True if signal == "transaction_created" else False,
+ "is_on_delete": True if signal == "transaction_deleted" else False,
+ "is_on_update": True if signal == "transaction_updated" else False,
+ f"{prefix}id": transaction.id,
+ f"{prefix}account_name": (
+ transaction.account.name if transaction.id else None
+ ),
+ f"{prefix}account_id": (
+ transaction.account.id if transaction.id else None
+ ),
+ f"{prefix}account_group_name": (
+ transaction.account.group.name
+ if transaction.id and transaction.account.group
+ else None
+ ),
+ f"{prefix}account_group_id": (
+ transaction.account.group.id
+ if transaction.id and transaction.account.group
+ else None
+ ),
+ f"{prefix}is_asset_account": (
+ transaction.account.is_asset if transaction.id else None
+ ),
+ f"{prefix}is_archived_account": (
+ transaction.account.is_archived if transaction.id else None
+ ),
+ f"{prefix}category_name": (
+ transaction.category.name if transaction.category else None
+ ),
+ f"{prefix}category_id": (
+ transaction.category.id if transaction.category else None
+ ),
+ f"{prefix}tag_names": (
+ [x.name for x in transaction.tags.all()] if transaction.id else []
+ ),
+ f"{prefix}tag_ids": (
+ [x.id for x in transaction.tags.all()] if transaction.id else []
+ ),
+ f"{prefix}entities_names": (
+ [x.name for x in transaction.entities.all()]
+ if transaction.id
+ else []
+ ),
+ f"{prefix}entities_ids": (
+ [x.id for x in transaction.entities.all()] if transaction.id else []
+ ),
+ f"{prefix}is_expense": transaction.type == Transaction.Type.EXPENSE,
+ f"{prefix}is_income": transaction.type == Transaction.Type.INCOME,
+ f"{prefix}is_paid": transaction.is_paid,
+ f"{prefix}description": transaction.description,
+ f"{prefix}amount": transaction.amount or 0,
+ f"{prefix}notes": transaction.notes,
+ f"{prefix}date": transaction.date,
+ f"{prefix}reference_date": transaction.reference_date,
+ f"{prefix}internal_note": transaction.internal_note,
+ f"{prefix}internal_id": transaction.internal_id,
+ f"{prefix}is_deleted": transaction.deleted,
+ f"{prefix}is_muted": transaction.mute,
+ }
+ else:
+ return {
+ "is_on_create": True if signal == "transaction_created" else False,
+ "is_on_delete": True if signal == "transaction_deleted" else False,
+ "is_on_update": True if signal == "transaction_updated" else False,
+ f"{prefix}id": transaction.get("id"),
+ f"{prefix}account_name": transaction.get("account", (None, None))[1],
+ f"{prefix}account_id": transaction.get("account", (None, None))[0],
+ f"{prefix}account_group_name": transaction.get(
+ "account_group", (None, None)
+ )[1],
+ f"{prefix}account_group_id": transaction.get(
+ "account_group", (None, None)
+ )[0],
+ f"{prefix}is_asset_account": transaction.get("is_asset"),
+ f"{prefix}is_archived_account": transaction.get("is_archived"),
+ f"{prefix}category_name": transaction.get("category", (None, None))[1],
+ f"{prefix}category_id": transaction.get("category", (None, None))[0],
+ f"{prefix}tag_names": [x[1] for x in transaction.get("tags", [])],
+ f"{prefix}tag_ids": [x[0] for x in transaction.get("tags", [])],
+ f"{prefix}entities_names": [
+ x[1] for x in transaction.get("entities", [])
+ ],
+ f"{prefix}entities_ids": [
+ x[0] for x in transaction.get("entities", [])
+ ],
+ f"{prefix}is_expense": transaction.get("type")
+ == Transaction.Type.EXPENSE,
+ f"{prefix}is_income": transaction.get("type")
+ == Transaction.Type.INCOME,
+ f"{prefix}is_paid": transaction.get("is_paid"),
+ f"{prefix}description": transaction.get("description", ""),
+ f"{prefix}amount": Decimal(transaction.get("amount")),
+ f"{prefix}notes": transaction.get("notes", ""),
+ f"{prefix}date": datetime.fromisoformat(transaction.get("date")),
+ f"{prefix}reference_date": datetime.fromisoformat(
+ transaction.get("reference_date")
+ ),
+ f"{prefix}internal_note": transaction.get("internal_note", ""),
+ f"{prefix}internal_id": transaction.get("internal_id", ""),
+ f"{prefix}is_deleted": transaction.get("deleted", True),
+ f"{prefix}is_muted": transaction.get("mute", False),
+ }
+
+ def _process_update_or_create_transaction_action(processed_action):
+ """Helper to process a single linked transaction action"""
+
+ dry_run_results.header("update_or_create_transaction", action=processed_action)
+
+ # Build search query using the helper method
+ search_query = processed_action.build_search_query(simple)
+ _log(f"Searching transactions using: {search_query}")
+
+ starting_instance = None
+
+ # Find latest matching transaction or create new
+ if search_query:
+ searched_transactions = Transaction.objects.filter(search_query).order_by(
+ "-date", "-id"
+ )
+ if searched_transactions.exists():
+ transaction = searched_transactions.first()
+ existing = True
+ starting_instance = transaction.deepcopy()
+ _log("Found at least one matching transaction, using latest:")
+ _log("{}".format(pformat(model_to_dict(transaction))))
+ else:
+ transaction = Transaction()
+ existing = False
+ _log(
+ "No matching transaction found, creating a new transaction",
+ )
+ else:
+ transaction = Transaction()
+ existing = False
+ _log(
+ "No matching transaction found, creating a new transaction",
+ )
+
+ simple.names.update(_get_names(transaction, prefix="my_"))
+
+ if processed_action.filter:
+ value = simple.eval(processed_action.filter)
+ if not value:
+ dry_run_results.error(
+ error="Filter did not match. Execution of this action has stopped.",
+ )
+ _log("Filter did not match. Execution of this action has stopped.")
+ return # Short-circuit execution if filter evaluates to false
+
+ # Set fields if provided
+ if processed_action.set_account:
+ value = simple.eval(processed_action.set_account)
+ if isinstance(value, int):
+ transaction.account = Account.objects.get(id=value)
+ else:
+ transaction.account = Account.objects.get(name=value)
+
+ if processed_action.set_type:
+ transaction.type = simple.eval(processed_action.set_type)
+
+ if processed_action.set_is_paid:
+ transaction.is_paid = simple.eval(processed_action.set_is_paid)
+
+ if processed_action.set_mute:
+ transaction.is_paid = simple.eval(processed_action.set_mute)
+
+ if processed_action.set_date:
+ transaction.date = simple.eval(processed_action.set_date)
+
+ if processed_action.set_reference_date:
+ transaction.reference_date = simple.eval(
+ processed_action.set_reference_date
+ )
+
+ if processed_action.set_amount:
+ transaction.amount = simple.eval(processed_action.set_amount)
+
+ if processed_action.set_description:
+ transaction.description = simple.eval(processed_action.set_description)
+
+ if processed_action.set_internal_note:
+ transaction.internal_note = simple.eval(processed_action.set_internal_note)
+
+ if processed_action.set_internal_id:
+ transaction.internal_id = simple.eval(processed_action.set_internal_id)
+
+ if processed_action.set_notes:
+ transaction.notes = simple.eval(processed_action.set_notes)
+
+ if processed_action.set_category:
+ value = simple.eval(processed_action.set_category)
+ if isinstance(value, int):
+ transaction.category = TransactionCategory.objects.get(id=value)
+ else:
+ transaction.category = TransactionCategory.objects.get(name=value)
+
+ if not transaction.id:
+ _log("Transaction will be created as:")
+ else:
+ _log("Trasanction will be updated as:")
+
+ _log(
+ "{}".format(
+ pformat(model_to_dict(transaction, exclude=["tags", "entities"])),
+ )
+ )
+ transaction.save()
+
+ # Handle M2M fields after save
+ tags = []
+ if processed_action.set_tags:
+ tags = simple.eval(processed_action.set_tags)
+ _log(f" And tags will be set as: {tags}")
+ transaction.tags.clear()
+ if isinstance(tags, (list, tuple)):
+ for tag in tags:
+ if isinstance(tag, int):
+ transaction.tags.add(TransactionTag.objects.get(id=tag))
+ else:
+ transaction.tags.add(TransactionTag.objects.get(name=tag))
+ elif isinstance(tags, (int, str)):
+ if isinstance(tags, int):
+ transaction.tags.add(TransactionTag.objects.get(id=tags))
+ else:
+ transaction.tags.add(TransactionTag.objects.get(name=tags))
+
+ entities = []
+ if processed_action.set_entities:
+ entities = simple.eval(processed_action.set_entities)
+ _log(f" And entities will be set as: {entities}")
+ transaction.entities.clear()
+ if isinstance(entities, (list, tuple)):
+ for entity in entities:
+ if isinstance(entity, int):
+ transaction.entities.add(
+ TransactionEntity.objects.get(id=entity)
+ )
+ else:
+ transaction.entities.add(
+ TransactionEntity.objects.get(name=entity)
+ )
+ elif isinstance(entities, (int, str)):
+ if isinstance(entities, int):
+ transaction.entities.add(TransactionEntity.objects.get(id=entities))
+ else:
+ transaction.entities.add(
+ TransactionEntity.objects.get(name=entities)
+ )
+
+ dry_run_results.update_or_create_transaction(
+ start_instance=starting_instance,
+ end_instance=transaction.deepcopy(),
+ updated=existing,
+ action=processed_action,
+ query=search_query,
+ entities=entities,
+ tags=tags,
+ )
+
+ # transaction.full_clean()
+
+ def _process_edit_transaction_action(transaction, processed_action) -> Transaction:
+ dry_run_results.header("edit_transaction", action=processed_action)
+
+ field = processed_action.field
+ original_value = getattr(transaction, field)
+ new_value = simple.eval(processed_action.value)
+
+ tags = []
+ entities = []
+
+ _log(
+ f"Changing field '{field}' from '{original_value}' to '{new_value}'",
+ )
+
+ if field == TransactionRuleAction.Field.account:
+ if isinstance(new_value, int):
+ account = Account.objects.get(id=new_value)
+ transaction.account = account
+ elif isinstance(new_value, str):
+ account = Account.objects.filter(name=new_value).first()
+ transaction.account = account
+
+ elif field == TransactionRuleAction.Field.category:
+ if isinstance(new_value, int):
+ category = TransactionCategory.objects.get(id=new_value)
+ transaction.category = category
+ elif isinstance(new_value, str):
+ category = TransactionCategory.objects.get(name=new_value)
+ transaction.category = category
+
+ elif field == TransactionRuleAction.Field.tags:
+ transaction.tags.clear()
+
+ if isinstance(new_value, list):
+ for tag_value in new_value:
+ if isinstance(tag_value, int):
+ tag = TransactionTag.objects.get(id=tag_value)
+
+ transaction.tags.add(tag)
+ tags.append(tag)
+ elif isinstance(tag_value, str):
+ tag = TransactionTag.objects.get(name=tag_value)
+
+ transaction.tags.add(tag)
+ tags.append(tag)
+
+ elif isinstance(new_value, (int, str)):
+ if isinstance(new_value, int):
+ tag = TransactionTag.objects.get(id=new_value)
+ else:
+ tag = TransactionTag.objects.get(name=new_value)
+
+ transaction.tags.add(tag)
+ tags.append(tag)
+
+ elif field == TransactionRuleAction.Field.entities:
+ transaction.entities.clear()
+ if isinstance(new_value, list):
+ for entity_value in new_value:
+ if isinstance(entity_value, int):
+ entity = TransactionEntity.objects.get(id=entity_value)
+
+ transaction.entities.add(entity)
+ entities.append(entity)
+ elif isinstance(entity_value, str):
+ entity = TransactionEntity.objects.get(name=entity_value)
+
+ transaction.entities.add(entity)
+ entities.append(entity)
+
+ elif isinstance(new_value, (int, str)):
+ if isinstance(new_value, int):
+ entity = TransactionEntity.objects.get(id=new_value)
+ else:
+ entity = TransactionEntity.objects.get(name=new_value)
+
+ transaction.entities.add(entity)
+ entities.append(entity)
+
+ else:
+ setattr(
+ transaction,
+ field,
+ new_value,
+ )
+
+ dry_run_results.edit_transaction(
+ instance=transaction.deepcopy(),
+ action=processed_action,
+ old_value=original_value,
+ new_value=new_value,
+ field=field,
+ tags=tags,
+ entities=entities,
+ )
+
+ transaction.full_clean()
+
+ return transaction
+
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
+ logs = [] if dry_run else None
+ dry_run_results = DryRunResults()
+
+ if dry_run and not rule_id:
+ raise Exception("Cannot dry run without a rule id")
try:
with cachalot_disabled():
@@ -51,54 +507,83 @@ def check_for_transaction_rules(
# Regular transaction processing for creates and updates
instance = Transaction.objects.get(id=instance_id)
+ dry_run_results.triggering_transaction(instance.deepcopy())
+
functions = {
"relativedelta": relativedelta,
"str": str,
"int": int,
"float": float,
+ "abs": abs,
+ "randint": randint,
+ "random": random,
"decimal": decimal.Decimal,
"datetime": datetime,
"date": date,
"transactions": transactions.TransactionsGetter,
}
+ _log("Starting rule execution...")
+ _log("Available functions: {}".format(functions.keys()))
+
names = _get_names(instance)
simple = EvalWithCompoundTypes(names=names, functions=functions)
+ if signal == "transaction_updated" and old_data:
+ simple.names.update(_get_names(old_data, "old_"))
+
# Select rules based on the signal type
- if signal == "transaction_created":
+ if dry_run and rule_id:
+ rules = TransactionRule.objects.filter(id=rule_id)
+ elif signal == "transaction_created":
rules = TransactionRule.objects.filter(
active=True, on_create=True
- ).order_by("id")
+ ).order_by("order", "id")
elif signal == "transaction_updated":
rules = TransactionRule.objects.filter(
active=True, on_update=True
- ).order_by("id")
+ ).order_by("order", "id")
elif signal == "transaction_deleted":
rules = TransactionRule.objects.filter(
active=True, on_delete=True
- ).order_by("id")
+ ).order_by("order", "id")
else:
- rules = TransactionRule.objects.filter(active=True).order_by("id")
+ rules = TransactionRule.objects.filter(active=True).order_by(
+ "order", "id"
+ )
+
+ _log("Testing {} rule(s)...".format(len(rules)))
# Process the rules as before
for rule in rules:
+ _log("Testing rule: {}".format(rule.name))
if simple.eval(rule.trigger):
- # For deleted transactions, we might want to limit what actions can be performed
+ _log("Initial trigger matched!")
+ # For deleted transactions, we want to limit what actions can be performed
if signal == "transaction_deleted":
+ _log(
+ "Event is of type 'delete'. Only processing Update or Create 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:
+ _log(
+ "Processing action with id {} and order {}...".format(
+ action.id, action.order
+ )
+ )
_process_update_or_create_transaction_action(
- action=action, simple_eval=simple
+ processed_action=action,
)
except Exception as e:
- logger.error(
+ dry_run_results.error(
+ "Error raised: '{}'. Check the logs tab for more "
+ "information".format(e)
+ )
+ _log(
f"Error processing update or create transaction action {action.id} on deletion",
- exc_info=True,
+ level="error",
)
else:
# Normal processing for non-deleted transactions
@@ -113,365 +598,126 @@ def check_for_transaction_rules(
) or any(a.order > 0 for a in update_or_create_actions)
if has_custom_order:
+ _log(
+ "One or more actions have a custom order, actions will be processed ordered by "
+ "order and creation date..."
+ )
# Combine and sort actions by order
all_actions = sorted(
chain(edit_actions, update_or_create_actions),
- key=lambda a: a.order,
+ key=lambda a: (a.order, a.id),
)
for action in all_actions:
try:
if isinstance(action, TransactionRuleAction):
+ _log(
+ "Processing 'edit_transaction' action with id {} and order {}...".format(
+ action.id, action.order
+ )
+ )
instance = _process_edit_transaction_action(
- instance=instance,
- action=action,
- simple_eval=simple,
+ transaction=instance,
+ processed_action=action,
)
- # Update names for next actions
- simple.names.update(_get_names(instance))
+
+ if rule.sequenced:
+ # Update names for next actions
+ simple.names.update(_get_names(instance))
else:
- _process_update_or_create_transaction_action(
- action=action, simple_eval=simple
+ _log(
+ "Processing 'update_or_create_transaction' action with id {} and order {}...".format(
+ action.id, action.order
+ )
)
+ _process_update_or_create_transaction_action(
+ processed_action=action,
+ )
+ _clear_names("my_")
except Exception as e:
- logger.error(
+ dry_run_results.error(
+ "Error raised: '{}'. Check the logs tab for more "
+ "information".format(e)
+ )
+ _log(
f"Error processing action {action.id}",
- exc_info=True,
+ level="error",
)
# Save at the end
if signal != "transaction_deleted":
instance.save()
else:
+ _log(
+ "No actions have a custom order, actions will be processed ordered by creation "
+ "date, with Edit actions running first, then Update or Create actions..."
+ )
# Original behavior
for action in edit_actions:
+ _log(
+ "Processing 'edit_transaction' action with id {}...".format(
+ action.id
+ )
+ )
try:
instance = _process_edit_transaction_action(
- instance=instance,
- action=action,
- simple_eval=simple,
+ transaction=instance,
+ processed_action=action,
)
+ if rule.sequenced:
+ # Update names for next actions
+ simple.names.update(_get_names(instance))
except Exception as e:
- logger.error(
+ dry_run_results.error(
+ "Error raised: '{}'. Check the logs tab for more "
+ "information".format(e)
+ )
+ _log(
f"Error processing edit transaction action {action.id}",
- exc_info=True,
+ level="error",
)
- simple.names.update(_get_names(instance))
+ if rule.sequenced:
+ # Update names for next actions
+ simple.names.update(_get_names(instance))
if signal != "transaction_deleted":
instance.save()
for action in update_or_create_actions:
+ _log(
+ "Processing 'update_or_create_transaction' action with id {}...".format(
+ action.id
+ )
+ )
try:
_process_update_or_create_transaction_action(
- action=action, simple_eval=simple
+ processed_action=action,
)
+ _clear_names("my_")
except Exception as e:
- logger.error(
- f"Error processing update or create transaction action {action.id}",
- exc_info=True,
+ dry_run_results.error(
+ "Error raised: '{}'. Check the logs tab for more "
+ "information".format(e)
)
+ _log(
+ f"Error processing update or create transaction action {action.id}",
+ level="error",
+ )
+ else:
+ dry_run_results.error(
+ error="Initial trigger didn't match, this rule will be skipped",
+ )
+ _log("Initial trigger didn't match, this rule will be skipped")
except Exception as e:
- logger.error(
- "Error while executing 'check_for_transaction_rules' task",
- exc_info=True,
+ _log(
+ "** Error while executing 'check_for_transaction_rules' task",
+ level="error",
)
delete_current_user()
- raise e
+ if not dry_run:
+ raise e
+
delete_current_user()
+ return logs, dry_run_results.results
-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):
- """Helper to process a single linked transaction action"""
-
- # Build search query using the helper method
- search_query = action.build_search_query(simple_eval)
- logger.info("Searching transactions using: %s", search_query)
-
- # Find latest matching transaction or create new
- if search_query:
- transactions = Transaction.objects.filter(search_query).order_by("-date", "-id")
- transaction = transactions.first()
- logger.info("Found at least one matching transaction, using latest")
- else:
- transaction = None
- logger.info("No matching transaction found, creating a new transaction")
-
- if not transaction:
- transaction = Transaction()
-
- simple_eval.names.update(
- {
- "my_account_name": (transaction.account.name if transaction.id else None),
- "my_account_id": transaction.account.id if transaction.id else None,
- "my_account_group_name": (
- transaction.account.group.name
- if transaction.id and transaction.account.group
- else None
- ),
- "my_account_group_id": (
- transaction.account.group.id
- if transaction.id and transaction.account.group
- else None
- ),
- "my_is_asset_account": (
- transaction.account.is_asset if transaction.id else None
- ),
- "my_is_archived_account": (
- transaction.account.is_archived if transaction.id else None
- ),
- "my_category_name": (
- transaction.category.name if transaction.category else None
- ),
- "my_category_id": transaction.category.id if transaction.category else None,
- "my_tag_names": (
- [x.name for x in transaction.tags.all()] if transaction.id else []
- ),
- "my_tag_ids": (
- [x.id for x in transaction.tags.all()] if transaction.id else []
- ),
- "my_entities_names": (
- [x.name for x in transaction.entities.all()] if transaction.id else []
- ),
- "my_entities_ids": (
- [x.id for x in transaction.entities.all()] if transaction.id else []
- ),
- "my_is_expense": transaction.type == Transaction.Type.EXPENSE,
- "my_is_income": transaction.type == Transaction.Type.INCOME,
- "my_is_paid": transaction.is_paid,
- "my_description": transaction.description,
- "my_amount": transaction.amount or 0,
- "my_notes": transaction.notes,
- "my_date": transaction.date,
- "my_reference_date": transaction.reference_date,
- "my_internal_note": transaction.internal_note,
- "my_internal_id": transaction.reference_date,
- }
- )
-
- if action.filter:
- value = simple_eval.eval(action.filter)
- if not value:
- return # Short-circuit execution if filter evaluates to false
-
- # Set fields if provided
- if action.set_account:
- value = simple_eval.eval(action.set_account)
- if isinstance(value, int):
- transaction.account = Account.objects.get(id=value)
- else:
- transaction.account = Account.objects.get(name=value)
-
- if action.set_type:
- transaction.type = simple_eval.eval(action.set_type)
-
- if action.set_is_paid:
- transaction.is_paid = simple_eval.eval(action.set_is_paid)
-
- if action.set_date:
- transaction.date = simple_eval.eval(action.set_date)
-
- if action.set_reference_date:
- transaction.reference_date = simple_eval.eval(action.set_reference_date)
-
- if action.set_amount:
- transaction.amount = simple_eval.eval(action.set_amount)
-
- if action.set_description:
- transaction.description = simple_eval.eval(action.set_description)
-
- if action.set_internal_note:
- transaction.internal_note = simple_eval.eval(action.set_internal_note)
-
- if action.set_internal_id:
- transaction.internal_id = simple_eval.eval(action.set_internal_id)
-
- if action.set_notes:
- transaction.notes = simple_eval.eval(action.set_notes)
-
- if action.set_category:
- value = simple_eval.eval(action.set_category)
- if isinstance(value, int):
- transaction.category = TransactionCategory.objects.get(id=value)
- else:
- transaction.category = TransactionCategory.objects.get(name=value)
-
- transaction.save()
-
- # Handle M2M fields after save
- if action.set_tags:
- tags_value = simple_eval.eval(action.set_tags)
- transaction.tags.clear()
- if isinstance(tags_value, (list, tuple)):
- for tag in tags_value:
- if isinstance(tag, int):
- transaction.tags.add(TransactionTag.objects.get(id=tag))
- else:
- transaction.tags.add(TransactionTag.objects.get(name=tag))
- elif isinstance(tags_value, (int, str)):
- if isinstance(tags_value, int):
- transaction.tags.add(TransactionTag.objects.get(id=tags_value))
- else:
- transaction.tags.add(TransactionTag.objects.get(name=tags_value))
-
- if action.set_entities:
- entities_value = simple_eval.eval(action.set_entities)
- transaction.entities.clear()
- if isinstance(entities_value, (list, tuple)):
- for entity in entities_value:
- if isinstance(entity, int):
- transaction.entities.add(TransactionEntity.objects.get(id=entity))
- else:
- transaction.entities.add(TransactionEntity.objects.get(name=entity))
- elif isinstance(entities_value, (int, str)):
- if isinstance(entities_value, int):
- transaction.entities.add(
- TransactionEntity.objects.get(id=entities_value)
- )
- else:
- transaction.entities.add(
- TransactionEntity.objects.get(name=entities_value)
- )
-
-
-def _process_edit_transaction_action(instance, action, simple_eval) -> Transaction:
- if action.field in [
- TransactionRuleAction.Field.type,
- TransactionRuleAction.Field.is_paid,
- TransactionRuleAction.Field.date,
- TransactionRuleAction.Field.reference_date,
- TransactionRuleAction.Field.amount,
- TransactionRuleAction.Field.description,
- TransactionRuleAction.Field.notes,
- ]:
- setattr(
- instance,
- action.field,
- simple_eval.eval(action.value),
- )
-
- elif action.field == TransactionRuleAction.Field.account:
- value = simple_eval.eval(action.value)
- if isinstance(value, int):
- account = Account.objects.get(id=value)
- instance.account = account
- elif isinstance(value, str):
- account = Account.objects.filter(name=value).first()
- instance.account = account
-
- elif action.field == TransactionRuleAction.Field.category:
- value = simple_eval.eval(action.value)
- if isinstance(value, int):
- category = TransactionCategory.objects.get(id=value)
- instance.category = category
- elif isinstance(value, str):
- category = TransactionCategory.objects.get(name=value)
- instance.category = category
-
- elif action.field == TransactionRuleAction.Field.tags:
- value = simple_eval.eval(action.value)
- if isinstance(value, list):
- # Clear existing tags
- instance.tags.clear()
- for tag_value in value:
- if isinstance(tag_value, int):
- tag = TransactionTag.objects.get(id=tag_value)
- instance.tags.add(tag)
- elif isinstance(tag_value, str):
- tag = TransactionTag.objects.get(name=tag_value)
- instance.tags.add(tag)
-
- elif isinstance(value, (int, str)):
- # If a single value is provided, treat it as a single tag
- instance.tags.clear()
- if isinstance(value, int):
- tag = TransactionTag.objects.get(id=value)
- else:
- tag = TransactionTag.objects.get(name=value)
-
- instance.tags.add(tag)
-
- elif action.field == TransactionRuleAction.Field.entities:
- value = simple_eval.eval(action.value)
- if isinstance(value, list):
- # Clear existing entities
- instance.entities.clear()
- for entity_value in value:
- if isinstance(entity_value, int):
- entity = TransactionEntity.objects.get(id=entity_value)
- instance.entities.add(entity)
- elif isinstance(entity_value, str):
- entity = TransactionEntity.objects.get(name=entity_value)
- instance.entities.add(entity)
-
- elif isinstance(value, (int, str)):
- # If a single value is provided, treat it as a single entity
- instance.entities.clear()
- if isinstance(value, int):
- entity = TransactionEntity.objects.get(id=value)
- else:
- entity = TransactionEntity.objects.get(name=value)
-
- instance.entities.add(entity)
-
- return instance
+ return None
diff --git a/app/apps/rules/urls.py b/app/apps/rules/urls.py
index 2107aa4..9ee0fb1 100644
--- a/app/apps/rules/urls.py
+++ b/app/apps/rules/urls.py
@@ -42,6 +42,21 @@ urlpatterns = [
views.transaction_rule_take_ownership,
name="transaction_rule_take_ownership",
),
+ path(
+ "rules/transaction//dry-run/created/",
+ views.dry_run_rule_created,
+ name="transaction_rule_dry_run_created",
+ ),
+ path(
+ "rules/transaction//dry-run/deleted/",
+ views.dry_run_rule_deleted,
+ name="transaction_rule_dry_run_deleted",
+ ),
+ path(
+ "rules/transaction//dry-run/updated/",
+ views.dry_run_rule_updated,
+ name="transaction_rule_dry_run_updated",
+ ),
path(
"rules/transaction//share/",
views.transaction_rule_share,
diff --git a/app/apps/rules/utils/transactions.py b/app/apps/rules/utils/transactions.py
index b119570..eac1305 100644
--- a/app/apps/rules/utils/transactions.py
+++ b/app/apps/rules/utils/transactions.py
@@ -56,3 +56,33 @@ class TransactionsGetter:
output_field=DecimalField(),
)
)["balance"]
+
+
+def serialize_transaction(sender: Transaction, deleted: bool):
+ return {
+ "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": deleted,
+ "internal_note": sender.internal_note,
+ "internal_id": sender.internal_id,
+ "mute": sender.mute,
+ }
diff --git a/app/apps/rules/views.py b/app/apps/rules/views.py
index 57408b7..da49954 100644
--- a/app/apps/rules/views.py
+++ b/app/apps/rules/views.py
@@ -1,7 +1,10 @@
from itertools import chain
+from copy import deepcopy
+
from django.contrib import messages
from django.contrib.auth.decorators import login_required
+from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.utils.translation import gettext_lazy as _
@@ -12,6 +15,9 @@ from apps.rules.forms import (
TransactionRuleForm,
TransactionRuleActionForm,
UpdateOrCreateTransactionRuleActionForm,
+ DryRunCreatedTransacion,
+ DryRunDeletedTransacion,
+ DryRunUpdatedTransactionForm,
)
from apps.rules.models import (
TransactionRule,
@@ -21,6 +27,11 @@ from apps.rules.models import (
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
from apps.common.decorators.demo import disabled_on_demo
+from apps.rules.tasks import check_for_transaction_rules
+from apps.common.middleware.thread_local import get_current_user
+from apps.rules.signals import transaction_created, transaction_updated
+from apps.rules.utils.transactions import serialize_transaction
+from apps.transactions.models import Transaction
@login_required
@@ -38,7 +49,7 @@ def rules_index(request):
@disabled_on_demo
@require_http_methods(["GET"])
def rules_list(request):
- transaction_rules = TransactionRule.objects.all().order_by("id")
+ transaction_rules = TransactionRule.objects.all().order_by("order", "id")
return render(
request,
"rules/fragments/list.html",
@@ -143,7 +154,9 @@ def transaction_rule_view(request, 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()
+ update_or_create_actions = (
+ transaction_rule.update_or_create_transaction_actions.all()
+ )
all_actions = sorted(
chain(edit_actions, update_or_create_actions),
@@ -416,3 +429,156 @@ def update_or_create_transaction_rule_action_delete(request, pk):
"HX-Trigger": "updated, hide_offcanvas",
},
)
+
+
+@only_htmx
+@login_required
+@disabled_on_demo
+@require_http_methods(["GET", "POST"])
+def dry_run_rule_created(request, pk):
+ rule = get_object_or_404(TransactionRule, id=pk)
+ logs = None
+ results = None
+
+ if request.method == "POST":
+ form = DryRunCreatedTransacion(request.POST)
+ if form.is_valid():
+ try:
+ with transaction.atomic():
+ logs, results = check_for_transaction_rules(
+ instance_id=form.cleaned_data["transaction"].id,
+ signal="transaction_created",
+ dry_run=True,
+ rule_id=rule.id,
+ user_id=get_current_user().id,
+ )
+ logs = "\n".join(logs)
+
+ response = render(
+ request,
+ "rules/fragments/transaction_rule/dry_run/created.html",
+ {"form": form, "rule": rule, "logs": logs, "results": results},
+ )
+
+ raise Exception("ROLLBACK")
+ except Exception:
+ pass
+
+ return response
+
+ else:
+ form = DryRunCreatedTransacion()
+
+ return render(
+ request,
+ "rules/fragments/transaction_rule/dry_run/created.html",
+ {"form": form, "rule": rule, "logs": logs, "results": results},
+ )
+
+
+@only_htmx
+@login_required
+@disabled_on_demo
+@require_http_methods(["GET", "POST"])
+def dry_run_rule_deleted(request, pk):
+ rule = get_object_or_404(TransactionRule, id=pk)
+ logs = None
+ results = None
+
+ if request.method == "POST":
+ form = DryRunDeletedTransacion(request.POST)
+ if form.is_valid():
+ try:
+ with transaction.atomic():
+ logs, results = check_for_transaction_rules(
+ instance_id=form.cleaned_data["transaction"].id,
+ signal="transaction_deleted",
+ dry_run=True,
+ rule_id=rule.id,
+ user_id=get_current_user().id,
+ )
+ logs = "\n".join(logs)
+
+ response = render(
+ request,
+ "rules/fragments/transaction_rule/dry_run/created.html",
+ {"form": form, "rule": rule, "logs": logs, "results": results},
+ )
+
+ raise Exception("ROLLBACK")
+ except Exception:
+ pass
+
+ return response
+
+ else:
+ form = DryRunDeletedTransacion()
+
+ return render(
+ request,
+ "rules/fragments/transaction_rule/dry_run/deleted.html",
+ {"form": form, "rule": rule, "logs": logs, "results": results},
+ )
+
+
+@only_htmx
+@login_required
+@disabled_on_demo
+@require_http_methods(["GET", "POST"])
+def dry_run_rule_updated(request, pk):
+ rule = get_object_or_404(TransactionRule, id=pk)
+ logs = None
+ results = None
+
+ if request.method == "POST":
+ form = DryRunUpdatedTransactionForm(request.POST)
+ if form.is_valid():
+ base_transaction = Transaction.objects.get(
+ id=request.POST.get("transaction")
+ )
+ old_data = deepcopy(base_transaction)
+ try:
+ with transaction.atomic():
+ for field_name, value in form.cleaned_data.items():
+ if value or isinstance(
+ value, bool
+ ): # Only update fields that have been filled in the form
+ if field_name == "tags":
+ base_transaction.tags.set(value)
+ elif field_name == "entities":
+ base_transaction.entities.set(value)
+ else:
+ setattr(base_transaction, field_name, value)
+
+ base_transaction.save()
+
+ logs, results = check_for_transaction_rules(
+ instance_id=base_transaction.id,
+ signal="transaction_updated",
+ dry_run=True,
+ rule_id=rule.id,
+ user_id=get_current_user().id,
+ old_data=old_data,
+ )
+ logs = "\n".join(logs) if logs else ""
+
+ response = render(
+ request,
+ "rules/fragments/transaction_rule/dry_run/created.html",
+ {"form": form, "rule": rule, "logs": logs, "results": results},
+ )
+
+ # This will rollback the transaction
+ raise Exception("ROLLBACK")
+ except Exception:
+ pass
+
+ return response
+ else:
+ form = DryRunUpdatedTransactionForm(initial={"is_paid": None, "type": None})
+
+ return render(
+ request,
+ "rules/fragments/transaction_rule/dry_run/updated.html",
+ {"form": form, "rule": rule, "logs": logs, "results": results},
+ )
diff --git a/app/apps/transactions/forms.py b/app/apps/transactions/forms.py
index 054b1db..684e6d8 100644
--- a/app/apps/transactions/forms.py
+++ b/app/apps/transactions/forms.py
@@ -1,3 +1,5 @@
+from copy import deepcopy
+
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
from crispy_forms.helper import FormHelper
@@ -239,11 +241,16 @@ class TransactionForm(forms.ModelForm):
def save(self, **kwargs):
is_new = not self.instance.id
+ if not is_new:
+ old_data = deepcopy(Transaction.objects.get(pk=self.instance.id))
+ else:
+ old_data = None
+
instance = super().save(**kwargs)
if is_new:
transaction_created.send(sender=instance)
else:
- transaction_updated.send(sender=instance)
+ transaction_updated.send(sender=instance, old_data=old_data)
return instance
@@ -382,35 +389,115 @@ class QuickTransactionForm(forms.ModelForm):
)
-class BulkEditTransactionForm(TransactionForm):
- is_paid = forms.NullBooleanField(required=False)
+class BulkEditTransactionForm(forms.Form):
+ type = forms.ChoiceField(
+ choices=(Transaction.Type.choices),
+ required=False,
+ label=_("Type"),
+ )
+ is_paid = forms.NullBooleanField(
+ required=False,
+ label=_("Paid"),
+ )
+ account = DynamicModelChoiceField(
+ model=Account,
+ required=False,
+ label=_("Account"),
+ queryset=Account.objects.filter(is_archived=False),
+ widget=TomSelect(clear_button=False, group_by="group"),
+ )
+ date = forms.DateField(
+ label=_("Date"),
+ required=False,
+ widget=AirDatePickerInput(clear_button=False),
+ )
+ reference_date = forms.DateField(
+ widget=AirMonthYearPickerInput(),
+ label=_("Reference Date"),
+ required=False,
+ )
+ amount = forms.DecimalField(
+ max_digits=42,
+ decimal_places=30,
+ required=False,
+ label=_("Amount"),
+ widget=ArbitraryDecimalDisplayNumberInput(),
+ )
+ description = forms.CharField(
+ max_length=500, required=False, label=_("Description")
+ )
+ notes = forms.CharField(
+ required=False,
+ widget=forms.Textarea(attrs={"rows": 3}),
+ label=_("Notes"),
+ )
+ category = DynamicModelChoiceField(
+ create_field="name",
+ model=TransactionCategory,
+ required=False,
+ label=_("Category"),
+ queryset=TransactionCategory.objects.filter(active=True),
+ )
+ tags = DynamicModelMultipleChoiceField(
+ model=TransactionTag,
+ to_field_name="name",
+ create_field="name",
+ required=False,
+ label=_("Tags"),
+ queryset=TransactionTag.objects.filter(active=True),
+ )
+ entities = DynamicModelMultipleChoiceField(
+ model=TransactionEntity,
+ to_field_name="name",
+ create_field="name",
+ required=False,
+ label=_("Entities"),
+ queryset=TransactionEntity.objects.all(),
+ )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- # Make all fields optional
- for field_name, field in self.fields.items():
- field.required = False
- del self.helper.layout[-1] # Remove button
- del self.helper.layout[0:2] # Remove type, is_paid field
+ self.fields["account"].queryset = Account.objects.filter(
+ is_archived=False,
+ )
- self.helper.layout.insert(
- 0,
+ self.fields["category"].queryset = TransactionCategory.objects.filter(
+ active=True
+ )
+ self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
+ self.fields["entities"].queryset = TransactionEntity.objects.all()
+
+ self.helper = FormHelper()
+ self.helper.form_tag = False
+ self.helper.form_method = "post"
+ self.helper.layout = Layout(
Field(
"type",
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
),
- )
-
- self.helper.layout.insert(
- 1,
Field(
"is_paid",
template="transactions/widgets/unselectable_paid_toggle_button.html",
),
- )
-
- self.helper.layout.append(
+ Row(
+ Column("account", css_class="form-group col-md-6 mb-0"),
+ Column("entities", css_class="form-group col-md-6 mb-0"),
+ css_class="form-row",
+ ),
+ Row(
+ Column(Field("date"), css_class="form-group col-md-6 mb-0"),
+ Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
+ css_class="form-row",
+ ),
+ "description",
+ Field("amount", inputmode="decimal"),
+ Row(
+ Column("category", css_class="form-group col-md-6 mb-0"),
+ Column("tags", css_class="form-group col-md-6 mb-0"),
+ css_class="form-row",
+ ),
+ "notes",
FormActions(
NoClassSubmit(
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
@@ -418,6 +505,9 @@ class BulkEditTransactionForm(TransactionForm):
),
)
+ self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
+ self.fields["date"].widget = AirDatePickerInput(clear_button=False)
+
class TransferForm(forms.Form):
from_account = forms.ModelChoiceField(
diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py
index 0e31531..cdbb069 100644
--- a/app/apps/transactions/models.py
+++ b/app/apps/transactions/models.py
@@ -1,4 +1,5 @@
import logging
+from copy import deepcopy
from dateutil.relativedelta import relativedelta
from django.conf import settings
@@ -33,13 +34,13 @@ transaction_deleted = Signal()
class SoftDeleteQuerySet(models.QuerySet):
@staticmethod
- def _emit_signals(instances, created=False):
+ def _emit_signals(instances, created=False, old_data=None):
"""Helper to emit signals for multiple instances"""
- for instance in instances:
+ for i, instance in enumerate(instances):
if created:
transaction_created.send(sender=instance)
else:
- transaction_updated.send(sender=instance)
+ transaction_updated.send(sender=instance, old_data=old_data[i])
def bulk_create(self, objs, emit_signal=True, **kwargs):
instances = super().bulk_create(objs, **kwargs)
@@ -50,22 +51,25 @@ class SoftDeleteQuerySet(models.QuerySet):
return instances
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
+ old_data = deepcopy(objs)
result = super().bulk_update(objs, fields, **kwargs)
if emit_signal:
- self._emit_signals(objs, created=False)
+ self._emit_signals(objs, created=False, old_data=old_data)
return result
def update(self, emit_signal=True, **kwargs):
# Get instances before update
instances = list(self)
+ old_data = deepcopy(instances)
+
result = super().update(**kwargs)
if emit_signal:
# Refresh instances to get new values
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
- self._emit_signals(refreshed, created=False)
+ self._emit_signals(refreshed, created=False, old_data=old_data)
return result
@@ -376,7 +380,7 @@ class Transaction(OwnedObject):
db_table = "transactions"
default_manager_name = "objects"
- def save(self, *args, **kwargs):
+ def clean_fields(self, *args, **kwargs):
self.amount = truncate_decimal(
value=self.amount, decimal_places=self.account.currency.decimal_places
)
@@ -386,6 +390,11 @@ class Transaction(OwnedObject):
elif not self.reference_date and self.date:
self.reference_date = self.date.replace(day=1)
+ super().clean_fields(*args, **kwargs)
+
+ def save(self, *args, **kwargs):
+ # This is not recommended as it will run twice on some cases like form and API saves.
+ # We only do this here because we forgot to independently call it on multiple places.
self.full_clean()
super().save(*args, **kwargs)
@@ -443,12 +452,58 @@ class Transaction(OwnedObject):
type_display = self.get_type_display()
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
account = self.account
- tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
+ tags = (
+ ", ".join([x.name for x in self.tags.all()])
+ if self.id
+ else None or _("No tags")
+ )
category = self.category or _("No category")
amount = localize_number(drop_trailing_zeros(self.amount))
description = self.description or _("No description")
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
+ def deepcopy(self, memo=None):
+ """
+ Creates a deep copy of the transaction instance.
+
+ This method returns a new, unsaved Transaction instance with the same
+ values as the original, including its many-to-many relationships.
+ The primary key and any other unique fields are reset to avoid
+ database integrity errors upon saving.
+ """
+ if memo is None:
+ memo = {}
+
+ # Create a new instance of the class
+ new_obj = self.__class__()
+ memo[id(self)] = new_obj
+
+ # Copy all concrete fields from the original to the new object
+ for field in self._meta.concrete_fields:
+ # Skip the primary key to allow the database to generate a new one
+ if field.primary_key:
+ continue
+
+ # Reset any unique fields to None to avoid constraint violations
+ if field.unique and field.name == "internal_id":
+ setattr(new_obj, field.name, None)
+ continue
+
+ # Copy the value of the field
+ setattr(new_obj, field.name, getattr(self, field.name))
+
+ # Save the new object to the database to get a primary key
+ new_obj.save()
+
+ # Copy the many-to-many relationships
+ for field in self._meta.many_to_many:
+ source_manager = getattr(self, field.name)
+ destination_manager = getattr(new_obj, field.name)
+ # Set the M2M relationships for the new object
+ destination_manager.set(source_manager.all())
+
+ return new_obj
+
class InstallmentPlan(models.Model):
class Recurrence(models.TextChoices):
diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py
index 286b835..5fceb11 100644
--- a/app/apps/transactions/views/transactions.py
+++ b/app/apps/transactions/views/transactions.py
@@ -213,6 +213,7 @@ def transactions_bulk_edit(request):
if form.is_valid():
# Apply changes from the form to all selected transactions
for transaction in transactions:
+ old_data = deepcopy(transaction)
for field_name, value in form.cleaned_data.items():
if value or isinstance(
value, bool
@@ -225,7 +226,7 @@ def transactions_bulk_edit(request):
setattr(transaction, field_name, value)
transaction.save()
- transaction_updated.send(sender=transaction)
+ transaction_updated.send(sender=transaction, old_data=old_data)
messages.success(
request,
@@ -373,10 +374,13 @@ def transactions_transfer(request):
@require_http_methods(["GET"])
def transaction_pay(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id)
+ old_data = deepcopy(transaction)
+
new_is_paid = False if transaction.is_paid else True
transaction.is_paid = new_is_paid
transaction.save()
- transaction_updated.send(sender=transaction)
+
+ transaction_updated.send(sender=transaction, old_data=old_data)
response = render(
request,
@@ -394,11 +398,12 @@ def transaction_pay(request, transaction_id):
@require_http_methods(["GET"])
def transaction_mute(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id)
+ old_data = deepcopy(transaction)
new_mute = False if transaction.mute else True
transaction.mute = new_mute
transaction.save()
- transaction_updated.send(sender=transaction)
+ transaction_updated.send(sender=transaction, old_data=old_data)
response = render(
request,
@@ -414,19 +419,20 @@ def transaction_mute(request, transaction_id):
@require_http_methods(["GET"])
def transaction_change_month(request, transaction_id, change_type):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
+ old_data = deepcopy(transaction)
if change_type == "next":
transaction.reference_date = transaction.reference_date + relativedelta(
months=1
)
transaction.save()
- transaction_updated.send(sender=transaction)
+ transaction_updated.send(sender=transaction, old_data=old_data)
elif change_type == "previous":
transaction.reference_date = transaction.reference_date - relativedelta(
months=1
)
transaction.save()
- transaction_updated.send(sender=transaction)
+ transaction_updated.send(sender=transaction, old_data=old_data)
return HttpResponse(
status=204,
@@ -440,9 +446,11 @@ def transaction_change_month(request, transaction_id, change_type):
def transaction_move_to_today(request, transaction_id):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
+ old_data = deepcopy(transaction)
+
transaction.date = timezone.localdate(timezone.now())
transaction.save()
- transaction_updated.send(sender=transaction)
+ transaction_updated.send(sender=transaction, old_data=old_data)
return HttpResponse(
status=204,
diff --git a/app/templates/cotton/transaction/item.html b/app/templates/cotton/transaction/item.html
index d182af4..9e69b52 100644
--- a/app/templates/cotton/transaction/item.html
+++ b/app/templates/cotton/transaction/item.html
@@ -1,8 +1,9 @@
{% load markdown %}
{% load i18n %}
-
+
- {% if not disable_selection %}
+ {% if not disable_selection or not dummy %}
{% endif %}
-
+
{# Date#}
@@ -58,14 +61,20 @@
{# Entities #}
- {% with transaction.entities.all as entities %}
- {% if entities %}
-
-
-
{{ entities|join:", " }}
-
- {% endif %}
- {% endwith %}
+ {% comment %} First, check for the highest priority: a valid 'overriden_entities' list. {% endcomment %}
+ {% if overriden_entities %}
+
+
+
{{ overriden_entities|join:", " }}
+
+
+ {% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
+ {% elif transaction.id and transaction.entities.all %}
+
+
+
{{ transaction.entities.all|join:", " }}
+
+ {% endif %}
{# Notes#}
{% if transaction.notes %}
@@ -81,17 +90,24 @@
{% endif %}
{# Tags#}
- {% with transaction.tags.all as tags %}
- {% if tags %}
-
-
-
{{ tags|join:", " }}
-
- {% endif %}
- {% endwith %}
+ {% comment %} First, check for the highest priority: a valid 'overriden_tags' list. {% endcomment %}
+ {% if overriden_tags %}
+
+
+
{{ overriden_tags|join:", " }}
+
+
+ {% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
+ {% elif transaction.id and transaction.tags.all %}
+
+
+
{{ transaction.tags.all|join:", " }}
+
+ {% endif %}
-
+
{# Exchange Rate#}
- {% with exchanged=transaction.exchanged_amount %}
- {% if exchanged %}
-
-
-
- {% endif %}
- {% endwith %}
+ {% if not dummy %}
+ {% with exchanged=transaction.exchanged_amount %}
+ {% if exchanged %}
+