This commit is contained in:
Herculino Trotta
2025-09-02 15:54:45 -03:00
parent eacafa1def
commit d724300513
10 changed files with 298 additions and 205 deletions

View File

@@ -42,6 +42,7 @@ class TransactionRuleForm(forms.ModelForm):
Column(Switch("on_create")), Column(Switch("on_create")),
Column(Switch("on_delete")), Column(Switch("on_delete")),
), ),
"order",
Switch("sequenced"), Switch("sequenced"),
"description", "description",
"trigger", "trigger",

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-09-02 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='order',
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
),
]

View File

@@ -17,6 +17,7 @@ class TransactionRule(SharedObject):
verbose_name=_("Sequenced"), verbose_name=_("Sequenced"),
default=False, default=False,
) )
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
objects = SharedObjectManager() objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager all_objects = models.Manager() # Unfiltered manager

View File

@@ -7,7 +7,6 @@ from decimal import Decimal
from itertools import chain from itertools import chain
from pprint import pformat from pprint import pformat
from random import randint, random from random import randint, random
from textwrap import indent
from typing import Literal from typing import Literal
from cachalot.api import cachalot_disabled from cachalot.api import cachalot_disabled
@@ -38,6 +37,10 @@ class DryRunResults:
def __init__(self): def __init__(self):
self.results = [] self.results = []
def header(self, header: str, action):
result = {"type": "header", "header_type": header, "action": action}
self.results.append(result)
def triggering_transaction(self, instance): def triggering_transaction(self, instance):
result = { result = {
"type": "triggering_transaction", "type": "triggering_transaction",
@@ -82,18 +85,12 @@ class DryRunResults:
} }
self.results.append(result) self.results.append(result)
def error( def error(self, error, level: Literal["error", "warning", "info"] = "error"):
self,
action: Literal["update_or_create_transaction", "edit_transaction"],
error,
action_obj,
):
result = { result = {
"type": "error", "type": "error",
"action": action,
"action_obj": action_obj,
"error": error, "error": error,
"traceback": traceback.format_exc(), "traceback": traceback.format_exc(),
"level": level,
} }
self.results.append(result) self.results.append(result)
@@ -126,129 +123,145 @@ def check_for_transaction_rules(
if k.startswith(prefix): if k.startswith(prefix):
del simple.names[k] del simple.names[k]
def _get_names(instance: Transaction | dict, prefix: str = ""): def _get_names(transaction: Transaction | dict, prefix: str = ""):
if isinstance(instance, Transaction): if isinstance(transaction, Transaction):
return { return {
f"{prefix}id": instance.id, "is_on_create": True if signal == "transaction_created" else False,
f"{prefix}account_name": instance.account.name if instance.id else None, "is_on_delete": True if signal == "transaction_deleted" else False,
f"{prefix}account_id": instance.account.id if instance.id else None, "is_on_update": True if signal == "transaction_updated" else False,
f"{prefix}id": transaction.id,
f"{prefix}account_name": (
transaction.account.name if transaction.id else None
),
f"{prefix}account_id": (
transaction.account.id if transaction.id else None
),
f"{prefix}account_group_name": ( f"{prefix}account_group_name": (
instance.account.group.name transaction.account.group.name
if instance.id and instance.account.group if transaction.id and transaction.account.group
else None else None
), ),
f"{prefix}account_group_id": ( f"{prefix}account_group_id": (
instance.account.group.id transaction.account.group.id
if instance.id and instance.account.group if transaction.id and transaction.account.group
else None else None
), ),
f"{prefix}is_asset_account": ( f"{prefix}is_asset_account": (
instance.account.is_asset if instance.id else None transaction.account.is_asset if transaction.id else None
), ),
f"{prefix}is_archived_account": ( f"{prefix}is_archived_account": (
instance.account.is_archived if instance.id else None transaction.account.is_archived if transaction.id else None
), ),
f"{prefix}category_name": ( f"{prefix}category_name": (
instance.category.name if instance.category else None transaction.category.name if transaction.category else None
), ),
f"{prefix}category_id": ( f"{prefix}category_id": (
instance.category.id if instance.category else None transaction.category.id if transaction.category else None
), ),
f"{prefix}tag_names": ( f"{prefix}tag_names": (
[x.name for x in instance.tags.all()] if instance.id else [] [x.name for x in transaction.tags.all()] if transaction.id else []
), ),
f"{prefix}tag_ids": ( f"{prefix}tag_ids": (
[x.id for x in instance.tags.all()] if instance.id else [] [x.id for x in transaction.tags.all()] if transaction.id else []
), ),
f"{prefix}entities_names": ( f"{prefix}entities_names": (
[x.name for x in instance.entities.all()] if instance.id else [] [x.name for x in transaction.entities.all()]
if transaction.id
else []
), ),
f"{prefix}entities_ids": ( f"{prefix}entities_ids": (
[x.id for x in instance.entities.all()] if instance.id else [] [x.id for x in transaction.entities.all()] if transaction.id else []
), ),
f"{prefix}is_expense": instance.type == Transaction.Type.EXPENSE, f"{prefix}is_expense": transaction.type == Transaction.Type.EXPENSE,
f"{prefix}is_income": instance.type == Transaction.Type.INCOME, f"{prefix}is_income": transaction.type == Transaction.Type.INCOME,
f"{prefix}is_paid": instance.is_paid, f"{prefix}is_paid": transaction.is_paid,
f"{prefix}description": instance.description, f"{prefix}description": transaction.description,
f"{prefix}amount": instance.amount or 0, f"{prefix}amount": transaction.amount or 0,
f"{prefix}notes": instance.notes, f"{prefix}notes": transaction.notes,
f"{prefix}date": instance.date, f"{prefix}date": transaction.date,
f"{prefix}reference_date": instance.reference_date, f"{prefix}reference_date": transaction.reference_date,
f"{prefix}internal_note": instance.internal_note, f"{prefix}internal_note": transaction.internal_note,
f"{prefix}internal_id": instance.internal_id, f"{prefix}internal_id": transaction.internal_id,
f"{prefix}is_deleted": instance.deleted, f"{prefix}is_deleted": transaction.deleted,
f"{prefix}is_muted": instance.mute, f"{prefix}is_muted": transaction.mute,
} }
else: else:
return { return {
f"{prefix}id": instance.get("id"), "is_on_create": True if signal == "transaction_created" else False,
f"{prefix}account_name": instance.get("account", (None, None))[1], "is_on_delete": True if signal == "transaction_deleted" else False,
f"{prefix}account_id": instance.get("account", (None, None))[0], "is_on_update": True if signal == "transaction_updated" else False,
f"{prefix}account_group_name": instance.get( f"{prefix}id": transaction.get("id"),
f"{prefix}account_name": transaction.get("account", (None, None))[1],
f"{prefix}account_id": transaction.get("account", (None, None))[0],
f"{prefix}account_group_name": transaction.get(
"account_group", (None, None) "account_group", (None, None)
)[1], )[1],
f"{prefix}account_group_id": instance.get( f"{prefix}account_group_id": transaction.get(
"account_group", (None, None) "account_group", (None, None)
)[0], )[0],
f"{prefix}is_asset_account": instance.get("is_asset"), f"{prefix}is_asset_account": transaction.get("is_asset"),
f"{prefix}is_archived_account": instance.get("is_archived"), f"{prefix}is_archived_account": transaction.get("is_archived"),
f"{prefix}category_name": instance.get("category", (None, None))[1], f"{prefix}category_name": transaction.get("category", (None, None))[1],
f"{prefix}category_id": instance.get("category", (None, None))[0], f"{prefix}category_id": transaction.get("category", (None, None))[0],
f"{prefix}tag_names": [x[1] for x in instance.get("tags", [])], f"{prefix}tag_names": [x[1] for x in transaction.get("tags", [])],
f"{prefix}tag_ids": [x[0] for x in instance.get("tags", [])], f"{prefix}tag_ids": [x[0] for x in transaction.get("tags", [])],
f"{prefix}entities_names": [x[1] for x in instance.get("entities", [])], f"{prefix}entities_names": [
f"{prefix}entities_ids": [x[0] for x in instance.get("entities", [])], x[1] for x in transaction.get("entities", [])
f"{prefix}is_expense": instance.get("type") == Transaction.Type.EXPENSE, ],
f"{prefix}is_income": instance.get("type") == Transaction.Type.INCOME, f"{prefix}entities_ids": [
f"{prefix}is_paid": instance.get("is_paid"), x[0] for x in transaction.get("entities", [])
f"{prefix}description": instance.get("description", ""), ],
f"{prefix}amount": Decimal(instance.get("amount")), f"{prefix}is_expense": transaction.get("type")
f"{prefix}notes": instance.get("notes", ""), == Transaction.Type.EXPENSE,
f"{prefix}date": datetime.fromisoformat(instance.get("date")), f"{prefix}is_income": transaction.get("type")
== Transaction.Type.INCOME,
f"{prefix}is_paid": transaction.get("is_paid"),
f"{prefix}description": transaction.get("description", ""),
f"{prefix}amount": Decimal(transaction.get("amount")),
f"{prefix}notes": transaction.get("notes", ""),
f"{prefix}date": datetime.fromisoformat(transaction.get("date")),
f"{prefix}reference_date": datetime.fromisoformat( f"{prefix}reference_date": datetime.fromisoformat(
instance.get("reference_date") transaction.get("reference_date")
), ),
f"{prefix}internal_note": instance.get("internal_note", ""), f"{prefix}internal_note": transaction.get("internal_note", ""),
f"{prefix}internal_id": instance.get("internal_id", ""), f"{prefix}internal_id": transaction.get("internal_id", ""),
f"{prefix}is_deleted": instance.get("deleted", True), f"{prefix}is_deleted": transaction.get("deleted", True),
f"{prefix}is_muted": instance.get("mute", False), f"{prefix}is_muted": transaction.get("mute", False),
} }
def _process_update_or_create_transaction_action(processed_action): def _process_update_or_create_transaction_action(processed_action):
"""Helper to process a single linked transaction action""" """Helper to process a single linked transaction action"""
dry_run_results.header("update_or_create_transaction", action=processed_action)
# Build search query using the helper method # Build search query using the helper method
search_query = processed_action.build_search_query(simple) search_query = processed_action.build_search_query(simple)
_log(f" ├─ Searching transactions using: {search_query}") _log(f"Searching transactions using: {search_query}")
starting_instance = None starting_instance = None
# Find latest matching transaction or create new # Find latest matching transaction or create new
if search_query: if search_query:
transactions = Transaction.objects.filter(search_query).order_by( searched_transactions = Transaction.objects.filter(search_query).order_by(
"-date", "-id" "-date", "-id"
) )
if transactions.exists(): if searched_transactions.exists():
transaction = transactions.first() transaction = searched_transactions.first()
existing = True existing = True
starting_instance = deepcopy(transaction) starting_instance = deepcopy(transaction)
_log(" ├─ Found at least one matching transaction, using latest:") _log("Found at least one matching transaction, using latest:")
_log( _log("{}".format(pformat(model_to_dict(transaction))))
" ├─ {}".format(
indent(pformat(model_to_dict(transaction)), " ")
)
)
else: else:
transaction = Transaction() transaction = Transaction()
existing = False existing = False
_log( _log(
" ├─ No matching transaction found, creating a new transaction", "No matching transaction found, creating a new transaction",
) )
else: else:
transaction = Transaction() transaction = Transaction()
existing = False existing = False
_log( _log(
" ├─ No matching transaction found, creating a new transaction", "No matching transaction found, creating a new transaction",
) )
simple.names.update(_get_names(transaction, prefix="my_")) simple.names.update(_get_names(transaction, prefix="my_"))
@@ -256,9 +269,10 @@ def check_for_transaction_rules(
if processed_action.filter: if processed_action.filter:
value = simple.eval(processed_action.filter) value = simple.eval(processed_action.filter)
if not value: if not value:
_log( dry_run_results.error(
" ├─ Filter did not match. Execution of this action has been stopped." error="Filter did not match. Execution of this action has stopped.",
) )
_log("Filter did not match. Execution of this action has stopped.")
return # Short-circuit execution if filter evaluates to false return # Short-circuit execution if filter evaluates to false
# Set fields if provided # Set fields if provided
@@ -310,34 +324,24 @@ def check_for_transaction_rules(
if dry_run: if dry_run:
if not transaction.id: if not transaction.id:
_log(" ├─ Transaction would be created as:") _log("Transaction would be created as:")
else: else:
_log(" ├─ Trasanction would be updated as:") _log("Trasanction would be updated as:")
_log( _log(
" ├─ {}".format( "{}".format(
indent( pformat(model_to_dict(transaction, exclude=["tags", "entities"])),
pformat(
model_to_dict(transaction, exclude=["tags", "entities"])
),
" ",
)
) )
) )
else: else:
if not transaction.id: if not transaction.id:
_log(" ├─ Transaction will be created as:") _log("Transaction will be created as:")
else: else:
_log(" ├─ Trasanction will be updated as:") _log("Trasanction will be updated as:")
_log( _log(
" ├─ {}".format( "{}".format(
indent( pformat(model_to_dict(transaction, exclude=["tags", "entities"])),
pformat(
model_to_dict(transaction, exclude=["tags", "entities"])
),
" ",
)
) )
) )
transaction.save() transaction.save()
@@ -347,9 +351,9 @@ def check_for_transaction_rules(
if processed_action.set_tags: if processed_action.set_tags:
tags = simple.eval(processed_action.set_tags) tags = simple.eval(processed_action.set_tags)
if dry_run: if dry_run:
_log(f" ├─ And tags would be set as: {tags}") _log(f" And tags would be set as: {tags}")
else: else:
_log(f" ├─ And tags will be set as: {tags}") _log(f" And tags will be set as: {tags}")
transaction.tags.clear() transaction.tags.clear()
if isinstance(tags, (list, tuple)): if isinstance(tags, (list, tuple)):
for tag in tags: for tag in tags:
@@ -367,9 +371,9 @@ def check_for_transaction_rules(
if processed_action.set_entities: if processed_action.set_entities:
entities = simple.eval(processed_action.set_entities) entities = simple.eval(processed_action.set_entities)
if dry_run: if dry_run:
_log(f" ├─ And entities would be set as: {entities}") _log(f" And entities would be set as: {entities}")
else: else:
_log(f" ├─ And entities will be set as: {entities}") _log(f" And entities will be set as: {entities}")
transaction.entities.clear() transaction.entities.clear()
if isinstance(entities, (list, tuple)): if isinstance(entities, (list, tuple)):
for entity in entities: for entity in entities:
@@ -391,8 +395,6 @@ def check_for_transaction_rules(
TransactionEntity.objects.get(name=entities) TransactionEntity.objects.get(name=entities)
) )
transaction.full_clean()
dry_run_results.update_or_create_transaction( dry_run_results.update_or_create_transaction(
start_instance=starting_instance, start_instance=starting_instance,
end_instance=deepcopy(transaction), end_instance=deepcopy(transaction),
@@ -403,68 +405,52 @@ def check_for_transaction_rules(
tags=tags, tags=tags,
) )
def _process_edit_transaction_action(instance, processed_action) -> Transaction: # transaction.full_clean()
def _process_edit_transaction_action(transaction, processed_action) -> Transaction:
dry_run_results.header("edit_transaction", action=processed_action)
field = processed_action.field field = processed_action.field
original_value = getattr(instance, field) original_value = getattr(transaction, field)
new_value = simple.eval(processed_action.value) new_value = simple.eval(processed_action.value)
tags = [] tags = []
entities = [] entities = []
_log( _log(
f" ├─ Changing field '{field}' from '{original_value}' to '{new_value}'", f"Changing field '{field}' from '{original_value}' to '{new_value}'",
) )
form_data = {} if field == TransactionRuleAction.Field.account:
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): if isinstance(new_value, int):
account = Account.objects.get(id=new_value) account = Account.objects.get(id=new_value)
instance.account = account transaction.account = account
elif isinstance(new_value, str): elif isinstance(new_value, str):
account = Account.objects.filter(name=new_value).first() account = Account.objects.filter(name=new_value).first()
instance.account = account transaction.account = account
elif field == TransactionRuleAction.Field.category: elif field == TransactionRuleAction.Field.category:
if isinstance(new_value, int): if isinstance(new_value, int):
category = TransactionCategory.objects.get(id=new_value) category = TransactionCategory.objects.get(id=new_value)
instance.category = category transaction.category = category
elif isinstance(new_value, str): elif isinstance(new_value, str):
category = TransactionCategory.objects.get(name=new_value) category = TransactionCategory.objects.get(name=new_value)
instance.category = category transaction.category = category
elif field == TransactionRuleAction.Field.tags: elif field == TransactionRuleAction.Field.tags:
if not dry_run: if not dry_run:
instance.tags.clear() transaction.tags.clear()
if isinstance(new_value, list): if isinstance(new_value, list):
for tag_value in new_value: for tag_value in new_value:
if isinstance(tag_value, int): if isinstance(tag_value, int):
tag = TransactionTag.objects.get(id=tag_value) tag = TransactionTag.objects.get(id=tag_value)
if not dry_run: if not dry_run:
instance.tags.add(tag) transaction.tags.add(tag)
tags.append(tag) tags.append(tag)
elif isinstance(tag_value, str): elif isinstance(tag_value, str):
tag = TransactionTag.objects.get(name=tag_value) tag = TransactionTag.objects.get(name=tag_value)
if not dry_run: if not dry_run:
instance.tags.add(tag) transaction.tags.add(tag)
tags.append(tag) tags.append(tag)
elif isinstance(new_value, (int, str)): elif isinstance(new_value, (int, str)):
@@ -474,23 +460,23 @@ def check_for_transaction_rules(
tag = TransactionTag.objects.get(name=new_value) tag = TransactionTag.objects.get(name=new_value)
if not dry_run: if not dry_run:
instance.tags.add(tag) transaction.tags.add(tag)
tags.append(tag) tags.append(tag)
elif field == TransactionRuleAction.Field.entities: elif field == TransactionRuleAction.Field.entities:
if not dry_run: if not dry_run:
instance.entities.clear() transaction.entities.clear()
if isinstance(new_value, list): if isinstance(new_value, list):
for entity_value in new_value: for entity_value in new_value:
if isinstance(entity_value, int): if isinstance(entity_value, int):
entity = TransactionEntity.objects.get(id=entity_value) entity = TransactionEntity.objects.get(id=entity_value)
if not dry_run: if not dry_run:
instance.entities.add(entity) transaction.entities.add(entity)
entities.append(entity) entities.append(entity)
elif isinstance(entity_value, str): elif isinstance(entity_value, str):
entity = TransactionEntity.objects.get(name=entity_value) entity = TransactionEntity.objects.get(name=entity_value)
if not dry_run: if not dry_run:
instance.entities.add(entity) transaction.entities.add(entity)
entities.append(entity) entities.append(entity)
elif isinstance(new_value, (int, str)): elif isinstance(new_value, (int, str)):
@@ -499,13 +485,18 @@ def check_for_transaction_rules(
else: else:
entity = TransactionEntity.objects.get(name=new_value) entity = TransactionEntity.objects.get(name=new_value)
if not dry_run: if not dry_run:
instance.entities.add(entity) transaction.entities.add(entity)
entities.append(entity) entities.append(entity)
instance.full_clean() else:
setattr(
transaction,
field,
new_value,
)
dry_run_results.edit_transaction( dry_run_results.edit_transaction(
instance=deepcopy(instance), instance=deepcopy(transaction),
action=processed_action, action=processed_action,
old_value=original_value, old_value=original_value,
new_value=new_value, new_value=new_value,
@@ -514,7 +505,9 @@ def check_for_transaction_rules(
entities=entities, entities=entities,
) )
return instance transaction.full_clean()
return transaction
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)
@@ -522,7 +515,7 @@ def check_for_transaction_rules(
dry_run_results = DryRunResults() dry_run_results = DryRunResults()
if dry_run and not rule_id: if dry_run and not rule_id:
raise Exception("-> Cannot dry run without a rule id") raise Exception("Cannot dry run without a rule id")
try: try:
with cachalot_disabled(): with cachalot_disabled():
@@ -553,8 +546,8 @@ def check_for_transaction_rules(
"transactions": transactions.TransactionsGetter, "transactions": transactions.TransactionsGetter,
} }
_log("-> Starting rule execution...") _log("Starting rule execution...")
_log("-> Available functions: {}".format(functions.keys())) _log("Available functions: {}".format(functions.keys()))
names = _get_names(instance) names = _get_names(instance)
@@ -564,45 +557,55 @@ def check_for_transaction_rules(
simple.names.update(_get_names(old_data, "old_")) 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 dry_run and rule_id:
rules = TransactionRule.objects.filter(id=rule_id)
elif signal == "transaction_created":
rules = TransactionRule.objects.filter( rules = TransactionRule.objects.filter(
active=True, on_create=True active=True, on_create=True
).order_by("id") ).order_by("order", "id")
elif signal == "transaction_updated": elif signal == "transaction_updated":
rules = TransactionRule.objects.filter( rules = TransactionRule.objects.filter(
active=True, on_update=True active=True, on_update=True
).order_by("id") ).order_by("order", "id")
elif signal == "transaction_deleted": elif signal == "transaction_deleted":
rules = TransactionRule.objects.filter( rules = TransactionRule.objects.filter(
active=True, on_delete=True active=True, on_delete=True
).order_by("id") ).order_by("order", "id")
else: else:
rules = TransactionRule.objects.filter(active=True).order_by("id") rules = TransactionRule.objects.filter(active=True).order_by(
"order", "id"
)
if dry_run and rule_id: _log("Testing {} rule(s)...".format(len(rules)))
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)) _log("Testing rule: {}".format(rule.name))
if simple.eval(rule.trigger): if simple.eval(rule.trigger):
_log("├─ Initial trigger matched!") _log("Initial trigger matched!")
# For deleted transactions, we want to limit what actions can be performed # For deleted transactions, we want to limit what actions can be performed
if signal == "transaction_deleted": if signal == "transaction_deleted":
_log( _log(
"├─ Event is of type 'delete'. Only processing Update or Create actions..." "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 action in rule.update_or_create_transaction_actions.all(): for action in rule.update_or_create_transaction_actions.all():
try: try:
_log(
"Processing action with id {} and order {}...".format(
action.id, action.order
)
)
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
processed_action=action, processed_action=action,
) )
except Exception as e: except Exception as e:
dry_run_results.error(
"Error raised: '{}'. Check the logs tab for more "
"information".format(e)
)
_log( _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",
level="error", level="error",
) )
else: else:
@@ -619,7 +622,7 @@ def check_for_transaction_rules(
if has_custom_order: if has_custom_order:
_log( _log(
"├─ One or more actions have a custom order, actions will be processed ordered by " "One or more actions have a custom order, actions will be processed ordered by "
"order and creation date..." "order and creation date..."
) )
# Combine and sort actions by order # Combine and sort actions by order
@@ -631,8 +634,13 @@ def check_for_transaction_rules(
for action in all_actions: for action in all_actions:
try: try:
if isinstance(action, TransactionRuleAction): if isinstance(action, TransactionRuleAction):
_log(
"Processing 'edit_transaction' action with id {} and order {}...".format(
action.id, action.order
)
)
instance = _process_edit_transaction_action( instance = _process_edit_transaction_action(
instance=instance, transaction=instance,
processed_action=action, processed_action=action,
) )
@@ -640,13 +648,22 @@ def check_for_transaction_rules(
# Update names for next actions # Update names for next actions
simple.names.update(_get_names(instance)) simple.names.update(_get_names(instance))
else: else:
_log(
"Processing 'update_or_create_transaction' action with id {} and order {}...".format(
action.id, action.order
)
)
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
processed_action=action, processed_action=action,
) )
_clear_names("my_") _clear_names("my_")
except Exception as e: except Exception as e:
dry_run_results.error(
"Error raised: '{}'. Check the logs tab for more "
"information".format(e)
)
_log( _log(
f"├─ Error processing action {action.id}", f"Error processing action {action.id}",
level="error", level="error",
) )
# Save at the end # Save at the end
@@ -654,22 +671,31 @@ def check_for_transaction_rules(
instance.save() instance.save()
else: else:
_log( _log(
"├─ No actions have a custom order, actions will be processed ordered by creation " "No actions have a custom order, actions will be processed ordered by creation "
"date, with Edit actions running first, then Update or Create actions..." "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:
_log(
"Processing 'edit_transaction' action with id {}...".format(
action.id
)
)
try: try:
instance = _process_edit_transaction_action( instance = _process_edit_transaction_action(
instance=instance, transaction=instance,
processed_action=action, processed_action=action,
) )
if rule.sequenced: if rule.sequenced:
# Update names for next actions # Update names for next actions
simple.names.update(_get_names(instance)) simple.names.update(_get_names(instance))
except Exception as e: except Exception as e:
dry_run_results.error(
"Error raised: '{}'. Check the logs tab for more "
"information".format(e)
)
_log( _log(
f"├─ Error processing edit transaction action {action.id}", f"Error processing edit transaction action {action.id}",
level="error", level="error",
) )
@@ -680,18 +706,30 @@ def check_for_transaction_rules(
instance.save() instance.save()
for action in update_or_create_actions: for action in update_or_create_actions:
_log(
"Processing 'update_or_create_transaction' action with id {}...".format(
action.id
)
)
try: try:
_process_update_or_create_transaction_action( _process_update_or_create_transaction_action(
processed_action=action, processed_action=action,
) )
_clear_names("my_") _clear_names("my_")
except Exception as e: except Exception as e:
dry_run_results.error(
"Error raised: '{}'. Check the logs tab for more "
"information".format(e)
)
_log( _log(
f"├─ Error processing update or create transaction action {action.id}", f"Error processing update or create transaction action {action.id}",
level="error", level="error",
) )
else: else:
_log("├─ Initial trigger didn't match, this rule will be skipped") dry_run_results.error(
error="Initial trigger didn't match, this rule will be skipped",
)
_log("Initial trigger didn't match, this rule will be skipped")
except Exception as e: except Exception as e:
_log( _log(
"** Error while executing 'check_for_transaction_rules' task", "** Error while executing 'check_for_transaction_rules' task",

View File

@@ -42,7 +42,7 @@ def rules_index(request):
@disabled_on_demo @disabled_on_demo
@require_http_methods(["GET"]) @require_http_methods(["GET"])
def rules_list(request): def rules_list(request):
transaction_rules = TransactionRule.objects.all().order_by("id") transaction_rules = TransactionRule.objects.all().order_by("order", "id")
return render( return render(
request, request,
"rules/fragments/list.html", "rules/fragments/list.html",

View File

@@ -380,7 +380,7 @@ class Transaction(OwnedObject):
db_table = "transactions" db_table = "transactions"
default_manager_name = "objects" default_manager_name = "objects"
def save(self, *args, **kwargs): def clean_fields(self, *args, **kwargs):
self.amount = truncate_decimal( self.amount = truncate_decimal(
value=self.amount, decimal_places=self.account.currency.decimal_places value=self.amount, decimal_places=self.account.currency.decimal_places
) )
@@ -390,6 +390,11 @@ class Transaction(OwnedObject):
elif not self.reference_date and self.date: elif not self.reference_date and self.date:
self.reference_date = self.date.replace(day=1) self.reference_date = self.date.replace(day=1)
super().clean_fields(*args, **kwargs)
def save(self, *args, **kwargs):
# This is not recommended as it will run twice on some cases like form and API saves.
# We only do this here because we forgot to independently call it on multiple places.
self.full_clean() self.full_clean()
super().save(*args, **kwargs) super().save(*args, **kwargs)
@@ -447,7 +452,11 @@ class Transaction(OwnedObject):
type_display = self.get_type_display() type_display = self.get_type_display()
frmt_date = date(self.date, "SHORT_DATE_FORMAT") frmt_date = date(self.date, "SHORT_DATE_FORMAT")
account = self.account account = self.account
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags") tags = (
", ".join([x.name for x in self.tags.all()])
if self.id
else None or _("No tags")
)
category = self.category or _("No category") category = self.category or _("No category")
amount = localize_number(drop_trailing_zeros(self.amount)) amount = localize_number(drop_trailing_zeros(self.amount))
description = self.description or _("No description") description = self.description or _("No description")

View File

@@ -177,7 +177,6 @@ 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()

View File

@@ -61,19 +61,20 @@
</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 %} {% comment %} First, check for the highest priority: a valid 'overriden_entities' list. {% endcomment %}
{% if entities and not overriden_entities %} {% if 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">{{ overriden_entities|join:", " }}</div>
</div> </div>
{% elif overriden_entities %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> {% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div> {% elif transaction.id and transaction.entities.all %}
<div class="col ps-0">{{ overriden_entities|join:", " }}</div> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
</div> <div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
{% endif %} <div class="col ps-0">{{ transaction.entities.all|join:", " }}</div>
{% endwith %} </div>
{% endif %}
{# 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">
@@ -89,19 +90,20 @@
</div> </div>
{% endif %} {% endif %}
{# Tags#} {# Tags#}
{% with transaction.tags.all as tags %} {% comment %} First, check for the highest priority: a valid 'overriden_tags' list. {% endcomment %}
{% if tags and not overriden_tags %} {% if 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">{{ overriden_tags|join:", " }}</div>
</div> </div>
{% elif overriden_tags %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> {% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div> {% elif transaction.id and transaction.tags.all %}
<div class="col ps-0">{{ overriden_tags|join:", " }}</div> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
</div> <div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
{% endif %} <div class="col ps-0">{{ transaction.tags.all|join:", " }}</div>
{% endwith %} </div>
{% endif %}
</div> </div>
</div> </div>
<div <div

View File

@@ -23,6 +23,7 @@
<tr> <tr>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col-auto">{% translate 'Order' %}</th>
<th scope="col" class="col">{% translate 'Name' %}</th> <th scope="col" class="col">{% translate 'Name' %}</th>
</tr> </tr>
</thead> </thead>
@@ -80,6 +81,9 @@
<i class="fa-solid fa-toggle-off tw:text-red-400"></i>{% endif %} <i class="fa-solid fa-toggle-off tw:text-red-400"></i>{% endif %}
</a> </a>
</td> </td>
<td class="col text-center">
<div>{{ rule.order }}</div>
</td>
<td class="col"> <td class="col">
<div>{{ rule.name }}</div> <div>{{ rule.name }}</div>
<div class="tw:text-gray-400">{{ rule.description }}</div> <div class="tw:text-gray-400">{{ rule.description }}</div>

View File

@@ -22,6 +22,21 @@
{% else %} {% else %}
{% for result in results %} {% for result in results %}
{% if result.type == 'header' %}
<div class="my-3">
<h6 class="text-center mb-3">
<span class="badge text-bg-secondary">
{% if result.header_type == "edit_transaction" %}
{% translate 'Edit transaction' %}
{% elif result.header_type == "update_or_create_transaction" %}
{% translate 'Update or create transaction' %}
{% endif %}
</span>
</h6>
</div>
{% endif %}
{% if result.type == 'triggering_transaction' %} {% if result.type == 'triggering_transaction' %}
<div class="mt-4"> <div class="mt-4">
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Start' %}</span></h6> <h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Start' %}</span></h6>
@@ -31,37 +46,43 @@
{% endif %} {% endif %}
{% if result.type == 'edit_transaction' %} {% if result.type == 'edit_transaction' %}
<div class="mt-4"> <div>
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Edit transaction' %}</span>
</h6>
<div> <div>
{% translate 'Set' %} <span {% translate 'Set' %} <span
class="badge text-bg-secondary">{{ result.field }}</span> {% translate 'to' %} class="badge text-bg-secondary">{{ result.field }}</span> {% translate 'to' %}
<span class="badge text-bg-secondary">{{ result.new_value }}</span> <span class="badge text-bg-secondary">{{ result.new_value }}</span>
</div> </div>
<c-transaction.item :transaction="result.transaction" :dummy="True" <c-transaction.item :transaction="result.transaction" :dummy="True"
:disable-selection="True" :overriden_tags="result.tags" :overriden_entities="result.entities"></c-transaction.item> :disable-selection="True" :overriden_tags="result.tags"
:overriden_entities="result.entities"></c-transaction.item>
</div> </div>
{% endif %} {% endif %}
{% if result.type == 'update_or_create_transaction' %} {% if result.type == 'update_or_create_transaction' %}
<div class="mt-4"> <div>
<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"> <div class="alert alert-info" role="alert">
{% translate 'Search' %}: {{ result.query }} {% translate 'Search' %}: {{ result.query }}
</div> </div>
{% if result.start_transaction %} {% if result.start_transaction %}
<c-transaction.item :transaction="result.start_transaction" :dummy="True" <c-transaction.item :transaction="result.start_transaction" :dummy="True"
:disable-selection="True"></c-transaction.item> :disable-selection="True"></c-transaction.item>
{% else %} {% else %}
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
{% translate 'No transaction found, a new one will be created' %} {% translate 'No transaction found, a new one will be created' %}
</div> </div>
{% endif %} {% endif %}
<div class="text-center h3"><i class="fa-solid fa-arrow-down"></i></div> <div class="text-center h3 my-2"><i class="fa-solid fa-arrow-down"></i></div>
<c-transaction.item :transaction="result.start_transaction" :dummy="True" <c-transaction.item :transaction="result.end_transaction" :dummy="True"
:disable-selection="True" :overriden_tags="result.tags" :overriden_entities="result.entities"></c-transaction.item> :disable-selection="True" :overriden_tags="result.tags"
:overriden_entities="result.entities"></c-transaction.item>
</div>
{% endif %}
{% if result.type == 'error' %}
<div>
<div class="alert alert-{% if result.level == 'error' %}danger{% elif result.level == 'warning' %}warning{% else %}info{% endif %}" role="alert">
{{ result.error }}
</div>
</div> </div>
{% endif %} {% endif %}