This commit is contained in:
Herculino Trotta
2025-09-02 23:17:04 -03:00
parent d724300513
commit 2235bdeabb
10 changed files with 398 additions and 128 deletions

View File

@@ -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("<hr/>"))
# 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
)

View File

@@ -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

View File

@@ -52,6 +52,11 @@ urlpatterns = [
views.dry_run_rule_deleted,
name="transaction_rule_dry_run_deleted",
),
path(
"rules/transaction/<int:pk>/dry-run/updated/",
views.dry_run_rule_updated,
name="transaction_rule_dry_run_updated",
),
path(
"rules/transaction/<int:pk>/share/",
views.transaction_rule_share,

View File

@@ -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},
)

View File

@@ -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(

View File

@@ -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):

View File

@@ -1,7 +0,0 @@
<div class="card tw:max-h-full tw:overflow-auto tw:overflow-x-auto">
<div class="card-body">
<pre>
{{ logs|linebreaks }}
</pre>
</div>
</div>

View File

@@ -0,0 +1,16 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit transaction rule' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_rule_dry_run_updated' pk=rule.id %}" hx-target="#generic-offcanvas"
hx-indicator="#dry-run-updated-result, closest form" class="show-loading" novalidate>
{% crispy form %}
</form>
<hr>
<div id="dry-run-updated-result" class="show-loading">
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
</div>
{% endblock %}

View File

@@ -53,8 +53,7 @@
<span class="badge text-bg-secondary">{{ result.new_value }}</span>
</div>
<c-transaction.item :transaction="result.transaction" :dummy="True"
:disable-selection="True" :overriden_tags="result.tags"
:overriden_entities="result.entities"></c-transaction.item>
:disable-selection="True"></c-transaction.item>
</div>
{% endif %}
@@ -73,8 +72,7 @@
{% endif %}
<div class="text-center h3 my-2"><i class="fa-solid fa-arrow-down"></i></div>
<c-transaction.item :transaction="result.end_transaction" :dummy="True"
:disable-selection="True" :overriden_tags="result.tags"
:overriden_entities="result.entities"></c-transaction.item>
:disable-selection="True"></c-transaction.item>
</div>
{% endif %}

View File

@@ -124,17 +124,17 @@
{% if transaction_rule.on_create %}
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_dry_run_created' pk=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'On creation' %}</a></li>
hx-target="#generic-offcanvas">{% trans 'Create' %}</a></li>
{% endif %}
{% if transaction_rule.on_update %}
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_dry_run_deleted' pk=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'On update' %}</a></li>
hx-get="{% url 'transaction_rule_dry_run_updated' pk=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Update' %}</a></li>
{% endif %}
{% if transaction_rule.on_delete %}
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_dry_run_deleted' pk=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'On delete' %}</a></li>
hx-target="#generic-offcanvas">{% trans 'Delete' %}</a></li>
{% endif %}
</ul>
</div>