diff --git a/app/apps/api/views/transactions.py b/app/apps/api/views/transactions.py index d3d214a..c07ae46 100644 --- a/app/apps/api/views/transactions.py +++ b/app/apps/api/views/transactions.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from rest_framework import viewsets from apps.api.custom.pagination import CustomPageNumberPagination @@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet): transaction_created.send(sender=instance) def perform_update(self, serializer): + old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"])) instance = serializer.save() - transaction_updated.send(sender=instance) + transaction_updated.send(sender=instance, old_data=old_data) def partial_update(self, request, *args, **kwargs): kwargs["partial"] = True diff --git a/app/apps/common/functions/decimals.py b/app/apps/common/functions/decimals.py index 8e4f804..7beda41 100644 --- a/app/apps/common/functions/decimals.py +++ b/app/apps/common/functions/decimals.py @@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places): :param decimal_places: The number of decimal places to keep :return: Truncated Decimal value """ + if isinstance(value, (int, float)): + value = Decimal(str(value)) + multiplier = Decimal(10**decimal_places) return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier diff --git a/app/apps/currencies/tests.py b/app/apps/currencies/tests.py index 9f5f5de..b5c7d05 100644 --- a/app/apps/currencies/tests.py +++ b/app/apps/currencies/tests.py @@ -40,12 +40,6 @@ class CurrencyTests(TestCase): with self.assertRaises(ValidationError): currency.full_clean() - def test_currency_unique_code(self): - """Test that currency codes must be unique""" - Currency.objects.create(code="USD", name="US Dollar", decimal_places=2) - with self.assertRaises(IntegrityError): - Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2) - def test_currency_unique_name(self): """Test that currency names must be unique""" Currency.objects.create(code="USD", name="US Dollar", decimal_places=2) diff --git a/app/apps/rules/forms.py b/app/apps/rules/forms.py index fceb802..64c6201 100644 --- a/app/apps/rules/forms.py +++ b/app/apps/rules/forms.py @@ -1,15 +1,19 @@ from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion from crispy_forms.bootstrap import FormActions, AccordionGroup from crispy_forms.helper import FormHelper -from crispy_forms.layout import Layout, Field, Row, Column +from crispy_forms.layout import Layout, Field, Row, Column, HTML from django import forms from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from apps.common.widgets.crispy.submit import NoClassSubmit -from apps.common.widgets.tom_select import TomSelect +from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.tom_select import TomSelect, TransactionSelect from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction from apps.rules.models import TransactionRuleAction +from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField +from apps.transactions.forms import BulkEditTransactionForm +from apps.transactions.models import Transaction class TransactionRuleForm(forms.ModelForm): @@ -40,6 +44,8 @@ class TransactionRuleForm(forms.ModelForm): Column(Switch("on_create")), Column(Switch("on_delete")), ), + "order", + Switch("sequenced"), "description", "trigger", ) @@ -149,6 +155,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): "search_category_operator": TomSelect(clear_button=False), "search_internal_note_operator": TomSelect(clear_button=False), "search_internal_id_operator": TomSelect(clear_button=False), + "search_mute_operator": TomSelect(clear_button=False), } labels = { @@ -166,6 +173,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): "search_internal_id_operator": _("Operator"), "search_tags_operator": _("Operator"), "search_entities_operator": _("Operator"), + "search_mute_operator": _("Operator"), "search_account": _("Account"), "search_type": _("Type"), "search_is_paid": _("Paid"), @@ -179,6 +187,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): "search_internal_id": _("Internal ID"), "search_tags": _("Tags"), "search_entities": _("Entities"), + "search_mute": _("Mute"), "set_account": _("Account"), "set_type": _("Type"), "set_is_paid": _("Paid"), @@ -192,6 +201,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): "set_category": _("Category"), "set_internal_note": _("Internal Note"), "set_internal_id": _("Internal ID"), + "set_mute": _("Mute"), } def __init__(self, *args, **kwargs): @@ -228,6 +238,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): css_class="form-group col-md-8", ), ), + Row( + Column( + Field("search_mute_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_mute", rows=1), + css_class="form-group col-md-8", + ), + ), Row( Column( Field("search_account_operator"), @@ -344,6 +364,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): _("Set Values"), Field("set_type", rows=1), Field("set_is_paid", rows=1), + Field("set_mute", rows=1), Field("set_account", rows=1), Field("set_entities", rows=1), Field("set_date", rows=1), @@ -385,3 +406,112 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): if commit: instance.save() return instance + + +class DryRunCreatedTransacion(forms.Form): + transaction = DynamicModelChoiceField( + model=Transaction, + to_field_name="id", + label=_("Transaction"), + required=True, + queryset=Transaction.objects.none(), + widget=TransactionSelect(clear_button=False, income=True, expense=True), + help_text=_("Type to search for a transaction"), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = Layout( + "transaction", + 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 + ) + + +class DryRunDeletedTransacion(forms.Form): + transaction = DynamicModelChoiceField( + model=Transaction, + to_field_name="id", + label=_("Transaction"), + required=True, + queryset=Transaction.objects.none(), + widget=TransactionSelect(clear_button=False, income=True, expense=True), + help_text=_("Type to search for a transaction"), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.layout = Layout( + "transaction", + 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 + ) + + +class DryRunUpdatedTransactionForm(BulkEditTransactionForm): + transaction = DynamicModelChoiceField( + model=Transaction, + to_field_name="id", + label=_("Transaction"), + required=True, + queryset=Transaction.objects.none(), + widget=TransactionSelect(clear_button=False, income=True, expense=True), + help_text=_("Type to search for a transaction"), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper.layout.insert(0, "transaction") + self.helper.layout.insert(1, HTML("
")) + + # 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 %} +
+ +
+ {% endif %} + {% endwith %} + {% endif %}
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
-
- {# Item actions#} -
-
+ {% endif %}
diff --git a/app/templates/rules/fragments/list.html b/app/templates/rules/fragments/list.html index 548f645..3d7a963 100644 --- a/app/templates/rules/fragments/list.html +++ b/app/templates/rules/fragments/list.html @@ -23,6 +23,7 @@ + {% translate 'Order' %} {% translate 'Name' %} @@ -80,6 +81,9 @@ {% endif %} + +
{{ rule.order }}
+
{{ rule.name }}
{{ rule.description }}
diff --git a/app/templates/rules/fragments/transaction_rule/dry_run/created.html b/app/templates/rules/fragments/transaction_rule/dry_run/created.html new file mode 100644 index 0000000..67834c4 --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/dry_run/created.html @@ -0,0 +1,16 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Edit transaction rule' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+
+
+ {% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %} +
+{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/dry_run/deleted.html b/app/templates/rules/fragments/transaction_rule/dry_run/deleted.html new file mode 100644 index 0000000..984d6b3 --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/dry_run/deleted.html @@ -0,0 +1,16 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Edit transaction rule' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+
+
+ {% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %} +
+{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/dry_run/updated.html b/app/templates/rules/fragments/transaction_rule/dry_run/updated.html new file mode 100644 index 0000000..9e50bd6 --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/dry_run/updated.html @@ -0,0 +1,16 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Edit transaction rule' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+
+
+ {% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %} +
+{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/dry_run/visual.html b/app/templates/rules/fragments/transaction_rule/dry_run/visual.html new file mode 100644 index 0000000..c3cd50f --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/dry_run/visual.html @@ -0,0 +1,101 @@ +{% load i18n %} +
+
+ +
+
+
+
+ {% if not results %} + {% translate 'Run a test to see...' %} + {% else %} + {% for result in results %} + + {% if result.type == 'header' %} +
+
+ + {% if result.header_type == "edit_transaction" %} + {% translate 'Edit transaction' %} + {% elif result.header_type == "update_or_create_transaction" %} + {% translate 'Update or create transaction' %} + {% endif %} + +
+
+ {% endif %} + + + {% if result.type == 'triggering_transaction' %} +
+
{% translate 'Start' %}
+ +
+ {% endif %} + + {% if result.type == 'edit_transaction' %} +
+
+ {% translate 'Set' %} {{ result.field }} {% translate 'to' %} + {{ result.new_value }} +
+ +
+ {% endif %} + + {% if result.type == 'update_or_create_transaction' %} +
+ + {% if result.start_transaction %} + + {% else %} + + {% endif %} +
+ +
+ {% endif %} + + {% if result.type == 'error' %} +
+ +
+ {% endif %} + + {% endfor %} + {% endif %} +
+
+ {% if not logs %} + {% translate 'Run a test to see...' %} + {% else %} +
+              {{ logs|linebreaks }}
+            
+ {% endif %} +
+
+
+
diff --git a/app/templates/rules/fragments/transaction_rule/view.html b/app/templates/rules/fragments/transaction_rule/view.html index d2eebe1..ebf19c5 100644 --- a/app/templates/rules/fragments/transaction_rule/view.html +++ b/app/templates/rules/fragments/transaction_rule/view.html @@ -66,7 +66,7 @@ data-text="{% translate "You won't be able to revert this!" %}" data-confirm-text="{% translate 'Yes, delete it!' %}" _="install prompt_swal"> - + @@ -101,33 +101,57 @@ data-text="{% translate "You won't be able to revert this!" %}" data-confirm-text="{% translate 'Yes, delete it!' %}" _="install prompt_swal"> - + {% endif %} - {% endfor %} - {% if not all_actions %} + {% empty %}
{% translate 'This rule has no actions' %}
- {% endif %} + {% endfor %}
-