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..db15d62 100644 --- a/app/apps/rules/forms.py +++ b/app/apps/rules/forms.py @@ -7,9 +7,11 @@ 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.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.models import Transaction class TransactionRuleForm(forms.ModelForm): @@ -40,6 +42,7 @@ class TransactionRuleForm(forms.ModelForm): Column(Switch("on_create")), Column(Switch("on_delete")), ), + Switch("sequenced"), "description", "trigger", ) @@ -149,6 +152,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 +170,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 +184,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 +198,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 +235,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 +361,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 +403,55 @@ 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" + ), + ), + ) + + +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" + ), + ), + ) diff --git a/app/apps/rules/models.py b/app/apps/rules/models.py index edaebe7..8a6214a 100644 --- a/app/apps/rules/models.py +++ b/app/apps/rules/models.py @@ -13,6 +13,10 @@ 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, + ) objects = SharedObjectManager() all_objects = models.Manager() # Unfiltered manager @@ -32,12 +36,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 +250,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 +314,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 +360,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..c9152f5 100644 --- a/app/apps/rules/tasks.py +++ b/app/apps/rules/tasks.py @@ -1,12 +1,19 @@ import decimal import logging +import traceback +from copy import deepcopy from datetime import datetime, date from decimal import Decimal from itertools import chain +from pprint import pformat +from random import randint, random +from textwrap import indent +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 +34,495 @@ from apps.rules.utils import transactions logger = logging.getLogger(__name__) +class DryRunResults: + def __init__(self): + self.results = [] + + 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": deepcopy(instance), + "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, + action: Literal["update_or_create_transaction", "edit_transaction"], + error, + action_obj, + ): + result = { + "type": "error", + "action": action, + "action_obj": action_obj, + "error": error, + "traceback": traceback.format_exc(), + } + 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(instance: Transaction | dict, prefix: str = ""): + if isinstance(instance, Transaction): + return { + f"{prefix}id": instance.id, + f"{prefix}account_name": instance.account.name if instance.id else None, + f"{prefix}account_id": instance.account.id if instance.id else None, + f"{prefix}account_group_name": ( + instance.account.group.name + if instance.id and instance.account.group + else None + ), + f"{prefix}account_group_id": ( + instance.account.group.id + if instance.id and instance.account.group + else None + ), + f"{prefix}is_asset_account": ( + instance.account.is_asset if instance.id else None + ), + f"{prefix}is_archived_account": ( + instance.account.is_archived if instance.id else None + ), + f"{prefix}category_name": ( + instance.category.name if instance.category else None + ), + f"{prefix}category_id": ( + instance.category.id if instance.category else None + ), + f"{prefix}tag_names": ( + [x.name for x in instance.tags.all()] if instance.id else [] + ), + f"{prefix}tag_ids": ( + [x.id for x in instance.tags.all()] if instance.id else [] + ), + f"{prefix}entities_names": ( + [x.name for x in instance.entities.all()] if instance.id else [] + ), + f"{prefix}entities_ids": ( + [x.id for x in instance.entities.all()] if instance.id else [] + ), + f"{prefix}is_expense": instance.type == Transaction.Type.EXPENSE, + f"{prefix}is_income": instance.type == Transaction.Type.INCOME, + f"{prefix}is_paid": instance.is_paid, + f"{prefix}description": instance.description, + f"{prefix}amount": instance.amount or 0, + f"{prefix}notes": instance.notes, + f"{prefix}date": instance.date, + f"{prefix}reference_date": instance.reference_date, + f"{prefix}internal_note": instance.internal_note, + f"{prefix}internal_id": instance.internal_id, + f"{prefix}is_deleted": instance.deleted, + f"{prefix}is_muted": instance.mute, + } + else: + return { + f"{prefix}id": instance.get("id"), + f"{prefix}account_name": instance.get("account", (None, None))[1], + f"{prefix}account_id": instance.get("account", (None, None))[0], + f"{prefix}account_group_name": instance.get( + "account_group", (None, None) + )[1], + f"{prefix}account_group_id": instance.get( + "account_group", (None, None) + )[0], + f"{prefix}is_asset_account": instance.get("is_asset"), + f"{prefix}is_archived_account": instance.get("is_archived"), + f"{prefix}category_name": instance.get("category", (None, None))[1], + f"{prefix}category_id": instance.get("category", (None, None))[0], + f"{prefix}tag_names": [x[1] for x in instance.get("tags", [])], + f"{prefix}tag_ids": [x[0] for x in instance.get("tags", [])], + f"{prefix}entities_names": [x[1] for x in instance.get("entities", [])], + f"{prefix}entities_ids": [x[0] for x in instance.get("entities", [])], + f"{prefix}is_expense": instance.get("type") == Transaction.Type.EXPENSE, + f"{prefix}is_income": instance.get("type") == Transaction.Type.INCOME, + f"{prefix}is_paid": instance.get("is_paid"), + f"{prefix}description": instance.get("description", ""), + f"{prefix}amount": Decimal(instance.get("amount")), + f"{prefix}notes": instance.get("notes", ""), + f"{prefix}date": datetime.fromisoformat(instance.get("date")), + f"{prefix}reference_date": datetime.fromisoformat( + instance.get("reference_date") + ), + f"{prefix}internal_note": instance.get("internal_note", ""), + f"{prefix}internal_id": instance.get("internal_id", ""), + f"{prefix}is_deleted": instance.get("deleted", True), + f"{prefix}is_muted": instance.get("mute", False), + } + + def _process_update_or_create_transaction_action(processed_action): + """Helper to process a single linked transaction 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: + transactions = Transaction.objects.filter(search_query).order_by( + "-date", "-id" + ) + if transactions.exists(): + transaction = transactions.first() + existing = True + starting_instance = deepcopy(transaction) + _log(" ├─ Found at least one matching transaction, using latest:") + _log( + " ├─ {}".format( + indent(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: + _log( + " ├─ Filter did not match. Execution of this action has been 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 dry_run: + if not transaction.id: + _log(" ├─ Transaction would be created as:") + else: + _log(" ├─ Trasanction would be updated as:") + + _log( + " ├─ {}".format( + indent( + pformat( + model_to_dict(transaction, exclude=["tags", "entities"]) + ), + " ", + ) + ) + ) + else: + if not transaction.id: + _log(" ├─ Transaction will be created as:") + else: + _log(" ├─ Trasanction will be updated as:") + + _log( + " ├─ {}".format( + indent( + 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) + if dry_run: + _log(f" ├─ And tags would be set as: {tags}") + else: + _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) + if dry_run: + _log(f" ├─ And entities would be set as: {entities}") + else: + _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) + ) + + transaction.full_clean() + + dry_run_results.update_or_create_transaction( + start_instance=starting_instance, + end_instance=deepcopy(transaction), + updated=existing, + action=processed_action, + query=search_query, + entities=entities, + tags=tags, + ) + + def _process_edit_transaction_action(instance, processed_action) -> Transaction: + field = processed_action.field + original_value = getattr(instance, field) + new_value = simple.eval(processed_action.value) + + tags = [] + entities = [] + + _log( + f" ├─ Changing field '{field}' from '{original_value}' to '{new_value}'", + ) + + form_data = {} + + if 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, + TransactionRuleAction.Field.mute, + TransactionRuleAction.Field.internal_note, + TransactionRuleAction.Field.internal_id, + ]: + setattr( + instance, + field, + new_value, + ) + + elif field == TransactionRuleAction.Field.account: + if isinstance(new_value, int): + account = Account.objects.get(id=new_value) + instance.account = account + elif isinstance(new_value, str): + account = Account.objects.filter(name=new_value).first() + instance.account = account + + elif field == TransactionRuleAction.Field.category: + if isinstance(new_value, int): + category = TransactionCategory.objects.get(id=new_value) + instance.category = category + elif isinstance(new_value, str): + category = TransactionCategory.objects.get(name=new_value) + instance.category = category + + elif field == TransactionRuleAction.Field.tags: + if not dry_run: + instance.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) + if not dry_run: + instance.tags.add(tag) + tags.append(tag) + elif isinstance(tag_value, str): + tag = TransactionTag.objects.get(name=tag_value) + if not dry_run: + instance.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) + + if not dry_run: + instance.tags.add(tag) + tags.append(tag) + + elif field == TransactionRuleAction.Field.entities: + if not dry_run: + instance.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) + if not dry_run: + instance.entities.add(entity) + entities.append(entity) + elif isinstance(entity_value, str): + entity = TransactionEntity.objects.get(name=entity_value) + if not dry_run: + instance.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) + if not dry_run: + instance.entities.add(entity) + entities.append(entity) + + instance.full_clean() + + dry_run_results.edit_transaction( + instance=deepcopy(instance), + action=processed_action, + old_value=original_value, + new_value=new_value, + field=field, + tags=tags, + entities=entities, + ) + + return instance + 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,21 +537,32 @@ def check_for_transaction_rules( # Regular transaction processing for creates and updates instance = Transaction.objects.get(id=instance_id) + dry_run_results.triggering_transaction(deepcopy(instance)) + 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": rules = TransactionRule.objects.filter( @@ -82,23 +579,31 @@ def check_for_transaction_rules( else: rules = TransactionRule.objects.filter(active=True).order_by("id") + if dry_run and rule_id: + rules = rules.filter(id=rule_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: _process_update_or_create_transaction_action( - action=action, simple_eval=simple + processed_action=action, ) except Exception as e: - logger.error( - f"Error processing update or create transaction action {action.id} on deletion", - exc_info=True, + _log( + f"├─ Error processing update or create transaction action {action.id} on deletion", + level="error", ) else: # Normal processing for non-deleted transactions @@ -113,10 +618,14 @@ 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: @@ -124,354 +633,77 @@ def check_for_transaction_rules( if isinstance(action, TransactionRuleAction): instance = _process_edit_transaction_action( instance=instance, - action=action, - simple_eval=simple, + 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 + processed_action=action, ) + _clear_names("my_") except Exception as e: - logger.error( - f"Error processing action {action.id}", - exc_info=True, + _log( + f"├─ Error processing action {action.id}", + level="error", ) # Save at the end - if signal != "transaction_deleted": + if not dry_run and 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: try: instance = _process_edit_transaction_action( instance=instance, - action=action, - simple_eval=simple, + processed_action=action, ) + if rule.sequenced: + # Update names for next actions + simple.names.update(_get_names(instance)) except Exception as e: - logger.error( - f"Error processing edit transaction action {action.id}", - exc_info=True, + _log( + f"├─ Error processing edit transaction action {action.id}", + level="error", ) - simple.names.update(_get_names(instance)) - if signal != "transaction_deleted": + if rule.sequenced: + # Update names for next actions + simple.names.update(_get_names(instance)) + if not dry_run and signal != "transaction_deleted": instance.save() for action in update_or_create_actions: 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, + _log( + f"├─ Error processing update or create transaction action {action.id}", + level="error", ) + else: + _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() + if dry_run: + 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..99d272d 100644 --- a/app/apps/rules/urls.py +++ b/app/apps/rules/urls.py @@ -42,6 +42,16 @@ 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//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..7b96a8f 100644 --- a/app/apps/rules/views.py +++ b/app/apps/rules/views.py @@ -12,6 +12,8 @@ from apps.rules.forms import ( TransactionRuleForm, TransactionRuleActionForm, UpdateOrCreateTransactionRuleActionForm, + DryRunCreatedTransacion, + DryRunDeletedTransacion, ) from apps.rules.models import ( TransactionRule, @@ -21,6 +23,8 @@ 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 @login_required @@ -143,7 +147,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 +422,65 @@ 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(): + 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) + + 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(): + 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) + + else: + form = DryRunDeletedTransacion() + + return render( + request, + "rules/fragments/transaction_rule/dry_run/deleted.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..8fd9231 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 diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 0e31531..5c646df 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 diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index 286b835..524039c 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -177,6 +177,7 @@ def transaction_edit(request, transaction_id, **kwargs): transaction = get_object_or_404(Transaction, id=transaction_id) if request.method == "POST": + print(request.POST, request.POST.items()) form = TransactionForm(request.POST, instance=transaction) if form.is_valid(): form.save() @@ -213,6 +214,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 +227,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 +375,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 +399,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 +420,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 +447,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..ca9e920 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,19 @@
{# Entities #} - {% with transaction.entities.all as entities %} - {% if entities %} -
-
-
{{ entities|join:", " }}
-
- {% endif %} - {% endwith %} + {% with transaction.entities.all as entities %} + {% if entities and not overriden_entities %} +
+
+
{{ entities|join:", " }}
+
+ {% elif overriden_entities %} +
+
+
{{ overriden_entities|join:", " }}
+
+ {% endif %} + {% endwith %} {# Notes#} {% if transaction.notes %}
@@ -82,16 +90,22 @@ {% endif %} {# Tags#} {% with transaction.tags.all as tags %} - {% if tags %} + {% if tags and not overriden_tags %}
{{ tags|join:", " }}
+ {% elif overriden_tags %} +
+
+
{{ overriden_tags|join:", " }}
+
{% endif %} {% endwith %}
-
+
{# 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/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/logs.html b/app/templates/rules/fragments/transaction_rule/dry_run/logs.html new file mode 100644 index 0000000..8acb25c --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/dry_run/logs.html @@ -0,0 +1,7 @@ +
+
+
+      {{ logs|linebreaks }}
+    
+
+
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..bc777fc --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/dry_run/visual.html @@ -0,0 +1,82 @@ +{% load i18n %} +
+
+ +
+
+
+
+ {% if not results %} + {% translate 'Run a test to see...' %} + {% else %} + {% for result in results %} + + {% if result.type == 'triggering_transaction' %} +
+
{% translate 'Start' %}
+ +
+ {% endif %} + + {% if result.type == 'edit_transaction' %} +
+
{% translate 'Edit transaction' %} +
+
+ {% translate 'Set' %} {{ result.field }} {% translate 'to' %} + {{ result.new_value }} +
+ +
+ {% endif %} + + {% if result.type == 'update_or_create_transaction' %} +
+
{% translate 'Update or create transaction' %} +
+ + {% if result.start_transaction %} + + {% else %} + + {% endif %} +
+ +
+ {% 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..a8c5265 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 %}
-