This commit is contained in:
Herculino Trotta
2025-09-02 09:47:27 -03:00
parent 72904266bf
commit c738f5ee29
19 changed files with 1125 additions and 499 deletions

View File

@@ -1,3 +1,5 @@
from copy import deepcopy
from rest_framework import viewsets from rest_framework import viewsets
from apps.api.custom.pagination import CustomPageNumberPagination from apps.api.custom.pagination import CustomPageNumberPagination
@@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet):
transaction_created.send(sender=instance) transaction_created.send(sender=instance)
def perform_update(self, serializer): def perform_update(self, serializer):
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
instance = serializer.save() instance = serializer.save()
transaction_updated.send(sender=instance) transaction_updated.send(sender=instance, old_data=old_data)
def partial_update(self, request, *args, **kwargs): def partial_update(self, request, *args, **kwargs):
kwargs["partial"] = True kwargs["partial"] = True

View File

@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
:param decimal_places: The number of decimal places to keep :param decimal_places: The number of decimal places to keep
:return: Truncated Decimal value :return: Truncated Decimal value
""" """
if isinstance(value, (int, float)):
value = Decimal(str(value))
multiplier = Decimal(10**decimal_places) multiplier = Decimal(10**decimal_places)
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier

View File

@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
currency.full_clean() 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): def test_currency_unique_name(self):
"""Test that currency names must be unique""" """Test that currency names must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2) Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)

View File

@@ -7,9 +7,11 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ 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 from apps.common.widgets.tom_select import TomSelect, TransactionSelect
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.models import TransactionRuleAction 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): class TransactionRuleForm(forms.ModelForm):
@@ -40,6 +42,7 @@ class TransactionRuleForm(forms.ModelForm):
Column(Switch("on_create")), Column(Switch("on_create")),
Column(Switch("on_delete")), Column(Switch("on_delete")),
), ),
Switch("sequenced"),
"description", "description",
"trigger", "trigger",
) )
@@ -149,6 +152,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_category_operator": TomSelect(clear_button=False), "search_category_operator": TomSelect(clear_button=False),
"search_internal_note_operator": TomSelect(clear_button=False), "search_internal_note_operator": TomSelect(clear_button=False),
"search_internal_id_operator": TomSelect(clear_button=False), "search_internal_id_operator": TomSelect(clear_button=False),
"search_mute_operator": TomSelect(clear_button=False),
} }
labels = { labels = {
@@ -166,6 +170,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_internal_id_operator": _("Operator"), "search_internal_id_operator": _("Operator"),
"search_tags_operator": _("Operator"), "search_tags_operator": _("Operator"),
"search_entities_operator": _("Operator"), "search_entities_operator": _("Operator"),
"search_mute_operator": _("Operator"),
"search_account": _("Account"), "search_account": _("Account"),
"search_type": _("Type"), "search_type": _("Type"),
"search_is_paid": _("Paid"), "search_is_paid": _("Paid"),
@@ -179,6 +184,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_internal_id": _("Internal ID"), "search_internal_id": _("Internal ID"),
"search_tags": _("Tags"), "search_tags": _("Tags"),
"search_entities": _("Entities"), "search_entities": _("Entities"),
"search_mute": _("Mute"),
"set_account": _("Account"), "set_account": _("Account"),
"set_type": _("Type"), "set_type": _("Type"),
"set_is_paid": _("Paid"), "set_is_paid": _("Paid"),
@@ -192,6 +198,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"set_category": _("Category"), "set_category": _("Category"),
"set_internal_note": _("Internal Note"), "set_internal_note": _("Internal Note"),
"set_internal_id": _("Internal ID"), "set_internal_id": _("Internal ID"),
"set_mute": _("Mute"),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -228,6 +235,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
css_class="form-group col-md-8", 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( Row(
Column( Column(
Field("search_account_operator"), Field("search_account_operator"),
@@ -344,6 +361,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
_("Set Values"), _("Set Values"),
Field("set_type", rows=1), Field("set_type", rows=1),
Field("set_is_paid", rows=1), Field("set_is_paid", rows=1),
Field("set_mute", rows=1),
Field("set_account", rows=1), Field("set_account", rows=1),
Field("set_entities", rows=1), Field("set_entities", rows=1),
Field("set_date", rows=1), Field("set_date", rows=1),
@@ -385,3 +403,55 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
if commit: if commit:
instance.save() instance.save()
return instance 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"
),
),
)

View File

@@ -13,6 +13,10 @@ class TransactionRule(SharedObject):
name = models.CharField(max_length=100, verbose_name=_("Name")) name = models.CharField(max_length=100, verbose_name=_("Name"))
description = models.TextField(blank=True, null=True, verbose_name=_("Description")) description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
trigger = models.TextField(verbose_name=_("Trigger")) trigger = models.TextField(verbose_name=_("Trigger"))
sequenced = models.BooleanField(
verbose_name=_("Sequenced"),
default=False,
)
objects = SharedObjectManager() objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager all_objects = models.Manager() # Unfiltered manager
@@ -32,12 +36,15 @@ class TransactionRuleAction(models.Model):
is_paid = "is_paid", _("Paid") is_paid = "is_paid", _("Paid")
date = "date", _("Date") date = "date", _("Date")
reference_date = "reference_date", _("Reference Date") reference_date = "reference_date", _("Reference Date")
mute = "mute", _("Mute")
amount = "amount", _("Amount") amount = "amount", _("Amount")
description = "description", _("Description") description = "description", _("Description")
notes = "notes", _("Notes") notes = "notes", _("Notes")
category = "category", _("Category") category = "category", _("Category")
tags = "tags", _("Tags") tags = "tags", _("Tags")
entities = "entities", _("Entities") entities = "entities", _("Entities")
internal_note = "internal_nome", _("Internal Note")
internal_id = "internal_id", _("Internal ID")
rule = models.ForeignKey( rule = models.ForeignKey(
TransactionRule, TransactionRule,
@@ -243,6 +250,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name="Internal ID Operator", 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 fields
set_account = models.TextField( set_account = models.TextField(
verbose_name=_("Account"), verbose_name=_("Account"),
@@ -296,6 +314,11 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
verbose_name=_("Tags"), verbose_name=_("Tags"),
blank=True, blank=True,
) )
set_mute = models.TextField(
verbose_name=_("Mute"),
blank=True,
)
order = models.PositiveIntegerField(default=0, verbose_name=_("Order")) order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
class Meta: class Meta:
@@ -337,6 +360,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
value = simple.eval(self.search_is_paid) value = simple.eval(self.search_is_paid)
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator) 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: if self.search_date:
value = simple.eval(self.search_date) value = simple.eval(self.search_date)
search_query &= add_to_query("date", value, self.search_date_operator) search_query &= add_to_query("date", value, self.search_date_operator)

View File

@@ -9,40 +9,17 @@ from apps.transactions.models import (
) )
from apps.rules.tasks import check_for_transaction_rules from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user from apps.common.middleware.thread_local import get_current_user
from apps.rules.utils.transactions import serialize_transaction
@receiver(transaction_created) @receiver(transaction_created)
@receiver(transaction_updated) @receiver(transaction_updated)
@receiver(transaction_deleted) @receiver(transaction_deleted)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs): def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
old_data = kwargs.get("old_data")
if signal is transaction_deleted: if signal is transaction_deleted:
# Serialize transaction data for processing # Serialize transaction data for processing
transaction_data = { transaction_data = serialize_transaction(sender, deleted=True)
"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,
}
check_for_transaction_rules.defer( check_for_transaction_rules.defer(
transaction_data=transaction_data, transaction_data=transaction_data,
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
dca_entry.amount_received = sender.amount dca_entry.amount_received = sender.amount
dca_entry.save() dca_entry.save()
if signal is transaction_updated and old_data:
old_data = serialize_transaction(old_data, deleted=False)
check_for_transaction_rules.defer( check_for_transaction_rules.defer(
instance_id=sender.id, instance_id=sender.id,
user_id=get_current_user().id, user_id=get_current_user().id,
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
if signal is transaction_created if signal is transaction_created
else "transaction_updated" else "transaction_updated"
), ),
old_data=old_data,
) )

View File

@@ -1,12 +1,19 @@
import decimal import decimal
import logging import logging
import traceback
from copy import deepcopy
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal from decimal import Decimal
from itertools import chain 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 cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.forms import model_to_dict
from procrastinate.contrib.django import app from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes from simpleeval import EvalWithCompoundTypes
@@ -27,16 +34,495 @@ from apps.rules.utils import transactions
logger = logging.getLogger(__name__) 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") @app.task(name="check_for_transaction_rules")
def check_for_transaction_rules( def check_for_transaction_rules(
instance_id=None, instance_id=None,
transaction_data=None, transaction_data=None,
old_data=None,
user_id=None, user_id=None,
signal=None, signal=None,
is_hard_deleted=False, 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) user = get_user_model().objects.get(id=user_id)
write_current_user(user) 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: try:
with cachalot_disabled(): with cachalot_disabled():
@@ -51,21 +537,32 @@ def check_for_transaction_rules(
# Regular transaction processing for creates and updates # Regular transaction processing for creates and updates
instance = Transaction.objects.get(id=instance_id) instance = Transaction.objects.get(id=instance_id)
dry_run_results.triggering_transaction(deepcopy(instance))
functions = { functions = {
"relativedelta": relativedelta, "relativedelta": relativedelta,
"str": str, "str": str,
"int": int, "int": int,
"float": float, "float": float,
"abs": abs,
"randint": randint,
"random": random,
"decimal": decimal.Decimal, "decimal": decimal.Decimal,
"datetime": datetime, "datetime": datetime,
"date": date, "date": date,
"transactions": transactions.TransactionsGetter, "transactions": transactions.TransactionsGetter,
} }
_log("-> Starting rule execution...")
_log("-> Available functions: {}".format(functions.keys()))
names = _get_names(instance) names = _get_names(instance)
simple = EvalWithCompoundTypes(names=names, functions=functions) 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 # Select rules based on the signal type
if signal == "transaction_created": if signal == "transaction_created":
rules = TransactionRule.objects.filter( rules = TransactionRule.objects.filter(
@@ -82,23 +579,31 @@ def check_for_transaction_rules(
else: else:
rules = TransactionRule.objects.filter(active=True).order_by("id") 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 # Process the rules as before
for rule in rules: for rule in rules:
_log("Testing rule: {}".format(rule.name))
if simple.eval(rule.trigger): 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": if signal == "transaction_deleted":
_log(
"├─ Event is of type 'delete'. Only processing Update or Create actions..."
)
# Process only create/update actions, not edit actions # Process only create/update actions, not edit actions
for ( for action in rule.update_or_create_transaction_actions.all():
action
) in rule.update_or_create_transaction_actions.all():
try: try:
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
action=action, simple_eval=simple processed_action=action,
) )
except Exception as e: except Exception as e:
logger.error( _log(
f"Error processing update or create transaction action {action.id} on deletion", f"├─ Error processing update or create transaction action {action.id} on deletion",
exc_info=True, level="error",
) )
else: else:
# Normal processing for non-deleted transactions # 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) ) or any(a.order > 0 for a in update_or_create_actions)
if has_custom_order: 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 # Combine and sort actions by order
all_actions = sorted( all_actions = sorted(
chain(edit_actions, update_or_create_actions), chain(edit_actions, update_or_create_actions),
key=lambda a: a.order, key=lambda a: (a.order, a.id),
) )
for action in all_actions: for action in all_actions:
@@ -124,354 +633,77 @@ def check_for_transaction_rules(
if isinstance(action, TransactionRuleAction): if isinstance(action, TransactionRuleAction):
instance = _process_edit_transaction_action( instance = _process_edit_transaction_action(
instance=instance, instance=instance,
action=action, processed_action=action,
simple_eval=simple,
) )
# 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: else:
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
action=action, simple_eval=simple processed_action=action,
) )
_clear_names("my_")
except Exception as e: except Exception as e:
logger.error( _log(
f"Error processing action {action.id}", f"├─ Error processing action {action.id}",
exc_info=True, level="error",
) )
# Save at the end # Save at the end
if signal != "transaction_deleted": if not dry_run and signal != "transaction_deleted":
instance.save() instance.save()
else: 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 # Original behavior
for action in edit_actions: for action in edit_actions:
try: try:
instance = _process_edit_transaction_action( instance = _process_edit_transaction_action(
instance=instance, instance=instance,
action=action, processed_action=action,
simple_eval=simple,
) )
if rule.sequenced:
# Update names for next actions
simple.names.update(_get_names(instance))
except Exception as e: except Exception as e:
logger.error( _log(
f"Error processing edit transaction action {action.id}", f"├─ Error processing edit transaction action {action.id}",
exc_info=True, level="error",
) )
simple.names.update(_get_names(instance)) if rule.sequenced:
if signal != "transaction_deleted": # Update names for next actions
simple.names.update(_get_names(instance))
if not dry_run and signal != "transaction_deleted":
instance.save() instance.save()
for action in update_or_create_actions: for action in update_or_create_actions:
try: try:
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
action=action, simple_eval=simple processed_action=action,
) )
_clear_names("my_")
except Exception as e: except Exception as e:
logger.error( _log(
f"Error processing update or create transaction action {action.id}", f"├─ Error processing update or create transaction action {action.id}",
exc_info=True, level="error",
) )
else:
_log("├─ Initial trigger didn't match, this rule will be skipped")
except Exception as e: except Exception as e:
logger.error( _log(
"Error while executing 'check_for_transaction_rules' task", "** Error while executing 'check_for_transaction_rules' task",
exc_info=True, level="error",
) )
delete_current_user() delete_current_user()
raise e if not dry_run:
raise e
delete_current_user() delete_current_user()
if dry_run:
return logs, dry_run_results.results
def _get_names(instance: Transaction | dict): return None
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

View File

@@ -42,6 +42,16 @@ urlpatterns = [
views.transaction_rule_take_ownership, views.transaction_rule_take_ownership,
name="transaction_rule_take_ownership", name="transaction_rule_take_ownership",
), ),
path(
"rules/transaction/<int:pk>/dry-run/created/",
views.dry_run_rule_created,
name="transaction_rule_dry_run_created",
),
path(
"rules/transaction/<int:pk>/dry-run/deleted/",
views.dry_run_rule_deleted,
name="transaction_rule_dry_run_deleted",
),
path( path(
"rules/transaction/<int:pk>/share/", "rules/transaction/<int:pk>/share/",
views.transaction_rule_share, views.transaction_rule_share,

View File

@@ -56,3 +56,33 @@ class TransactionsGetter:
output_field=DecimalField(), output_field=DecimalField(),
) )
)["balance"] )["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,
}

View File

@@ -12,6 +12,8 @@ from apps.rules.forms import (
TransactionRuleForm, TransactionRuleForm,
TransactionRuleActionForm, TransactionRuleActionForm,
UpdateOrCreateTransactionRuleActionForm, UpdateOrCreateTransactionRuleActionForm,
DryRunCreatedTransacion,
DryRunDeletedTransacion,
) )
from apps.rules.models import ( from apps.rules.models import (
TransactionRule, TransactionRule,
@@ -21,6 +23,8 @@ from apps.rules.models import (
from apps.common.models import SharedObject from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm from apps.common.forms import SharedObjectForm
from apps.common.decorators.demo import disabled_on_demo 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 @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) transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
edit_actions = transaction_rule.transaction_actions.all() 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( all_actions = sorted(
chain(edit_actions, update_or_create_actions), 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", "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},
)

View File

@@ -1,3 +1,5 @@
from copy import deepcopy
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
@@ -239,11 +241,16 @@ class TransactionForm(forms.ModelForm):
def save(self, **kwargs): def save(self, **kwargs):
is_new = not self.instance.id 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) instance = super().save(**kwargs)
if is_new: if is_new:
transaction_created.send(sender=instance) transaction_created.send(sender=instance)
else: else:
transaction_updated.send(sender=instance) transaction_updated.send(sender=instance, old_data=old_data)
return instance return instance

View File

@@ -1,4 +1,5 @@
import logging import logging
from copy import deepcopy
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.conf import settings from django.conf import settings
@@ -33,13 +34,13 @@ transaction_deleted = Signal()
class SoftDeleteQuerySet(models.QuerySet): class SoftDeleteQuerySet(models.QuerySet):
@staticmethod @staticmethod
def _emit_signals(instances, created=False): def _emit_signals(instances, created=False, old_data=None):
"""Helper to emit signals for multiple instances""" """Helper to emit signals for multiple instances"""
for instance in instances: for i, instance in enumerate(instances):
if created: if created:
transaction_created.send(sender=instance) transaction_created.send(sender=instance)
else: 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): def bulk_create(self, objs, emit_signal=True, **kwargs):
instances = super().bulk_create(objs, **kwargs) instances = super().bulk_create(objs, **kwargs)
@@ -50,22 +51,25 @@ class SoftDeleteQuerySet(models.QuerySet):
return instances return instances
def bulk_update(self, objs, fields, emit_signal=True, **kwargs): def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
old_data = deepcopy(objs)
result = super().bulk_update(objs, fields, **kwargs) result = super().bulk_update(objs, fields, **kwargs)
if emit_signal: if emit_signal:
self._emit_signals(objs, created=False) self._emit_signals(objs, created=False, old_data=old_data)
return result return result
def update(self, emit_signal=True, **kwargs): def update(self, emit_signal=True, **kwargs):
# Get instances before update # Get instances before update
instances = list(self) instances = list(self)
old_data = deepcopy(instances)
result = super().update(**kwargs) result = super().update(**kwargs)
if emit_signal: if emit_signal:
# Refresh instances to get new values # Refresh instances to get new values
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances]) 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 return result

View File

@@ -177,6 +177,7 @@ def transaction_edit(request, transaction_id, **kwargs):
transaction = get_object_or_404(Transaction, id=transaction_id) transaction = get_object_or_404(Transaction, id=transaction_id)
if request.method == "POST": if request.method == "POST":
print(request.POST, request.POST.items())
form = TransactionForm(request.POST, instance=transaction) form = TransactionForm(request.POST, instance=transaction)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@@ -213,6 +214,7 @@ def transactions_bulk_edit(request):
if form.is_valid(): if form.is_valid():
# Apply changes from the form to all selected transactions # Apply changes from the form to all selected transactions
for transaction in transactions: for transaction in transactions:
old_data = deepcopy(transaction)
for field_name, value in form.cleaned_data.items(): for field_name, value in form.cleaned_data.items():
if value or isinstance( if value or isinstance(
value, bool value, bool
@@ -225,7 +227,7 @@ def transactions_bulk_edit(request):
setattr(transaction, field_name, value) setattr(transaction, field_name, value)
transaction.save() transaction.save()
transaction_updated.send(sender=transaction) transaction_updated.send(sender=transaction, old_data=old_data)
messages.success( messages.success(
request, request,
@@ -373,10 +375,13 @@ def transactions_transfer(request):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def transaction_pay(request, transaction_id): def transaction_pay(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=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 new_is_paid = False if transaction.is_paid else True
transaction.is_paid = new_is_paid transaction.is_paid = new_is_paid
transaction.save() transaction.save()
transaction_updated.send(sender=transaction)
transaction_updated.send(sender=transaction, old_data=old_data)
response = render( response = render(
request, request,
@@ -394,11 +399,12 @@ def transaction_pay(request, transaction_id):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def transaction_mute(request, transaction_id): def transaction_mute(request, transaction_id):
transaction = get_object_or_404(Transaction, pk=transaction_id) transaction = get_object_or_404(Transaction, pk=transaction_id)
old_data = deepcopy(transaction)
new_mute = False if transaction.mute else True new_mute = False if transaction.mute else True
transaction.mute = new_mute transaction.mute = new_mute
transaction.save() transaction.save()
transaction_updated.send(sender=transaction) transaction_updated.send(sender=transaction, old_data=old_data)
response = render( response = render(
request, request,
@@ -414,19 +420,20 @@ def transaction_mute(request, transaction_id):
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def transaction_change_month(request, transaction_id, change_type): def transaction_change_month(request, transaction_id, change_type):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id) transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
old_data = deepcopy(transaction)
if change_type == "next": if change_type == "next":
transaction.reference_date = transaction.reference_date + relativedelta( transaction.reference_date = transaction.reference_date + relativedelta(
months=1 months=1
) )
transaction.save() transaction.save()
transaction_updated.send(sender=transaction) transaction_updated.send(sender=transaction, old_data=old_data)
elif change_type == "previous": elif change_type == "previous":
transaction.reference_date = transaction.reference_date - relativedelta( transaction.reference_date = transaction.reference_date - relativedelta(
months=1 months=1
) )
transaction.save() transaction.save()
transaction_updated.send(sender=transaction) transaction_updated.send(sender=transaction, old_data=old_data)
return HttpResponse( return HttpResponse(
status=204, status=204,
@@ -440,9 +447,11 @@ def transaction_change_month(request, transaction_id, change_type):
def transaction_move_to_today(request, transaction_id): def transaction_move_to_today(request, transaction_id):
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id) transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
old_data = deepcopy(transaction)
transaction.date = timezone.localdate(timezone.now()) transaction.date = timezone.localdate(timezone.now())
transaction.save() transaction.save()
transaction_updated.send(sender=transaction) transaction_updated.send(sender=transaction, old_data=old_data)
return HttpResponse( return HttpResponse(
status=204, status=204,

View File

@@ -1,8 +1,9 @@
{% load markdown %} {% load markdown %}
{% load i18n %} {% load i18n %}
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10"> <div
class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
<div class="d-flex my-1"> <div class="d-flex my-1">
{% if not disable_selection %} {% if not disable_selection or not dummy %}
<label class="px-3 d-flex align-items-center justify-content-center"> <label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}" <input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve> id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
@@ -19,9 +20,10 @@
<a class="text-decoration-none p-3 tw:text-gray-500!" <a class="text-decoration-none p-3 tw:text-gray-500!"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}" title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
role="button" role="button"
{% if not dummy %}
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}" hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
hx-target="closest .transaction" hx-target="closest .transaction"
hx-swap="outerHTML"> hx-swap="outerHTML"{% endif %}>
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i {% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
class="fa-regular fa-circle"></i>{% endif %} class="fa-regular fa-circle"></i>{% endif %}
</a> </a>
@@ -33,7 +35,8 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}"> <div
class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
{# Date#} {# Date#}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div> <div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
@@ -58,14 +61,19 @@
</div> </div>
<div class="tw:text-gray-400 tw:text-sm"> <div class="tw:text-gray-400 tw:text-sm">
{# Entities #} {# Entities #}
{% with transaction.entities.all as entities %} {% with transaction.entities.all as entities %}
{% if entities %} {% if entities and not overriden_entities %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div> <div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ entities|join:", " }}</div> <div class="col ps-0">{{ entities|join:", " }}</div>
</div> </div>
{% endif %} {% elif overriden_entities %}
{% endwith %} <div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ overriden_entities|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
{# Notes#} {# Notes#}
{% if transaction.notes %} {% if transaction.notes %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
@@ -82,16 +90,22 @@
{% endif %} {% endif %}
{# Tags#} {# Tags#}
{% with transaction.tags.all as tags %} {% with transaction.tags.all as tags %}
{% if tags %} {% if tags and not overriden_tags %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div> <div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ tags|join:", " }}</div> <div class="col ps-0">{{ tags|join:", " }}</div>
</div> </div>
{% elif overriden_tags %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ overriden_tags|join:", " }}</div>
</div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
</div> </div>
<div class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}"> <div
class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
<div class="main-amount mb-2 mb-lg-0"> <div class="main-amount mb-2 mb-lg-0">
<c-amount.display <c-amount.display
:amount="transaction.amount" :amount="transaction.amount"
@@ -101,107 +115,136 @@
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display> color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</div> </div>
{# Exchange Rate#} {# Exchange Rate#}
{% with exchanged=transaction.exchanged_amount %} {% if not dummy %}
{% if exchanged %} {% with exchanged=transaction.exchanged_amount %}
<div class="exchanged-amount mb-2 mb-lg-0"> {% if exchanged %}
<c-amount.display <div class="exchanged-amount mb-2 mb-lg-0">
:amount="exchanged.amount" <c-amount.display
:prefix="exchanged.prefix" :amount="exchanged.amount"
:suffix="exchanged.suffix" :prefix="exchanged.prefix"
:decimal_places="exchanged.decimal_places" :suffix="exchanged.suffix"
color="grey"></c-amount.display> :decimal_places="exchanged.decimal_places"
</div> color="grey"></c-amount.display>
{% endif %} </div>
{% endwith %} {% endif %}
{% endwith %}
{% endif %}
<div> <div>
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }} {% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
</div> </div>
</div> </div>
<div> {% if not dummy %}
{# Item actions#} <div>
<div {# Item actions#}
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card"> <div
<div class="card-body p-1 shadow-lg d-flex flex-row gap-1"> class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
{% if not transaction.deleted %} <div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
<a class="btn btn-secondary btn-sm transaction-action" {% if not transaction.deleted %}
role="button" <a class="btn btn-secondary btn-sm transaction-action"
data-bs-toggle="tooltip" role="button"
data-bs-title="{% translate "Edit" %}" data-bs-toggle="tooltip"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}" data-bs-title="{% translate "Edit" %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML"> hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
<i class="fa-solid fa-pencil fa-fw"></i></a> hx-target="#generic-offcanvas" hx-swap="innerHTML">
<a class="btn btn-secondary btn-sm transaction-action" <i class="fa-solid fa-pencil fa-fw"></i></a>
role="button" <a class="btn btn-secondary btn-sm transaction-action"
data-bs-toggle="tooltip" role="button"
data-bs-title="{% translate "Delete" %}" data-bs-toggle="tooltip"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}" data-bs-title="{% translate "Delete" %}"
hx-trigger='confirmed' hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
data-bypass-on-ctrl="true" hx-trigger='confirmed'
data-title="{% translate "Are you sure?" %}" data-bypass-on-ctrl="true"
data-text="{% translate "You won't be able to revert this!" %}" data-title="{% translate "Are you sure?" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-text="{% translate "You won't be able to revert this!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i> data-confirm-text="{% translate "Yes, delete it!" %}"
</a> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false"> </a>
<i class="fa-solid fa-ellipsis fa-fw"></i> <button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown"
</button> aria-expanded="false">
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start"> <i class="fa-solid fa-ellipsis fa-fw"></i>
{% if transaction.account.is_untracked_by %} </button>
<li> <ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true"> {% if transaction.account.is_untracked_by %}
<i class="fa-solid fa-eye fa-fw me-2"></i> <li>
<div> <a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
{% translate 'Show on summaries' %} <i class="fa-solid fa-eye fa-fw me-2"></i>
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div> <div>
</div> {% translate 'Show on summaries' %}
</a> <div
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
</div>
</a>
</li>
{% elif transaction.category.mute %}
<li>
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
<i class="fa-solid fa-eye fa-fw me-2"></i>
<div>
{% translate 'Show on summaries' %}
<div
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
</div>
</a>
</li>
{% elif transaction.mute %}
<li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
hx-target="closest .transaction" hx-swap="outerHTML"><i
class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
{% else %}
<li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
hx-target="closest .transaction" hx-swap="outerHTML"><i
class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
{% endif %}
<li><a class="dropdown-item" href="#"
hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i
class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a>
</li> </li>
{% elif transaction.category.mute %}
<li> <li>
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true"> <hr class="dropdown-divider">
<i class="fa-solid fa-eye fa-fw me-2"></i>
<div>
{% translate 'Show on summaries' %}
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
</div>
</a>
</li> </li>
{% elif transaction.mute %} <li><a class="dropdown-item" href="#"
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li> hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i
{% else %} class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li> </li>
{% endif %} <li><a class="dropdown-item" href="#"
<li><a class="dropdown-item" href="#" hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a></li> hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i
<li><hr class="dropdown-divider"></li> class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a></li> <li><a class="dropdown-item" href="#"
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li> hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li> class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
<li><hr class="dropdown-divider"></li> <li>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li> <hr class="dropdown-divider">
</ul> </li>
{% else %} <li><a class="dropdown-item" href="#"
<a class="btn btn-secondary btn-sm transaction-action" hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i
role="button" class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
data-bs-toggle="tooltip" </ul>
data-bs-title="{% translate "Restore" %}" {% else %}
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i <a class="btn btn-secondary btn-sm transaction-action"
class="fa-solid fa-trash-arrow-up"></i></a> role="button"
<a class="btn btn-secondary btn-sm transaction-action" data-bs-toggle="tooltip"
role="button" data-bs-title="{% translate "Restore" %}"
data-bs-toggle="tooltip" hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
data-bs-title="{% translate "Delete" %}" class="fa-solid fa-trash-arrow-up"></i></a>
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}" <a class="btn btn-secondary btn-sm transaction-action"
hx-trigger='confirmed' role="button"
data-bypass-on-ctrl="true" data-bs-toggle="tooltip"
data-title="{% translate "Are you sure?" %}" data-bs-title="{% translate "Delete" %}"
data-text="{% translate "You won't be able to revert this!" %}" hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
data-confirm-text="{% translate "Yes, delete it!" %}" hx-trigger='confirmed'
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i> data-bypass-on-ctrl="true"
</a> data-title="{% translate "Are you sure?" %}"
{% endif %} data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> {% endif %}
</div> </div>
</div> </div>
</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_created' pk=rule.id %}" hx-target="#generic-offcanvas"
hx-indicator="#dry-run-created-result, closest form" class="show-loading" novalidate>
{% crispy form %}
</form>
<hr>
<div id="dry-run-created-result" class="show-loading">
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
</div>
{% endblock %}

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_deleted' pk=rule.id %}" hx-target="#generic-offcanvas"
hx-indicator="#dry-run-deleted-result, closest form" class="show-loading" novalidate>
{% crispy form %}
</form>
<hr>
<div id="dry-run-deleted-result" class="show-loading">
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
</div>
{% endblock %}

View File

@@ -0,0 +1,7 @@
<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,82 @@
{% load i18n %}
<div class="card tw:max-h-full tw:overflow-auto tw:overflow-x-auto">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<button class="nav-link active" id="visual-tab" data-bs-toggle="tab" data-bs-target="#visual-tab-pane"
type="button" role="tab" aria-controls="visual-tab-pane"
aria-selected="true">{% translate 'Visual' %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="logs-tab" data-bs-toggle="tab" data-bs-target="#logs-tab-pane" type="button"
role="tab" aria-controls="logs-tab-pane" aria-selected="false">{% translate 'Logs' %}</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="visual-tab-pane" role="tabpanel" aria-labelledby="home-tab"
tabindex="0">
{% if not results %}
{% translate 'Run a test to see...' %}
{% else %}
{% for result in results %}
{% if result.type == 'triggering_transaction' %}
<div class="mt-4">
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Start' %}</span></h6>
<c-transaction.item :transaction="result.transaction" :dummy="True"
:disable-selection="True"></c-transaction.item>
</div>
{% endif %}
{% if result.type == 'edit_transaction' %}
<div class="mt-4">
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Edit transaction' %}</span>
</h6>
<div>
{% translate 'Set' %} <span
class="badge text-bg-secondary">{{ result.field }}</span> {% translate 'to' %}
<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>
</div>
{% endif %}
{% if result.type == 'update_or_create_transaction' %}
<div class="mt-4">
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Update or create transaction' %}</span>
</h6>
<div class="alert alert-info" role="alert">
{% translate 'Search' %}: {{ result.query }}
</div>
{% if result.start_transaction %}
<c-transaction.item :transaction="result.start_transaction" :dummy="True"
:disable-selection="True"></c-transaction.item>
{% else %}
<div class="alert alert-danger" role="alert">
{% translate 'No transaction found, a new one will be created' %}
</div>
{% endif %}
<div class="text-center h3"><i class="fa-solid fa-arrow-down"></i></div>
<c-transaction.item :transaction="result.start_transaction" :dummy="True"
:disable-selection="True" :overriden_tags="result.tags" :overriden_entities="result.entities"></c-transaction.item>
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
<div class="tab-pane fade" id="logs-tab-pane" role="tabpanel" aria-labelledby="logs-tab" tabindex="0">
{% if not logs %}
{% translate 'Run a test to see...' %}
{% else %}
<pre>
{{ logs|linebreaks }}
</pre>
{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -66,7 +66,7 @@
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate 'Yes, delete it!' %}" data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i> <i class="fa-solid fa-trash fa-fw"></i>
</a> </a>
</div> </div>
</div> </div>
@@ -101,33 +101,57 @@
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate 'Yes, delete it!' %}" data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i> <i class="fa-solid fa-trash fa-fw"></i>
</a> </a>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% empty %}
{% if not all_actions %}
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
{% translate 'This rule has no actions' %} {% translate 'This rule has no actions' %}
</div> </div>
</div> </div>
{% endif %} {% endfor %}
<hr> <hr>
<div class="dropdown"> <div class="d-grid d-lg-flex gap-2">
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown" <div class="dropdown flex-fill">
aria-expanded="false"> <button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %} aria-expanded="false">
</button> <i class="fa-solid fa-flask-vial me-2"></i>{% translate 'Test' %}
<ul class="dropdown-menu dropdown-menu-end w-100"> </button>
<li><a class="dropdown-item" role="link" <ul class="dropdown-menu">
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}" {% if transaction_rule.on_create %}
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li> <li><a class="dropdown-item" role="link" href="#"
<li><a class="dropdown-item" role="link" hx-get="{% url 'transaction_rule_dry_run_created' pk=transaction_rule.id %}"
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}" hx-target="#generic-offcanvas">{% trans 'On creation' %}</a></li>
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li> {% endif %}
</ul> {% 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>
{% 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>
{% endif %}
</ul>
</div>
<div class="dropdown flex-fill">
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>