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 apps.api.custom.pagination import CustomPageNumberPagination
@@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet):
transaction_created.send(sender=instance)
def perform_update(self, serializer):
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
instance = serializer.save()
transaction_updated.send(sender=instance)
transaction_updated.send(sender=instance, old_data=old_data)
def partial_update(self, request, *args, **kwargs):
kwargs["partial"] = True

View File

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

View File

@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
with self.assertRaises(ValidationError):
currency.full_clean()
def test_currency_unique_code(self):
"""Test that currency codes must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
with self.assertRaises(IntegrityError):
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
def test_currency_unique_name(self):
"""Test that currency names must be unique"""
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)

View File

@@ -7,9 +7,11 @@ from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.tom_select import TomSelect
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
from apps.rules.models import TransactionRuleAction
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
from apps.transactions.models import Transaction
class TransactionRuleForm(forms.ModelForm):
@@ -40,6 +42,7 @@ class TransactionRuleForm(forms.ModelForm):
Column(Switch("on_create")),
Column(Switch("on_delete")),
),
Switch("sequenced"),
"description",
"trigger",
)
@@ -149,6 +152,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_category_operator": TomSelect(clear_button=False),
"search_internal_note_operator": TomSelect(clear_button=False),
"search_internal_id_operator": TomSelect(clear_button=False),
"search_mute_operator": TomSelect(clear_button=False),
}
labels = {
@@ -166,6 +170,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_internal_id_operator": _("Operator"),
"search_tags_operator": _("Operator"),
"search_entities_operator": _("Operator"),
"search_mute_operator": _("Operator"),
"search_account": _("Account"),
"search_type": _("Type"),
"search_is_paid": _("Paid"),
@@ -179,6 +184,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"search_internal_id": _("Internal ID"),
"search_tags": _("Tags"),
"search_entities": _("Entities"),
"search_mute": _("Mute"),
"set_account": _("Account"),
"set_type": _("Type"),
"set_is_paid": _("Paid"),
@@ -192,6 +198,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
"set_category": _("Category"),
"set_internal_note": _("Internal Note"),
"set_internal_id": _("Internal ID"),
"set_mute": _("Mute"),
}
def __init__(self, *args, **kwargs):
@@ -228,6 +235,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_mute_operator"),
css_class="form-group col-md-4",
),
Column(
Field("search_mute", rows=1),
css_class="form-group col-md-8",
),
),
Row(
Column(
Field("search_account_operator"),
@@ -344,6 +361,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
_("Set Values"),
Field("set_type", rows=1),
Field("set_is_paid", rows=1),
Field("set_mute", rows=1),
Field("set_account", rows=1),
Field("set_entities", rows=1),
Field("set_date", rows=1),
@@ -385,3 +403,55 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
if commit:
instance.save()
return instance
class DryRunCreatedTransacion(forms.Form):
transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Transaction"),
required=True,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=False, income=True, expense=True),
help_text=_("Type to search for a transaction"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"transaction",
FormActions(
NoClassSubmit(
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
),
),
)
class DryRunDeletedTransacion(forms.Form):
transaction = DynamicModelChoiceField(
model=Transaction,
to_field_name="id",
label=_("Transaction"),
required=True,
queryset=Transaction.objects.none(),
widget=TransactionSelect(clear_button=False, income=True, expense=True),
help_text=_("Type to search for a transaction"),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.layout = Layout(
"transaction",
FormActions(
NoClassSubmit(
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
),
),
)

View File

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

View File

@@ -9,40 +9,17 @@ from apps.transactions.models import (
)
from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
from apps.rules.utils.transactions import serialize_transaction
@receiver(transaction_created)
@receiver(transaction_updated)
@receiver(transaction_deleted)
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
old_data = kwargs.get("old_data")
if signal is transaction_deleted:
# Serialize transaction data for processing
transaction_data = {
"id": sender.id,
"account": (sender.account.id, sender.account.name),
"account_group": (
sender.account.group.id if sender.account.group else None,
sender.account.group.name if sender.account.group else None,
),
"type": str(sender.type),
"is_paid": sender.is_paid,
"is_asset": sender.account.is_asset,
"is_archived": sender.account.is_archived,
"category": (
sender.category.id if sender.category else None,
sender.category.name if sender.category else None,
),
"date": sender.date.isoformat(),
"reference_date": sender.reference_date.isoformat(),
"amount": str(sender.amount),
"description": sender.description,
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"deleted": True,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,
}
transaction_data = serialize_transaction(sender, deleted=True)
check_for_transaction_rules.defer(
transaction_data=transaction_data,
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
dca_entry.amount_received = sender.amount
dca_entry.save()
if signal is transaction_updated and old_data:
old_data = serialize_transaction(old_data, deleted=False)
check_for_transaction_rules.defer(
instance_id=sender.id,
user_id=get_current_user().id,
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
if signal is transaction_created
else "transaction_updated"
),
old_data=old_data,
)

View File

@@ -1,12 +1,19 @@
import decimal
import logging
import traceback
from copy import deepcopy
from datetime import datetime, date
from decimal import Decimal
from itertools import chain
from pprint import pformat
from random import randint, random
from textwrap import indent
from typing import Literal
from cachalot.api import cachalot_disabled
from dateutil.relativedelta import relativedelta
from django.contrib.auth import get_user_model
from django.forms import model_to_dict
from procrastinate.contrib.django import app
from simpleeval import EvalWithCompoundTypes
@@ -27,16 +34,495 @@ from apps.rules.utils import transactions
logger = logging.getLogger(__name__)
class DryRunResults:
def __init__(self):
self.results = []
def triggering_transaction(self, instance):
result = {
"type": "triggering_transaction",
"transaction": instance,
}
self.results.append(result)
def edit_transaction(
self, instance, action, old_value, new_value, field, tags, entities
):
result = {
"type": "edit_transaction",
"transaction": deepcopy(instance),
"action": action,
"old_value": old_value,
"new_value": new_value,
"field": field,
"tags": tags,
"entities": entities,
}
self.results.append(result)
def update_or_create_transaction(
self,
updated: bool,
action,
query,
tags,
entities,
start_instance=None,
end_instance=None,
):
result = {
"type": "update_or_create_transaction",
"start_transaction": start_instance,
"end_transaction": end_instance,
"updated": updated,
"action": action,
"query": query,
"tags": tags,
"entities": entities,
}
self.results.append(result)
def error(
self,
action: Literal["update_or_create_transaction", "edit_transaction"],
error,
action_obj,
):
result = {
"type": "error",
"action": action,
"action_obj": action_obj,
"error": error,
"traceback": traceback.format_exc(),
}
self.results.append(result)
@app.task(name="check_for_transaction_rules")
def check_for_transaction_rules(
instance_id=None,
transaction_data=None,
old_data=None,
user_id=None,
signal=None,
is_hard_deleted=False,
dry_run=False,
rule_id=None,
):
def _log(message: str, level="info"):
if dry_run:
if logs is not None:
logs.append(message)
if level == "error":
logs.append(traceback.format_exc())
else:
if level == "info":
logger.info(message)
elif level == "error":
logger.error(message, exc_info=True)
def _clear_names(prefix: str):
for k in list(simple.names.keys()):
if k.startswith(prefix):
del simple.names[k]
def _get_names(instance: Transaction | dict, prefix: str = ""):
if isinstance(instance, Transaction):
return {
f"{prefix}id": instance.id,
f"{prefix}account_name": instance.account.name if instance.id else None,
f"{prefix}account_id": instance.account.id if instance.id else None,
f"{prefix}account_group_name": (
instance.account.group.name
if instance.id and instance.account.group
else None
),
f"{prefix}account_group_id": (
instance.account.group.id
if instance.id and instance.account.group
else None
),
f"{prefix}is_asset_account": (
instance.account.is_asset if instance.id else None
),
f"{prefix}is_archived_account": (
instance.account.is_archived if instance.id else None
),
f"{prefix}category_name": (
instance.category.name if instance.category else None
),
f"{prefix}category_id": (
instance.category.id if instance.category else None
),
f"{prefix}tag_names": (
[x.name for x in instance.tags.all()] if instance.id else []
),
f"{prefix}tag_ids": (
[x.id for x in instance.tags.all()] if instance.id else []
),
f"{prefix}entities_names": (
[x.name for x in instance.entities.all()] if instance.id else []
),
f"{prefix}entities_ids": (
[x.id for x in instance.entities.all()] if instance.id else []
),
f"{prefix}is_expense": instance.type == Transaction.Type.EXPENSE,
f"{prefix}is_income": instance.type == Transaction.Type.INCOME,
f"{prefix}is_paid": instance.is_paid,
f"{prefix}description": instance.description,
f"{prefix}amount": instance.amount or 0,
f"{prefix}notes": instance.notes,
f"{prefix}date": instance.date,
f"{prefix}reference_date": instance.reference_date,
f"{prefix}internal_note": instance.internal_note,
f"{prefix}internal_id": instance.internal_id,
f"{prefix}is_deleted": instance.deleted,
f"{prefix}is_muted": instance.mute,
}
else:
return {
f"{prefix}id": instance.get("id"),
f"{prefix}account_name": instance.get("account", (None, None))[1],
f"{prefix}account_id": instance.get("account", (None, None))[0],
f"{prefix}account_group_name": instance.get(
"account_group", (None, None)
)[1],
f"{prefix}account_group_id": instance.get(
"account_group", (None, None)
)[0],
f"{prefix}is_asset_account": instance.get("is_asset"),
f"{prefix}is_archived_account": instance.get("is_archived"),
f"{prefix}category_name": instance.get("category", (None, None))[1],
f"{prefix}category_id": instance.get("category", (None, None))[0],
f"{prefix}tag_names": [x[1] for x in instance.get("tags", [])],
f"{prefix}tag_ids": [x[0] for x in instance.get("tags", [])],
f"{prefix}entities_names": [x[1] for x in instance.get("entities", [])],
f"{prefix}entities_ids": [x[0] for x in instance.get("entities", [])],
f"{prefix}is_expense": instance.get("type") == Transaction.Type.EXPENSE,
f"{prefix}is_income": instance.get("type") == Transaction.Type.INCOME,
f"{prefix}is_paid": instance.get("is_paid"),
f"{prefix}description": instance.get("description", ""),
f"{prefix}amount": Decimal(instance.get("amount")),
f"{prefix}notes": instance.get("notes", ""),
f"{prefix}date": datetime.fromisoformat(instance.get("date")),
f"{prefix}reference_date": datetime.fromisoformat(
instance.get("reference_date")
),
f"{prefix}internal_note": instance.get("internal_note", ""),
f"{prefix}internal_id": instance.get("internal_id", ""),
f"{prefix}is_deleted": instance.get("deleted", True),
f"{prefix}is_muted": instance.get("mute", False),
}
def _process_update_or_create_transaction_action(processed_action):
"""Helper to process a single linked transaction action"""
# Build search query using the helper method
search_query = processed_action.build_search_query(simple)
_log(f" ├─ Searching transactions using: {search_query}")
starting_instance = None
# Find latest matching transaction or create new
if search_query:
transactions = Transaction.objects.filter(search_query).order_by(
"-date", "-id"
)
if transactions.exists():
transaction = transactions.first()
existing = True
starting_instance = deepcopy(transaction)
_log(" ├─ Found at least one matching transaction, using latest:")
_log(
" ├─ {}".format(
indent(pformat(model_to_dict(transaction)), " ")
)
)
else:
transaction = Transaction()
existing = False
_log(
" ├─ No matching transaction found, creating a new transaction",
)
else:
transaction = Transaction()
existing = False
_log(
" ├─ No matching transaction found, creating a new transaction",
)
simple.names.update(_get_names(transaction, prefix="my_"))
if processed_action.filter:
value = simple.eval(processed_action.filter)
if not value:
_log(
" ├─ Filter did not match. Execution of this action has been stopped."
)
return # Short-circuit execution if filter evaluates to false
# Set fields if provided
if processed_action.set_account:
value = simple.eval(processed_action.set_account)
if isinstance(value, int):
transaction.account = Account.objects.get(id=value)
else:
transaction.account = Account.objects.get(name=value)
if processed_action.set_type:
transaction.type = simple.eval(processed_action.set_type)
if processed_action.set_is_paid:
transaction.is_paid = simple.eval(processed_action.set_is_paid)
if processed_action.set_mute:
transaction.is_paid = simple.eval(processed_action.set_mute)
if processed_action.set_date:
transaction.date = simple.eval(processed_action.set_date)
if processed_action.set_reference_date:
transaction.reference_date = simple.eval(
processed_action.set_reference_date
)
if processed_action.set_amount:
transaction.amount = simple.eval(processed_action.set_amount)
if processed_action.set_description:
transaction.description = simple.eval(processed_action.set_description)
if processed_action.set_internal_note:
transaction.internal_note = simple.eval(processed_action.set_internal_note)
if processed_action.set_internal_id:
transaction.internal_id = simple.eval(processed_action.set_internal_id)
if processed_action.set_notes:
transaction.notes = simple.eval(processed_action.set_notes)
if processed_action.set_category:
value = simple.eval(processed_action.set_category)
if isinstance(value, int):
transaction.category = TransactionCategory.objects.get(id=value)
else:
transaction.category = TransactionCategory.objects.get(name=value)
if dry_run:
if not transaction.id:
_log(" ├─ Transaction would be created as:")
else:
_log(" ├─ Trasanction would be updated as:")
_log(
" ├─ {}".format(
indent(
pformat(
model_to_dict(transaction, exclude=["tags", "entities"])
),
" ",
)
)
)
else:
if not transaction.id:
_log(" ├─ Transaction will be created as:")
else:
_log(" ├─ Trasanction will be updated as:")
_log(
" ├─ {}".format(
indent(
pformat(
model_to_dict(transaction, exclude=["tags", "entities"])
),
" ",
)
)
)
transaction.save()
# Handle M2M fields after save
tags = []
if processed_action.set_tags:
tags = simple.eval(processed_action.set_tags)
if dry_run:
_log(f" ├─ And tags would be set as: {tags}")
else:
_log(f" ├─ And tags will be set as: {tags}")
transaction.tags.clear()
if isinstance(tags, (list, tuple)):
for tag in tags:
if isinstance(tag, int):
transaction.tags.add(TransactionTag.objects.get(id=tag))
else:
transaction.tags.add(TransactionTag.objects.get(name=tag))
elif isinstance(tags, (int, str)):
if isinstance(tags, int):
transaction.tags.add(TransactionTag.objects.get(id=tags))
else:
transaction.tags.add(TransactionTag.objects.get(name=tags))
entities = []
if processed_action.set_entities:
entities = simple.eval(processed_action.set_entities)
if dry_run:
_log(f" ├─ And entities would be set as: {entities}")
else:
_log(f" ├─ And entities will be set as: {entities}")
transaction.entities.clear()
if isinstance(entities, (list, tuple)):
for entity in entities:
if isinstance(entity, int):
transaction.entities.add(
TransactionEntity.objects.get(id=entity)
)
else:
transaction.entities.add(
TransactionEntity.objects.get(name=entity)
)
elif isinstance(entities, (int, str)):
if isinstance(entities, int):
transaction.entities.add(
TransactionEntity.objects.get(id=entities)
)
else:
transaction.entities.add(
TransactionEntity.objects.get(name=entities)
)
transaction.full_clean()
dry_run_results.update_or_create_transaction(
start_instance=starting_instance,
end_instance=deepcopy(transaction),
updated=existing,
action=processed_action,
query=search_query,
entities=entities,
tags=tags,
)
def _process_edit_transaction_action(instance, processed_action) -> Transaction:
field = processed_action.field
original_value = getattr(instance, field)
new_value = simple.eval(processed_action.value)
tags = []
entities = []
_log(
f" ├─ Changing field '{field}' from '{original_value}' to '{new_value}'",
)
form_data = {}
if field in [
TransactionRuleAction.Field.type,
TransactionRuleAction.Field.is_paid,
TransactionRuleAction.Field.date,
TransactionRuleAction.Field.reference_date,
TransactionRuleAction.Field.amount,
TransactionRuleAction.Field.description,
TransactionRuleAction.Field.notes,
TransactionRuleAction.Field.mute,
TransactionRuleAction.Field.internal_note,
TransactionRuleAction.Field.internal_id,
]:
setattr(
instance,
field,
new_value,
)
elif field == TransactionRuleAction.Field.account:
if isinstance(new_value, int):
account = Account.objects.get(id=new_value)
instance.account = account
elif isinstance(new_value, str):
account = Account.objects.filter(name=new_value).first()
instance.account = account
elif field == TransactionRuleAction.Field.category:
if isinstance(new_value, int):
category = TransactionCategory.objects.get(id=new_value)
instance.category = category
elif isinstance(new_value, str):
category = TransactionCategory.objects.get(name=new_value)
instance.category = category
elif field == TransactionRuleAction.Field.tags:
if not dry_run:
instance.tags.clear()
if isinstance(new_value, list):
for tag_value in new_value:
if isinstance(tag_value, int):
tag = TransactionTag.objects.get(id=tag_value)
if not dry_run:
instance.tags.add(tag)
tags.append(tag)
elif isinstance(tag_value, str):
tag = TransactionTag.objects.get(name=tag_value)
if not dry_run:
instance.tags.add(tag)
tags.append(tag)
elif isinstance(new_value, (int, str)):
if isinstance(new_value, int):
tag = TransactionTag.objects.get(id=new_value)
else:
tag = TransactionTag.objects.get(name=new_value)
if not dry_run:
instance.tags.add(tag)
tags.append(tag)
elif field == TransactionRuleAction.Field.entities:
if not dry_run:
instance.entities.clear()
if isinstance(new_value, list):
for entity_value in new_value:
if isinstance(entity_value, int):
entity = TransactionEntity.objects.get(id=entity_value)
if not dry_run:
instance.entities.add(entity)
entities.append(entity)
elif isinstance(entity_value, str):
entity = TransactionEntity.objects.get(name=entity_value)
if not dry_run:
instance.entities.add(entity)
entities.append(entity)
elif isinstance(new_value, (int, str)):
if isinstance(new_value, int):
entity = TransactionEntity.objects.get(id=new_value)
else:
entity = TransactionEntity.objects.get(name=new_value)
if not dry_run:
instance.entities.add(entity)
entities.append(entity)
instance.full_clean()
dry_run_results.edit_transaction(
instance=deepcopy(instance),
action=processed_action,
old_value=original_value,
new_value=new_value,
field=field,
tags=tags,
entities=entities,
)
return instance
user = get_user_model().objects.get(id=user_id)
write_current_user(user)
logs = [] if dry_run else None
dry_run_results = DryRunResults()
if dry_run and not rule_id:
raise Exception("-> Cannot dry run without a rule id")
try:
with cachalot_disabled():
@@ -51,21 +537,32 @@ def check_for_transaction_rules(
# Regular transaction processing for creates and updates
instance = Transaction.objects.get(id=instance_id)
dry_run_results.triggering_transaction(deepcopy(instance))
functions = {
"relativedelta": relativedelta,
"str": str,
"int": int,
"float": float,
"abs": abs,
"randint": randint,
"random": random,
"decimal": decimal.Decimal,
"datetime": datetime,
"date": date,
"transactions": transactions.TransactionsGetter,
}
_log("-> Starting rule execution...")
_log("-> Available functions: {}".format(functions.keys()))
names = _get_names(instance)
simple = EvalWithCompoundTypes(names=names, functions=functions)
if signal == "transaction_updated" and old_data:
simple.names.update(_get_names(old_data, "old_"))
# Select rules based on the signal type
if signal == "transaction_created":
rules = TransactionRule.objects.filter(
@@ -82,23 +579,31 @@ def check_for_transaction_rules(
else:
rules = TransactionRule.objects.filter(active=True).order_by("id")
if dry_run and rule_id:
rules = rules.filter(id=rule_id)
_log("-> Testing {} rule(s)...".format(len(rules)))
# Process the rules as before
for rule in rules:
_log("Testing rule: {}".format(rule.name))
if simple.eval(rule.trigger):
# For deleted transactions, we might want to limit what actions can be performed
_log("├─ Initial trigger matched!")
# For deleted transactions, we want to limit what actions can be performed
if signal == "transaction_deleted":
_log(
"├─ Event is of type 'delete'. Only processing Update or Create actions..."
)
# Process only create/update actions, not edit actions
for (
action
) in rule.update_or_create_transaction_actions.all():
for action in rule.update_or_create_transaction_actions.all():
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
processed_action=action,
)
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id} on deletion",
exc_info=True,
_log(
f"├─ Error processing update or create transaction action {action.id} on deletion",
level="error",
)
else:
# Normal processing for non-deleted transactions
@@ -113,10 +618,14 @@ def check_for_transaction_rules(
) or any(a.order > 0 for a in update_or_create_actions)
if has_custom_order:
_log(
"├─ One or more actions have a custom order, actions will be processed ordered by "
"order and creation date..."
)
# Combine and sort actions by order
all_actions = sorted(
chain(edit_actions, update_or_create_actions),
key=lambda a: a.order,
key=lambda a: (a.order, a.id),
)
for action in all_actions:
@@ -124,354 +633,77 @@ def check_for_transaction_rules(
if isinstance(action, TransactionRuleAction):
instance = _process_edit_transaction_action(
instance=instance,
action=action,
simple_eval=simple,
processed_action=action,
)
# Update names for next actions
simple.names.update(_get_names(instance))
if rule.sequenced:
# Update names for next actions
simple.names.update(_get_names(instance))
else:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
processed_action=action,
)
_clear_names("my_")
except Exception as e:
logger.error(
f"Error processing action {action.id}",
exc_info=True,
_log(
f"├─ Error processing action {action.id}",
level="error",
)
# Save at the end
if signal != "transaction_deleted":
if not dry_run and signal != "transaction_deleted":
instance.save()
else:
_log(
"├─ No actions have a custom order, actions will be processed ordered by creation "
"date, with Edit actions running first, then Update or Create actions..."
)
# Original behavior
for action in edit_actions:
try:
instance = _process_edit_transaction_action(
instance=instance,
action=action,
simple_eval=simple,
processed_action=action,
)
if rule.sequenced:
# Update names for next actions
simple.names.update(_get_names(instance))
except Exception as e:
logger.error(
f"Error processing edit transaction action {action.id}",
exc_info=True,
_log(
f"├─ Error processing edit transaction action {action.id}",
level="error",
)
simple.names.update(_get_names(instance))
if signal != "transaction_deleted":
if rule.sequenced:
# Update names for next actions
simple.names.update(_get_names(instance))
if not dry_run and signal != "transaction_deleted":
instance.save()
for action in update_or_create_actions:
try:
_process_update_or_create_transaction_action(
action=action, simple_eval=simple
processed_action=action,
)
_clear_names("my_")
except Exception as e:
logger.error(
f"Error processing update or create transaction action {action.id}",
exc_info=True,
_log(
f"├─ Error processing update or create transaction action {action.id}",
level="error",
)
else:
_log("├─ Initial trigger didn't match, this rule will be skipped")
except Exception as e:
logger.error(
"Error while executing 'check_for_transaction_rules' task",
exc_info=True,
_log(
"** Error while executing 'check_for_transaction_rules' task",
level="error",
)
delete_current_user()
raise e
if not dry_run:
raise e
delete_current_user()
if dry_run:
return logs, dry_run_results.results
def _get_names(instance: Transaction | dict):
if isinstance(instance, Transaction):
return {
"id": instance.id,
"account_name": instance.account.name,
"account_id": instance.account.id,
"account_group_name": (
instance.account.group.name if instance.account.group else None
),
"account_group_id": (
instance.account.group.id if instance.account.group else None
),
"is_asset_account": instance.account.is_asset,
"is_archived_account": instance.account.is_archived,
"category_name": instance.category.name if instance.category else None,
"category_id": instance.category.id if instance.category else None,
"tag_names": [x.name for x in instance.tags.all()],
"tag_ids": [x.id for x in instance.tags.all()],
"entities_names": [x.name for x in instance.entities.all()],
"entities_ids": [x.id for x in instance.entities.all()],
"is_expense": instance.type == Transaction.Type.EXPENSE,
"is_income": instance.type == Transaction.Type.INCOME,
"is_paid": instance.is_paid,
"description": instance.description,
"amount": instance.amount,
"notes": instance.notes,
"date": instance.date,
"reference_date": instance.reference_date,
"internal_note": instance.internal_note,
"internal_id": instance.internal_id,
"is_deleted": instance.deleted,
}
else:
return {
"id": instance.get("id"),
"account_name": instance.get("account", (None, None))[1],
"account_id": instance.get("account", (None, None))[0],
"account_group_name": instance.get("account_group", (None, None))[1],
"account_group_id": instance.get("account_group", (None, None))[0],
"is_asset_account": instance.get("is_asset"),
"is_archived_account": instance.get("is_archived"),
"category_name": instance.get("category", (None, None))[1],
"category_id": instance.get("category", (None, None))[0],
"tag_names": [x[1] for x in instance.get("tags", [])],
"tag_ids": [x[0] for x in instance.get("tags", [])],
"entities_names": [x[1] for x in instance.get("entities", [])],
"entities_ids": [x[0] for x in instance.get("entities", [])],
"is_expense": instance.get("type") == Transaction.Type.EXPENSE,
"is_income": instance.get("type") == Transaction.Type.INCOME,
"is_paid": instance.get("is_paid"),
"description": instance.get("description", ""),
"amount": Decimal(instance.get("amount")),
"notes": instance.get("notes", ""),
"date": datetime.fromisoformat(instance.get("date")),
"reference_date": datetime.fromisoformat(instance.get("reference_date")),
"internal_note": instance.get("internal_note", ""),
"internal_id": instance.get("internal_id", ""),
"is_deleted": instance.get("deleted", True),
}
def _process_update_or_create_transaction_action(action, simple_eval):
"""Helper to process a single linked transaction action"""
# Build search query using the helper method
search_query = action.build_search_query(simple_eval)
logger.info("Searching transactions using: %s", search_query)
# Find latest matching transaction or create new
if search_query:
transactions = Transaction.objects.filter(search_query).order_by("-date", "-id")
transaction = transactions.first()
logger.info("Found at least one matching transaction, using latest")
else:
transaction = None
logger.info("No matching transaction found, creating a new transaction")
if not transaction:
transaction = Transaction()
simple_eval.names.update(
{
"my_account_name": (transaction.account.name if transaction.id else None),
"my_account_id": transaction.account.id if transaction.id else None,
"my_account_group_name": (
transaction.account.group.name
if transaction.id and transaction.account.group
else None
),
"my_account_group_id": (
transaction.account.group.id
if transaction.id and transaction.account.group
else None
),
"my_is_asset_account": (
transaction.account.is_asset if transaction.id else None
),
"my_is_archived_account": (
transaction.account.is_archived if transaction.id else None
),
"my_category_name": (
transaction.category.name if transaction.category else None
),
"my_category_id": transaction.category.id if transaction.category else None,
"my_tag_names": (
[x.name for x in transaction.tags.all()] if transaction.id else []
),
"my_tag_ids": (
[x.id for x in transaction.tags.all()] if transaction.id else []
),
"my_entities_names": (
[x.name for x in transaction.entities.all()] if transaction.id else []
),
"my_entities_ids": (
[x.id for x in transaction.entities.all()] if transaction.id else []
),
"my_is_expense": transaction.type == Transaction.Type.EXPENSE,
"my_is_income": transaction.type == Transaction.Type.INCOME,
"my_is_paid": transaction.is_paid,
"my_description": transaction.description,
"my_amount": transaction.amount or 0,
"my_notes": transaction.notes,
"my_date": transaction.date,
"my_reference_date": transaction.reference_date,
"my_internal_note": transaction.internal_note,
"my_internal_id": transaction.reference_date,
}
)
if action.filter:
value = simple_eval.eval(action.filter)
if not value:
return # Short-circuit execution if filter evaluates to false
# Set fields if provided
if action.set_account:
value = simple_eval.eval(action.set_account)
if isinstance(value, int):
transaction.account = Account.objects.get(id=value)
else:
transaction.account = Account.objects.get(name=value)
if action.set_type:
transaction.type = simple_eval.eval(action.set_type)
if action.set_is_paid:
transaction.is_paid = simple_eval.eval(action.set_is_paid)
if action.set_date:
transaction.date = simple_eval.eval(action.set_date)
if action.set_reference_date:
transaction.reference_date = simple_eval.eval(action.set_reference_date)
if action.set_amount:
transaction.amount = simple_eval.eval(action.set_amount)
if action.set_description:
transaction.description = simple_eval.eval(action.set_description)
if action.set_internal_note:
transaction.internal_note = simple_eval.eval(action.set_internal_note)
if action.set_internal_id:
transaction.internal_id = simple_eval.eval(action.set_internal_id)
if action.set_notes:
transaction.notes = simple_eval.eval(action.set_notes)
if action.set_category:
value = simple_eval.eval(action.set_category)
if isinstance(value, int):
transaction.category = TransactionCategory.objects.get(id=value)
else:
transaction.category = TransactionCategory.objects.get(name=value)
transaction.save()
# Handle M2M fields after save
if action.set_tags:
tags_value = simple_eval.eval(action.set_tags)
transaction.tags.clear()
if isinstance(tags_value, (list, tuple)):
for tag in tags_value:
if isinstance(tag, int):
transaction.tags.add(TransactionTag.objects.get(id=tag))
else:
transaction.tags.add(TransactionTag.objects.get(name=tag))
elif isinstance(tags_value, (int, str)):
if isinstance(tags_value, int):
transaction.tags.add(TransactionTag.objects.get(id=tags_value))
else:
transaction.tags.add(TransactionTag.objects.get(name=tags_value))
if action.set_entities:
entities_value = simple_eval.eval(action.set_entities)
transaction.entities.clear()
if isinstance(entities_value, (list, tuple)):
for entity in entities_value:
if isinstance(entity, int):
transaction.entities.add(TransactionEntity.objects.get(id=entity))
else:
transaction.entities.add(TransactionEntity.objects.get(name=entity))
elif isinstance(entities_value, (int, str)):
if isinstance(entities_value, int):
transaction.entities.add(
TransactionEntity.objects.get(id=entities_value)
)
else:
transaction.entities.add(
TransactionEntity.objects.get(name=entities_value)
)
def _process_edit_transaction_action(instance, action, simple_eval) -> Transaction:
if action.field in [
TransactionRuleAction.Field.type,
TransactionRuleAction.Field.is_paid,
TransactionRuleAction.Field.date,
TransactionRuleAction.Field.reference_date,
TransactionRuleAction.Field.amount,
TransactionRuleAction.Field.description,
TransactionRuleAction.Field.notes,
]:
setattr(
instance,
action.field,
simple_eval.eval(action.value),
)
elif action.field == TransactionRuleAction.Field.account:
value = simple_eval.eval(action.value)
if isinstance(value, int):
account = Account.objects.get(id=value)
instance.account = account
elif isinstance(value, str):
account = Account.objects.filter(name=value).first()
instance.account = account
elif action.field == TransactionRuleAction.Field.category:
value = simple_eval.eval(action.value)
if isinstance(value, int):
category = TransactionCategory.objects.get(id=value)
instance.category = category
elif isinstance(value, str):
category = TransactionCategory.objects.get(name=value)
instance.category = category
elif action.field == TransactionRuleAction.Field.tags:
value = simple_eval.eval(action.value)
if isinstance(value, list):
# Clear existing tags
instance.tags.clear()
for tag_value in value:
if isinstance(tag_value, int):
tag = TransactionTag.objects.get(id=tag_value)
instance.tags.add(tag)
elif isinstance(tag_value, str):
tag = TransactionTag.objects.get(name=tag_value)
instance.tags.add(tag)
elif isinstance(value, (int, str)):
# If a single value is provided, treat it as a single tag
instance.tags.clear()
if isinstance(value, int):
tag = TransactionTag.objects.get(id=value)
else:
tag = TransactionTag.objects.get(name=value)
instance.tags.add(tag)
elif action.field == TransactionRuleAction.Field.entities:
value = simple_eval.eval(action.value)
if isinstance(value, list):
# Clear existing entities
instance.entities.clear()
for entity_value in value:
if isinstance(entity_value, int):
entity = TransactionEntity.objects.get(id=entity_value)
instance.entities.add(entity)
elif isinstance(entity_value, str):
entity = TransactionEntity.objects.get(name=entity_value)
instance.entities.add(entity)
elif isinstance(value, (int, str)):
# If a single value is provided, treat it as a single entity
instance.entities.clear()
if isinstance(value, int):
entity = TransactionEntity.objects.get(id=value)
else:
entity = TransactionEntity.objects.get(name=value)
instance.entities.add(entity)
return instance
return None

View File

@@ -42,6 +42,16 @@ urlpatterns = [
views.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(
"rules/transaction/<int:pk>/share/",
views.transaction_rule_share,

View File

@@ -56,3 +56,33 @@ class TransactionsGetter:
output_field=DecimalField(),
)
)["balance"]
def serialize_transaction(sender: Transaction, deleted: bool):
return {
"id": sender.id,
"account": (sender.account.id, sender.account.name),
"account_group": (
sender.account.group.id if sender.account.group else None,
sender.account.group.name if sender.account.group else None,
),
"type": str(sender.type),
"is_paid": sender.is_paid,
"is_asset": sender.account.is_asset,
"is_archived": sender.account.is_archived,
"category": (
sender.category.id if sender.category else None,
sender.category.name if sender.category else None,
),
"date": sender.date.isoformat(),
"reference_date": sender.reference_date.isoformat(),
"amount": str(sender.amount),
"description": sender.description,
"notes": sender.notes,
"tags": list(sender.tags.values_list("id", "name")),
"entities": list(sender.entities.values_list("id", "name")),
"deleted": deleted,
"internal_note": sender.internal_note,
"internal_id": sender.internal_id,
"mute": sender.mute,
}

View File

@@ -12,6 +12,8 @@ from apps.rules.forms import (
TransactionRuleForm,
TransactionRuleActionForm,
UpdateOrCreateTransactionRuleActionForm,
DryRunCreatedTransacion,
DryRunDeletedTransacion,
)
from apps.rules.models import (
TransactionRule,
@@ -21,6 +23,8 @@ from apps.rules.models import (
from apps.common.models import SharedObject
from apps.common.forms import SharedObjectForm
from apps.common.decorators.demo import disabled_on_demo
from apps.rules.tasks import check_for_transaction_rules
from apps.common.middleware.thread_local import get_current_user
@login_required
@@ -143,7 +147,9 @@ def transaction_rule_view(request, transaction_rule_id):
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
edit_actions = transaction_rule.transaction_actions.all()
update_or_create_actions = transaction_rule.update_or_create_transaction_actions.all()
update_or_create_actions = (
transaction_rule.update_or_create_transaction_actions.all()
)
all_actions = sorted(
chain(edit_actions, update_or_create_actions),
@@ -416,3 +422,65 @@ def update_or_create_transaction_rule_action_delete(request, pk):
"HX-Trigger": "updated, hide_offcanvas",
},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def dry_run_rule_created(request, pk):
rule = get_object_or_404(TransactionRule, id=pk)
logs = None
results = None
if request.method == "POST":
form = DryRunCreatedTransacion(request.POST)
if form.is_valid():
logs, results = check_for_transaction_rules(
instance_id=form.cleaned_data["transaction"].id,
signal="transaction_created",
dry_run=True,
rule_id=rule.id,
user_id=get_current_user().id,
)
logs = "\n".join(logs)
else:
form = DryRunCreatedTransacion()
return render(
request,
"rules/fragments/transaction_rule/dry_run/created.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)
@only_htmx
@login_required
@disabled_on_demo
@require_http_methods(["GET", "POST"])
def dry_run_rule_deleted(request, pk):
rule = get_object_or_404(TransactionRule, id=pk)
logs = None
results = None
if request.method == "POST":
form = DryRunDeletedTransacion(request.POST)
if form.is_valid():
logs, results = check_for_transaction_rules(
instance_id=form.cleaned_data["transaction"].id,
signal="transaction_deleted",
dry_run=True,
rule_id=rule.id,
user_id=get_current_user().id,
)
logs = "\n".join(logs)
else:
form = DryRunDeletedTransacion()
return render(
request,
"rules/fragments/transaction_rule/dry_run/deleted.html",
{"form": form, "rule": rule, "logs": logs, "results": results},
)

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
{% load markdown %}
{% 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">
{% if not disable_selection %}
{% if not disable_selection or not dummy %}
<label class="px-3 d-flex align-items-center justify-content-center">
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
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!"
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
role="button"
{% if not dummy %}
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
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
class="fa-regular fa-circle"></i>{% endif %}
</a>
@@ -33,7 +35,8 @@
</div>
{% endif %}
</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#}
<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>
@@ -58,14 +61,19 @@
</div>
<div class="tw:text-gray-400 tw:text-sm">
{# Entities #}
{% with transaction.entities.all as entities %}
{% if entities %}
<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">{{ entities|join:", " }}</div>
</div>
{% endif %}
{% endwith %}
{% with transaction.entities.all as entities %}
{% if entities and not overriden_entities %}
<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">{{ entities|join:", " }}</div>
</div>
{% elif overriden_entities %}
<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#}
{% if transaction.notes %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
@@ -82,16 +90,22 @@
{% endif %}
{# 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="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>
{% 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 %}
{% endwith %}
</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">
<c-amount.display
:amount="transaction.amount"
@@ -101,107 +115,136 @@
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</div>
{# Exchange Rate#}
{% with exchanged=transaction.exchanged_amount %}
{% if exchanged %}
<div class="exchanged-amount mb-2 mb-lg-0">
<c-amount.display
:amount="exchanged.amount"
:prefix="exchanged.prefix"
:suffix="exchanged.suffix"
:decimal_places="exchanged.decimal_places"
color="grey"></c-amount.display>
</div>
{% endif %}
{% endwith %}
{% if not dummy %}
{% with exchanged=transaction.exchanged_amount %}
{% if exchanged %}
<div class="exchanged-amount mb-2 mb-lg-0">
<c-amount.display
:amount="exchanged.amount"
:prefix="exchanged.prefix"
:suffix="exchanged.suffix"
:decimal_places="exchanged.decimal_places"
color="grey"></c-amount.display>
</div>
{% endif %}
{% endwith %}
{% endif %}
<div>
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
</div>
</div>
<div>
{# Item actions#}
<div
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 class="card-body p-1 shadow-lg d-flex flex-row gap-1">
{% if not transaction.deleted %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
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>
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-ellipsis fa-fw"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
{% if transaction.account.is_untracked_by %}
<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 account' %}</div>
</div>
</a>
{% if not dummy %}
<div>
{# Item actions#}
<div
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 class="card-body p-1 shadow-lg d-flex flex-row gap-1">
{% if not transaction.deleted %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
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>
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-ellipsis fa-fw"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
{% if transaction.account.is_untracked_by %}
<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 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>
{% 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>
<hr class="dropdown-divider">
</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><hr class="dropdown-divider"></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="#" 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>
<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>
<li><hr class="dropdown-divider"></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>
</ul>
{% else %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Restore" %}"
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
class="fa-solid fa-trash-arrow-up"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
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 %}
<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="#"
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>
<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>
<li>
<hr class="dropdown-divider">
</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>
</ul>
{% else %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Restore" %}"
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
class="fa-solid fa-trash-arrow-up"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
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>
{% endif %}
</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-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
@@ -101,33 +101,57 @@
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
{% endif %}
{% endfor %}
{% if not all_actions %}
{% empty %}
<div class="card">
<div class="card-body">
{% translate 'This rule has no actions' %}
</div>
</div>
{% endif %}
{% endfor %}
<hr>
<div class="dropdown">
<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 dropdown-menu-end w-100">
<li><a class="dropdown-item" role="link"
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"
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 class="d-grid d-lg-flex gap-2">
<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-flask-vial me-2"></i>{% translate 'Test' %}
</button>
<ul class="dropdown-menu">
{% 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>
{% 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>
{% 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>