From 2235bdeabb4e01facde4717c201fea9d7d40e631 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Tue, 2 Sep 2025 23:17:04 -0300 Subject: [PATCH] changes --- app/apps/rules/forms.py | 61 +++++++- app/apps/rules/tasks.py | 136 ++++++++---------- app/apps/rules/urls.py | 5 + app/apps/rules/views.py | 130 ++++++++++++++--- app/apps/transactions/forms.py | 115 ++++++++++++--- app/apps/transactions/models.py | 42 ++++++ .../transaction_rule/dry_run/logs.html | 7 - .../transaction_rule/dry_run/updated.html | 16 +++ .../transaction_rule/dry_run/visual.html | 6 +- .../fragments/transaction_rule/view.html | 8 +- 10 files changed, 398 insertions(+), 128 deletions(-) delete mode 100644 app/templates/rules/fragments/transaction_rule/dry_run/logs.html create mode 100644 app/templates/rules/fragments/transaction_rule/dry_run/updated.html diff --git a/app/apps/rules/forms.py b/app/apps/rules/forms.py index f3989c2..64c6201 100644 --- a/app/apps/rules/forms.py +++ b/app/apps/rules/forms.py @@ -1,16 +1,18 @@ 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.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 @@ -431,6 +433,17 @@ class DryRunCreatedTransacion(forms.Form): ), ) + 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( @@ -456,3 +469,49 @@ class DryRunDeletedTransacion(forms.Form): ), ), ) + + 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/tasks.py b/app/apps/rules/tasks.py index 6e259e8..ca06b7e 100644 --- a/app/apps/rules/tasks.py +++ b/app/apps/rules/tasks.py @@ -1,7 +1,6 @@ import decimal import logging import traceback -from copy import deepcopy from datetime import datetime, date from decimal import Decimal from itertools import chain @@ -53,7 +52,7 @@ class DryRunResults: ): result = { "type": "edit_transaction", - "transaction": deepcopy(instance), + "transaction": instance.deepcopy(), "action": action, "old_value": old_value, "new_value": new_value, @@ -248,7 +247,7 @@ def check_for_transaction_rules( if searched_transactions.exists(): transaction = searched_transactions.first() existing = True - starting_instance = deepcopy(transaction) + starting_instance = transaction.deepcopy() _log("Found at least one matching transaction, using latest:") _log("{}".format(pformat(model_to_dict(transaction)))) else: @@ -322,82 +321,62 @@ def check_for_transaction_rules( 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( - pformat(model_to_dict(transaction, exclude=["tags", "entities"])), - ) - ) + if not transaction.id: + _log("Transaction will be created as:") else: - if not transaction.id: - _log("Transaction will be created as:") - else: - _log("Trasanction will be updated as:") + _log("Trasanction will be updated as:") - _log( - "{}".format( - pformat(model_to_dict(transaction, exclude=["tags", "entities"])), - ) + _log( + "{}".format( + pformat(model_to_dict(transaction, exclude=["tags", "entities"])), ) - transaction.save() + ) + 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)) + _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=tags)) + 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): + _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=entities) + TransactionEntity.objects.get(id=entity) ) else: transaction.entities.add( - TransactionEntity.objects.get(name=entities) + 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=deepcopy(transaction), + end_instance=transaction.deepcopy(), updated=existing, action=processed_action, query=search_query, @@ -438,19 +417,19 @@ def check_for_transaction_rules( transaction.category = category elif field == TransactionRuleAction.Field.tags: - if not dry_run: - transaction.tags.clear() + 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) - if not dry_run: - transaction.tags.add(tag) + + transaction.tags.add(tag) tags.append(tag) elif isinstance(tag_value, str): tag = TransactionTag.objects.get(name=tag_value) - if not dry_run: - transaction.tags.add(tag) + + transaction.tags.add(tag) tags.append(tag) elif isinstance(new_value, (int, str)): @@ -459,24 +438,22 @@ def check_for_transaction_rules( else: tag = TransactionTag.objects.get(name=new_value) - if not dry_run: - transaction.tags.add(tag) + transaction.tags.add(tag) tags.append(tag) elif field == TransactionRuleAction.Field.entities: - if not dry_run: - transaction.entities.clear() + 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) - if not dry_run: - transaction.entities.add(entity) + + transaction.entities.add(entity) entities.append(entity) elif isinstance(entity_value, str): entity = TransactionEntity.objects.get(name=entity_value) - if not dry_run: - transaction.entities.add(entity) + + transaction.entities.add(entity) entities.append(entity) elif isinstance(new_value, (int, str)): @@ -484,8 +461,8 @@ def check_for_transaction_rules( entity = TransactionEntity.objects.get(id=new_value) else: entity = TransactionEntity.objects.get(name=new_value) - if not dry_run: - transaction.entities.add(entity) + + transaction.entities.add(entity) entities.append(entity) else: @@ -496,7 +473,7 @@ def check_for_transaction_rules( ) dry_run_results.edit_transaction( - instance=deepcopy(transaction), + instance=transaction.deepcopy(), action=processed_action, old_value=original_value, new_value=new_value, @@ -530,7 +507,7 @@ 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)) + dry_run_results.triggering_transaction(instance.deepcopy()) functions = { "relativedelta": relativedelta, @@ -667,7 +644,7 @@ def check_for_transaction_rules( level="error", ) # Save at the end - if not dry_run and signal != "transaction_deleted": + if signal != "transaction_deleted": instance.save() else: _log( @@ -702,7 +679,7 @@ def check_for_transaction_rules( if rule.sequenced: # Update names for next actions simple.names.update(_get_names(instance)) - if not dry_run and signal != "transaction_deleted": + if signal != "transaction_deleted": instance.save() for action in update_or_create_actions: @@ -741,7 +718,6 @@ def check_for_transaction_rules( delete_current_user() - if dry_run: - return logs, dry_run_results.results + return logs, dry_run_results.results return None diff --git a/app/apps/rules/urls.py b/app/apps/rules/urls.py index 99d272d..9ee0fb1 100644 --- a/app/apps/rules/urls.py +++ b/app/apps/rules/urls.py @@ -52,6 +52,11 @@ urlpatterns = [ 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/views.py b/app/apps/rules/views.py index e02b85e..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 _ @@ -14,6 +17,7 @@ from apps.rules.forms import ( UpdateOrCreateTransactionRuleActionForm, DryRunCreatedTransacion, DryRunDeletedTransacion, + DryRunUpdatedTransactionForm, ) from apps.rules.models import ( TransactionRule, @@ -25,6 +29,9 @@ 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 @@ -436,14 +443,28 @@ def dry_run_rule_created(request, pk): 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) + 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() @@ -467,14 +488,28 @@ def dry_run_rule_deleted(request, pk): 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) + 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() @@ -484,3 +519,66 @@ def dry_run_rule_deleted(request, pk): "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 8fd9231..684e6d8 100644 --- a/app/apps/transactions/forms.py +++ b/app/apps/transactions/forms.py @@ -389,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" @@ -425,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 f969997..cdbb069 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -462,6 +462,48 @@ class Transaction(OwnedObject): 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/templates/rules/fragments/transaction_rule/dry_run/logs.html b/app/templates/rules/fragments/transaction_rule/dry_run/logs.html deleted file mode 100644 index 8acb25c..0000000 --- a/app/templates/rules/fragments/transaction_rule/dry_run/logs.html +++ /dev/null @@ -1,7 +0,0 @@ -
-
-
-      {{ logs|linebreaks }}
-    
-
-
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 index b7fcc59..c3cd50f 100644 --- a/app/templates/rules/fragments/transaction_rule/dry_run/visual.html +++ b/app/templates/rules/fragments/transaction_rule/dry_run/visual.html @@ -53,8 +53,7 @@ {{ result.new_value }} + :disable-selection="True"> {% endif %} @@ -73,8 +72,7 @@ {% endif %}
+ :disable-selection="True"> {% endif %} diff --git a/app/templates/rules/fragments/transaction_rule/view.html b/app/templates/rules/fragments/transaction_rule/view.html index a8c5265..ebf19c5 100644 --- a/app/templates/rules/fragments/transaction_rule/view.html +++ b/app/templates/rules/fragments/transaction_rule/view.html @@ -124,17 +124,17 @@ {% if transaction_rule.on_create %}
  • {% trans 'On creation' %}
  • + hx-target="#generic-offcanvas">{% trans 'Create' %} {% endif %} {% if transaction_rule.on_update %}
  • {% trans 'On update' %}
  • + hx-get="{% url 'transaction_rule_dry_run_updated' pk=transaction_rule.id %}" + hx-target="#generic-offcanvas">{% trans 'Update' %} {% endif %} {% if transaction_rule.on_delete %}
  • {% trans 'On delete' %}
  • + hx-target="#generic-offcanvas">{% trans 'Delete' %} {% endif %}