From 467131d9f1075b2c3938e7b5e14ff8a9e7be934a Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 8 Feb 2025 04:16:28 -0300 Subject: [PATCH 1/2] feat(rules): add Update or Create Transaction action --- app/apps/rules/admin.py | 9 +- app/apps/rules/forms.py | 260 ++++++++++- app/apps/rules/models.py | 376 +++++++++++++++ app/apps/rules/tasks.py | 431 +++++++++++++----- app/apps/rules/urls.py | 21 +- app/apps/rules/views.py | 106 ++++- .../add.html | 11 + .../edit.html | 11 + .../fragments/transaction_rule/view.html | 181 +++++--- 9 files changed, 1207 insertions(+), 199 deletions(-) create mode 100644 app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/add.html create mode 100644 app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/edit.html diff --git a/app/apps/rules/admin.py b/app/apps/rules/admin.py index d89b498..ea6684e 100644 --- a/app/apps/rules/admin.py +++ b/app/apps/rules/admin.py @@ -1,7 +1,12 @@ from django.contrib import admin -from apps.rules.models import TransactionRule, TransactionRuleAction +from apps.rules.models import ( + TransactionRule, + TransactionRuleAction, + UpdateOrCreateTransactionRuleAction, +) + -# Register your models here. admin.site.register(TransactionRule) admin.site.register(TransactionRuleAction) +admin.site.register(UpdateOrCreateTransactionRuleAction) diff --git a/app/apps/rules/forms.py b/app/apps/rules/forms.py index 966ec26..63fa554 100644 --- a/app/apps/rules/forms.py +++ b/app/apps/rules/forms.py @@ -1,15 +1,15 @@ -from crispy_bootstrap5.bootstrap5 import Switch -from crispy_forms.bootstrap import FormActions +from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion +from crispy_forms.bootstrap import FormActions, AccordionGroup from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field, Row, Column from django import forms from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ -from apps.rules.models import TransactionRule -from apps.rules.models import TransactionRuleAction from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.tom_select import TomSelect +from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction +from apps.rules.models import TransactionRuleAction class TransactionRuleForm(forms.ModelForm): @@ -123,3 +123,255 @@ class TransactionRuleActionForm(forms.ModelForm): if commit: instance.save() return instance + + +class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm): + class Meta: + model = UpdateOrCreateTransactionRuleAction + exclude = ("rule",) + widgets = { + "search_account_operator": TomSelect(clear_button=False), + "search_type_operator": TomSelect(clear_button=False), + "search_is_paid_operator": TomSelect(clear_button=False), + "search_date_operator": TomSelect(clear_button=False), + "search_reference_date_operator": TomSelect(clear_button=False), + "search_amount_operator": TomSelect(clear_button=False), + "search_description_operator": TomSelect(clear_button=False), + "search_notes_operator": TomSelect(clear_button=False), + "search_category_operator": TomSelect(clear_button=False), + "search_internal_note_operator": TomSelect(clear_button=False), + "search_internal_id_operator": TomSelect(clear_button=False), + } + + labels = { + "search_account_operator": _("Operator"), + "search_type_operator": _("Operator"), + "search_is_paid_operator": _("Operator"), + "search_date_operator": _("Operator"), + "search_reference_date_operator": _("Operator"), + "search_amount_operator": _("Operator"), + "search_description_operator": _("Operator"), + "search_notes_operator": _("Operator"), + "search_category_operator": _("Operator"), + "search_internal_note_operator": _("Operator"), + "search_internal_id_operator": _("Operator"), + "search_tags_operator": _("Operator"), + "search_entities_operator": _("Operator"), + "search_account": _("Account"), + "search_type": _("Type"), + "search_is_paid": _("Paid"), + "search_date": _("Date"), + "search_reference_date": _("Reference Date"), + "search_amount": _("Amount"), + "search_description": _("Description"), + "search_notes": _("Notes"), + "search_category": _("Category"), + "search_internal_note": _("Internal Note"), + "search_internal_id": _("Internal ID"), + "search_tags": _("Tags"), + "search_entities": _("Entities"), + "set_account": _("Account"), + "set_type": _("Type"), + "set_is_paid": _("Paid"), + "set_date": _("Date"), + "set_reference_date": _("Reference Date"), + "set_amount": _("Amount"), + "set_description": _("Description"), + "set_tags": _("Tags"), + "set_entities": _("Entities"), + "set_notes": _("Notes"), + "set_category": _("Category"), + "set_internal_note": _("Internal Note"), + "set_internal_id": _("Internal ID"), + } + + def __init__(self, *args, **kwargs): + self.rule = kwargs.pop("rule", None) + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.form_method = "post" + + self.helper.layout = Layout( + BS5Accordion( + AccordionGroup( + _("Search Criteria"), + Field("filter", rows=1), + Row( + Column( + Field("search_type_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_type", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_is_paid_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_is_paid", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_account_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_account", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_entities_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_entities", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_date_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_date", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_reference_date_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_reference_date", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_description_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_description", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_amount_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_amount", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_category_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_category", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_tags_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_tags", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_notes_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_notes", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_internal_note_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_internal_note", rows=1), + css_class="form-group col-md-8", + ), + ), + Row( + Column( + Field("search_internal_id_operator"), + css_class="form-group col-md-4", + ), + Column( + Field("search_internal_id", rows=1), + css_class="form-group col-md-8", + ), + ), + active=True, + ), + AccordionGroup( + _("Set Values"), + Field("set_type", rows=1), + Field("set_is_paid", rows=1), + Field("set_account", rows=1), + Field("set_entities", rows=1), + Field("set_date", rows=1), + Field("set_reference_date", rows=1), + Field("set_description", rows=1), + Field("set_amount", rows=1), + Field("set_category", rows=1), + Field("set_tags", rows=1), + Field("set_notes", rows=1), + Field("set_internal_note", rows=1), + Field("set_internal_id", rows=1), + css_class="mb-3", + active=True, + ), + always_open=True, + ), + ) + + if self.instance and self.instance.pk: + self.helper.layout.append( + FormActions( + NoClassSubmit( + "submit", _("Update"), css_class="btn btn-outline-primary w-100" + ), + ), + ) + else: + self.helper.layout.append( + FormActions( + NoClassSubmit( + "submit", _("Add"), css_class="btn btn-outline-primary w-100" + ), + ), + ) + + def save(self, commit=True): + instance = super().save(commit=False) + instance.rule = self.rule + if commit: + instance.save() + return instance diff --git a/app/apps/rules/models.py b/app/apps/rules/models.py index a33cc13..cd5d32f 100644 --- a/app/apps/rules/models.py +++ b/app/apps/rules/models.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models import Q from django.utils.translation import gettext_lazy as _ @@ -10,6 +11,10 @@ class TransactionRule(models.Model): description = models.TextField(blank=True, null=True, verbose_name=_("Description")) trigger = models.TextField(verbose_name=_("Trigger")) + class Meta: + verbose_name = _("Transaction rule") + verbose_name_plural = _("Transaction rules") + def __str__(self): return self.name @@ -45,4 +50,375 @@ class TransactionRuleAction(models.Model): return f"{self.rule} - {self.field} - {self.value}" class Meta: + verbose_name = _("Edit transaction action") + verbose_name_plural = _("Edit transaction actions") unique_together = (("rule", "field"),) + + +class UpdateOrCreateTransactionRuleAction(models.Model): + """ + Will attempt to find and update latest matching transaction, or create new if none found. + """ + + class SearchOperator(models.TextChoices): + EXACT = "exact", _("is exactly") + CONTAINS = "contains", _("contains") + STARTSWITH = "startswith", _("starts with") + ENDSWITH = "endswith", _("ends with") + EQ = "eq", _("equals") + GT = "gt", _("greater than") + LT = "lt", _("less than") + GTE = "gte", _("greater than or equal") + LTE = "lte", _("less than or equal") + + rule = models.ForeignKey( + TransactionRule, + on_delete=models.CASCADE, + related_name="update_or_create_transaction_actions", + verbose_name=_("Rule"), + ) + + filter = models.TextField( + verbose_name=_("Filter"), + blank=True, + help_text=_( + "Generic expression to enable or disable execution. Should evaluate to True or False" + ), + ) + + # Search fields with operators + search_account = models.TextField( + verbose_name=_("Search Account"), + blank=True, + help_text=_("Expression to match transaction account (ID or name)"), + ) + search_account_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Account Operator"), + ) + + search_type = models.TextField( + verbose_name=_("Search Type"), + blank=True, + help_text=_("Expression to match transaction type ('IN' or 'EX')"), + ) + search_type_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Type Operator"), + ) + + search_is_paid = models.TextField( + verbose_name=_("Search Is Paid"), + blank=True, + help_text=_("Expression to match transaction paid status"), + ) + search_is_paid_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Is Paid Operator"), + ) + + search_date = models.TextField( + verbose_name=_("Search Date"), + blank=True, + help_text=_("Expression to match transaction date"), + ) + search_date_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Date Operator"), + ) + + search_reference_date = models.TextField( + verbose_name=_("Search Reference Date"), + blank=True, + help_text=_("Expression to match transaction reference date"), + ) + search_reference_date_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Reference Date Operator"), + ) + + search_amount = models.TextField( + verbose_name=_("Search Amount"), + blank=True, + help_text=_("Expression to match transaction amount"), + ) + search_amount_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Amount Operator"), + ) + + search_description = models.TextField( + verbose_name=_("Search Description"), + blank=True, + help_text=_("Expression to match transaction description"), + ) + search_description_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.CONTAINS, + verbose_name=_("Description Operator"), + ) + + search_notes = models.TextField( + verbose_name=_("Search Notes"), + blank=True, + help_text=_("Expression to match transaction notes"), + ) + search_notes_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.CONTAINS, + verbose_name=_("Notes Operator"), + ) + + search_category = models.TextField( + verbose_name=_("Search Category"), + blank=True, + help_text=_("Expression to match transaction category (ID or name)"), + ) + search_category_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Category Operator"), + ) + + search_tags = models.TextField( + verbose_name=_("Search Tags"), + blank=True, + help_text=_("Expression to match transaction tags (list of IDs or names)"), + ) + search_tags_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.CONTAINS, + verbose_name=_("Tags Operator"), + ) + + search_entities = models.TextField( + verbose_name=_("Search Entities"), + blank=True, + help_text=_("Expression to match transaction entities (list of IDs or names)"), + ) + search_entities_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.CONTAINS, + verbose_name=_("Entities Operator"), + ) + + search_internal_note = models.TextField( + verbose_name=_("Search Internal Note"), + blank=True, + help_text=_("Expression to match transaction internal note"), + ) + search_internal_note_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Internal Note Operator"), + ) + + search_internal_id = models.TextField( + verbose_name=_("Search Internal ID"), + blank=True, + help_text=_("Expression to match transaction internal ID"), + ) + search_internal_id_operator = models.CharField( + max_length=10, + choices=SearchOperator.choices, + default=SearchOperator.EXACT, + verbose_name=_("Internal ID Operator"), + ) + + # Set fields + set_account = models.TextField( + verbose_name=_("Set Account"), + blank=True, + help_text=_("Expression for account to set (ID or name)"), + ) + set_type = models.TextField( + verbose_name=_("Set Type"), + blank=True, + help_text=_("Expression for type to set ('IN' or 'EX')"), + ) + set_is_paid = models.TextField( + verbose_name=_("Set Is Paid"), + blank=True, + help_text=_("Expression for paid status to set"), + ) + set_date = models.TextField( + verbose_name=_("Set Date"), + blank=True, + help_text=_("Expression for date to set"), + ) + set_reference_date = models.TextField( + verbose_name=_("Set Reference Date"), + blank=True, + help_text=_("Expression for reference date to set"), + ) + set_amount = models.TextField( + verbose_name=_("Set Amount"), + blank=True, + help_text=_("Expression for amount to set"), + ) + set_description = models.TextField( + verbose_name=_("Set Description"), + blank=True, + help_text=_("Expression for description to set"), + ) + set_notes = models.TextField( + verbose_name=_("Set Notes"), + blank=True, + help_text=_("Expression for notes to set"), + ) + set_internal_note = models.TextField( + verbose_name=_("Set Internal Note"), + blank=True, + help_text=_("Expression for internal note to set"), + ) + set_internal_id = models.TextField( + verbose_name=_("Set Internal ID"), + blank=True, + help_text=_("Expression for internal ID to set"), + ) + set_entities = models.TextField( + verbose_name=_("Set Entities"), + blank=True, + help_text=_("Expression for entities to set (list of IDs or names)"), + ) + set_category = models.TextField( + verbose_name=_("Set Category"), + blank=True, + help_text=_("Expression for category to set (ID or name)"), + ) + set_tags = models.TextField( + verbose_name=_("Set Tags"), + blank=True, + help_text=_("Expression for tags to set (list of IDs or names)"), + ) + + class Meta: + verbose_name = _("Update or create transaction action") + verbose_name_plural = _("Update or create transaction actions") + + def __str__(self): + return f"Update or create transaction action for {self.rule}" + + def build_search_query(self, simple): + """Builds Q objects based on search fields and their operators""" + search_query = Q() + + def add_to_query(field_name, value, operator): + if isinstance(value, (int, str)): + lookup = f"{field_name}__{operator}" + return Q(**{lookup: value}) + return Q() + + if self.search_account: + value = simple.eval(self.search_account) + if isinstance(value, int): + search_query &= add_to_query( + "account_id", value, self.search_account_operator + ) + else: + search_query &= add_to_query( + "account__name", value, self.search_account_operator + ) + + if self.search_type: + value = simple.eval(self.search_type) + search_query &= add_to_query("type", value, self.search_type_operator) + + if self.search_is_paid: + value = simple.eval(self.search_is_paid) + search_query &= add_to_query("is_paid", value, self.search_is_paid_operator) + + if self.search_date: + value = simple.eval(self.search_date) + search_query &= add_to_query("date", value, self.search_date_operator) + + if self.search_reference_date: + value = simple.eval(self.search_reference_date) + search_query &= add_to_query( + "reference_date", value, self.search_reference_date_operator + ) + + if self.search_amount: + value = simple.eval(self.search_amount) + search_query &= add_to_query("amount", value, self.search_amount_operator) + + if self.search_description: + value = simple.eval(self.search_description) + search_query &= add_to_query( + "description", value, self.search_description_operator + ) + + if self.search_notes: + value = simple.eval(self.search_notes) + search_query &= add_to_query("notes", value, self.search_notes_operator) + + if self.search_internal_note: + value = simple.eval(self.search_internal_note) + search_query &= add_to_query( + "internal_note", value, self.search_internal_note_operator + ) + + if self.search_internal_id: + value = simple.eval(self.search_internal_id) + search_query &= add_to_query( + "internal_id", value, self.search_internal_id_operator + ) + + if self.search_category: + value = simple.eval(self.search_category) + if isinstance(value, int): + search_query &= add_to_query( + "category_id", value, self.search_category_operator + ) + else: + search_query &= add_to_query( + "category__name", value, self.search_category_operator + ) + + if self.search_tags: + tags_value = simple.eval(self.search_tags) + if isinstance(tags_value, (list, tuple)): + for tag in tags_value: + if isinstance(tag, int): + search_query &= Q(tags__id=tag) + else: + search_query &= Q(tags__name__iexact=tag) + elif isinstance(tags_value, (int, str)): + if isinstance(tags_value, int): + search_query &= Q(tags__id=tags_value) + else: + search_query &= Q(tags__name__iexact=tags_value) + + if self.search_entities: + entities_value = simple.eval(self.search_entities) + if isinstance(entities_value, (list, tuple)): + for entity in entities_value: + if isinstance(entity, int): + search_query &= Q(entities__id=entity) + else: + search_query &= Q(entities__name__iexact=entity) + elif isinstance(entities_value, (int, str)): + if isinstance(entities_value, int): + search_query &= Q(entities__id=entities_value) + else: + search_query &= Q(entities__name__iexact=entities_value) + + return search_query diff --git a/app/apps/rules/tasks.py b/app/apps/rules/tasks.py index db8c63c..af54d76 100644 --- a/app/apps/rules/tasks.py +++ b/app/apps/rules/tasks.py @@ -1,4 +1,6 @@ +import decimal import logging +from datetime import datetime, date from cachalot.api import cachalot_disabled from dateutil.relativedelta import relativedelta @@ -6,7 +8,10 @@ from procrastinate.contrib.django import app from simpleeval import EvalWithCompoundTypes from apps.accounts.models import Account -from apps.rules.models import TransactionRule, TransactionRuleAction +from apps.rules.models import ( + TransactionRule, + TransactionRuleAction, +) from apps.transactions.models import ( Transaction, TransactionCategory, @@ -14,7 +19,6 @@ from apps.transactions.models import ( TransactionEntity, ) - logger = logging.getLogger(__name__) @@ -25,137 +29,332 @@ def check_for_transaction_rules( ): try: with cachalot_disabled(): - instance = Transaction.objects.get(id=instance_id) - context = { - "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, + functions = { + "relativedelta": relativedelta, + "str": str, + "int": int, + "float": float, + "decimal": decimal.Decimal, + "datetime": datetime, + "date": date, } - functions = {"relativedelta": relativedelta} - - simple = EvalWithCompoundTypes(names=context, functions=functions) + simple = EvalWithCompoundTypes( + names=_get_names(instance), functions=functions + ) if signal == "transaction_created": - rules = TransactionRule.objects.filter(active=True, on_create=True) + rules = TransactionRule.objects.filter( + active=True, on_create=True + ).order_by("id") elif signal == "transaction_updated": - rules = TransactionRule.objects.filter(active=True, on_update=True) + rules = TransactionRule.objects.filter( + active=True, on_update=True + ).order_by("id") else: - rules = TransactionRule.objects.filter(active=True) + rules = TransactionRule.objects.filter(active=True).order_by("id") for rule in rules: if simple.eval(rule.trigger): for action in rule.transaction_actions.all(): - 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(action.value), + try: + instance = _process_edit_transaction_action( + instance=instance, action=action, simple_eval=simple + ) + except Exception as e: + logger.error( + f"Error processing edit transaction action {action.id}", + exc_info=True, + ) + # else: + # simple.names.update(_get_names(instance)) + # instance.save() + + simple.names.update(_get_names(instance)) + instance.save() + + for action in rule.update_or_create_transaction_actions.all(): + try: + _process_update_or_create_transaction_action( + action=action, simple_eval=simple + ) + except Exception as e: + logger.error( + f"Error processing update or create transaction action {action.id}", + exc_info=True, ) - elif action.field == TransactionRuleAction.Field.account: - value = simple.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(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(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(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) - - instance.save() except Exception as e: logger.error( "Error while executing 'check_for_transaction_rules' task", exc_info=True, ) raise e + + +def _get_names(instance): + 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, + } + + +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) + + # Find latest matching transaction or create new + if search_query: + transaction = ( + Transaction.objects.filter(search_query).order_by("-date", "-id").first() + ) + else: + transaction = None + + 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 diff --git a/app/apps/rules/urls.py b/app/apps/rules/urls.py index 13b7e5f..b0f9661 100644 --- a/app/apps/rules/urls.py +++ b/app/apps/rules/urls.py @@ -38,18 +38,33 @@ urlpatterns = [ name="transaction_rule_delete", ), path( - "rules/transaction//action/add/", + "rules/transaction//transaction-action/add/", views.transaction_rule_action_add, name="transaction_rule_action_add", ), path( - "rules/transaction/action//edit/", + "rules/transaction/transaction-action//edit/", views.transaction_rule_action_edit, name="transaction_rule_action_edit", ), path( - "rules/transaction/action//delete/", + "rules/transaction/transaction-action//delete/", views.transaction_rule_action_delete, name="transaction_rule_action_delete", ), + path( + "rules/transaction//update-or-create-transaction-action/add/", + views.update_or_create_transaction_rule_action_add, + name="update_or_create_transaction_rule_action_add", + ), + path( + "rules/transaction/update-or-create-transaction-action//edit/", + views.update_or_create_transaction_rule_action_edit, + name="update_or_create_transaction_rule_action_edit", + ), + path( + "rules/transaction/update-or-create-transaction-action//delete/", + views.update_or_create_transaction_rule_action_delete, + name="update_or_create_transaction_rule_action_delete", + ), ] diff --git a/app/apps/rules/views.py b/app/apps/rules/views.py index 0d87d8c..0bd1892 100644 --- a/app/apps/rules/views.py +++ b/app/apps/rules/views.py @@ -6,8 +6,16 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_http_methods from apps.common.decorators.htmx import only_htmx -from apps.rules.forms import TransactionRuleForm, TransactionRuleActionForm -from apps.rules.models import TransactionRule, TransactionRuleAction +from apps.rules.forms import ( + TransactionRuleForm, + TransactionRuleActionForm, + UpdateOrCreateTransactionRuleActionForm, +) +from apps.rules.models import ( + TransactionRule, + TransactionRuleAction, + UpdateOrCreateTransactionRuleAction, +) @login_required @@ -60,10 +68,15 @@ def transaction_rule_add(request, **kwargs): if request.method == "POST": form = TransactionRuleForm(request.POST) if form.is_valid(): - instance = form.save() + form.save() messages.success(request, _("Rule added successfully")) - return redirect("transaction_rule_action_add", instance.id) + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas", + }, + ) else: form = TransactionRuleForm() @@ -215,3 +228,88 @@ def transaction_rule_action_delete(request, transaction_rule_action_id): "HX-Trigger": "updated, hide_offcanvas", }, ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def update_or_create_transaction_rule_action_add(request, transaction_rule_id): + transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id) + + if request.method == "POST": + form = UpdateOrCreateTransactionRuleActionForm( + request.POST, rule=transaction_rule + ) + if form.is_valid(): + form.save() + messages.success( + request, _("Update or Create Transaction action added successfully") + ) + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas", + }, + ) + else: + form = UpdateOrCreateTransactionRuleActionForm(rule=transaction_rule) + + return render( + request, + "rules/fragments/transaction_rule/update_or_create_transaction_rule_action/add.html", + {"form": form, "transaction_rule_id": transaction_rule_id}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def update_or_create_transaction_rule_action_edit(request, pk): + linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk) + transaction_rule = linked_action.rule + + if request.method == "POST": + form = UpdateOrCreateTransactionRuleActionForm( + request.POST, instance=linked_action, rule=transaction_rule + ) + if form.is_valid(): + form.save() + messages.success( + request, _("Update or Create Transaction action updated successfully") + ) + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas", + }, + ) + else: + form = UpdateOrCreateTransactionRuleActionForm( + instance=linked_action, rule=transaction_rule + ) + + return render( + request, + "rules/fragments/transaction_rule/update_or_create_transaction_rule_action/edit.html", + {"form": form, "action": linked_action}, + ) + + +@only_htmx +@login_required +@require_http_methods(["DELETE"]) +def update_or_create_transaction_rule_action_delete(request, pk): + linked_action = get_object_or_404(UpdateOrCreateTransactionRuleAction, id=pk) + + linked_action.delete() + + messages.success( + request, _("Update or Create Transaction action deleted successfully") + ) + + return HttpResponse( + status=204, + headers={ + "HX-Trigger": "updated, hide_offcanvas", + }, + ) diff --git a/app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/add.html b/app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/add.html new file mode 100644 index 0000000..401543b --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/add.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Add action to transaction rule' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/edit.html b/app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/edit.html new file mode 100644 index 0000000..863efbd --- /dev/null +++ b/app/templates/rules/fragments/transaction_rule/update_or_create_transaction_rule_action/edit.html @@ -0,0 +1,11 @@ +{% extends 'extends/offcanvas.html' %} +{% load i18n %} +{% load crispy_forms_tags %} + +{% block title %}{% translate 'Edit transaction rule action' %}{% endblock %} + +{% block body %} +
+ {% crispy form %} +
+{% endblock %} diff --git a/app/templates/rules/fragments/transaction_rule/view.html b/app/templates/rules/fragments/transaction_rule/view.html index eafc7c1..9cdbeb5 100644 --- a/app/templates/rules/fragments/transaction_rule/view.html +++ b/app/templates/rules/fragments/transaction_rule/view.html @@ -5,80 +5,121 @@ {% block title %}{% translate 'Transaction Rule' %}{% endblock %} {% block body %} -
-
{{ transaction_rule.name }}
-
{{ transaction_rule.description }}
-
-
-
{% translate 'If transaction...' %}
-
-
- {{ transaction_rule.trigger }} -
- -
-
{% translate 'Then...' %}
- {% for action in transaction_rule.transaction_actions.all %} -
-
-
-
{% translate 'Set' %}
-
{{ action.get_field_display }}
+
+
{% translate 'Then...' %}
+ {% for action in transaction_rule.transaction_actions.all %} +
+
+
{% trans 'Edit transaction' %}
+
+
+
{% translate 'Set' %} {{ action.get_field_display }} {% translate 'to' %}
+
{{ action.value }}
+
+ +
+ {% endfor %} + {% for action in transaction_rule.update_or_create_transaction_actions.all %} +
+
+
{% trans 'Update or create transaction' %}
+
+
+
{% trans 'Edit to view' %}
+
+ +
+ {% endfor %} + {% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %} +
+
+ {% translate 'This rule has no actions' %} +
+
+ {% endif %} +
+ -
-
{% translate 'to' %}
-
{{ action.value }}
-
-
-
- {% empty %} -
-
- {% translate 'This rule has no actions' %} -
-
- {% endfor %} -
- - {% translate 'Add new' %} - -
-
{% endblock %} From 942154480ed335d2f26b352ff98f79a3acea3c23 Mon Sep 17 00:00:00 2001 From: Herculino Trotta Date: Sat, 8 Feb 2025 04:16:28 -0300 Subject: [PATCH 2/2] feat(rules): add Update or Create Transaction action --- ...006_updateorcreatetransactionruleaction.py | 60 +++++++++++++++++++ ...etransactionruleaction_options_and_more.py | 22 +++++++ ...tionruleaction_search_entities_and_more.py | 33 ++++++++++ ..._alter_transactionrule_options_and_more.py | 25 ++++++++ 4 files changed, 140 insertions(+) create mode 100644 app/apps/rules/migrations/0006_updateorcreatetransactionruleaction.py create mode 100644 app/apps/rules/migrations/0007_alter_updateorcreatetransactionruleaction_options_and_more.py create mode 100644 app/apps/rules/migrations/0008_updateorcreatetransactionruleaction_search_entities_and_more.py create mode 100644 app/apps/rules/migrations/0009_alter_transactionrule_options_and_more.py diff --git a/app/apps/rules/migrations/0006_updateorcreatetransactionruleaction.py b/app/apps/rules/migrations/0006_updateorcreatetransactionruleaction.py new file mode 100644 index 0000000..c2d49fc --- /dev/null +++ b/app/apps/rules/migrations/0006_updateorcreatetransactionruleaction.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.5 on 2025-02-08 03:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rules', '0005_alter_transactionruleaction_rule'), + ] + + operations = [ + migrations.CreateModel( + name='UpdateOrCreateTransactionRuleAction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('search_account', models.TextField(blank=True, help_text='Expression to match transaction account (ID or name)', verbose_name='Search Account')), + ('search_account_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Account Operator')), + ('search_type', models.TextField(blank=True, help_text="Expression to match transaction type ('IN' or 'EX')", verbose_name='Search Type')), + ('search_type_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Type Operator')), + ('search_is_paid', models.TextField(blank=True, help_text='Expression to match transaction paid status', verbose_name='Search Is Paid')), + ('search_is_paid_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Is Paid Operator')), + ('search_date', models.TextField(blank=True, help_text='Expression to match transaction date', verbose_name='Search Date')), + ('search_date_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Date Operator')), + ('search_reference_date', models.TextField(blank=True, help_text='Expression to match transaction reference date', verbose_name='Search Reference Date')), + ('search_reference_date_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Reference Date Operator')), + ('search_amount', models.TextField(blank=True, help_text='Expression to match transaction amount', verbose_name='Search Amount')), + ('search_amount_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Amount Operator')), + ('search_description', models.TextField(blank=True, help_text='Expression to match transaction description', verbose_name='Search Description')), + ('search_description_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Description Operator')), + ('search_notes', models.TextField(blank=True, help_text='Expression to match transaction notes', verbose_name='Search Notes')), + ('search_notes_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Notes Operator')), + ('search_category', models.TextField(blank=True, help_text='Expression to match transaction category (ID or name)', verbose_name='Search Category')), + ('search_category_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Category Operator')), + ('search_internal_note', models.TextField(blank=True, help_text='Expression to match transaction internal note', verbose_name='Search Internal Note')), + ('search_internal_note_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Internal Note Operator')), + ('search_internal_id', models.TextField(blank=True, help_text='Expression to match transaction internal ID', verbose_name='Search Internal ID')), + ('search_internal_id_operator', models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Internal ID Operator')), + ('set_account', models.TextField(blank=True, help_text='Expression for account to set (ID or name)', verbose_name='Set Account')), + ('set_type', models.TextField(blank=True, help_text="Expression for type to set ('IN' or 'EX')", verbose_name='Set Type')), + ('set_is_paid', models.TextField(blank=True, help_text='Expression for paid status to set', verbose_name='Set Is Paid')), + ('set_date', models.TextField(blank=True, help_text='Expression for date to set', verbose_name='Set Date')), + ('set_reference_date', models.TextField(blank=True, help_text='Expression for reference date to set', verbose_name='Set Reference Date')), + ('set_amount', models.TextField(blank=True, help_text='Expression for amount to set', verbose_name='Set Amount')), + ('set_description', models.TextField(blank=True, help_text='Expression for description to set', verbose_name='Set Description')), + ('set_notes', models.TextField(blank=True, help_text='Expression for notes to set', verbose_name='Set Notes')), + ('set_internal_note', models.TextField(blank=True, help_text='Expression for internal note to set', verbose_name='Set Internal Note')), + ('set_internal_id', models.TextField(blank=True, help_text='Expression for internal ID to set', verbose_name='Set Internal ID')), + ('set_category', models.TextField(blank=True, help_text='Expression for category to set (ID or name)', verbose_name='Set Category')), + ('set_tags', models.TextField(blank=True, help_text='Expression for tags to set (list of IDs or names)', verbose_name='Set Tags')), + ('set_entities', models.TextField(blank=True, help_text='Expression for entities to set (list of IDs or names)', verbose_name='Set Entities')), + ('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='update_or_create_transaction_actions', to='rules.transactionrule', verbose_name='Rule')), + ], + options={ + 'verbose_name': 'pdate or Create Transaction Action', + 'verbose_name_plural': 'pdate or Create Transaction Action Actions', + }, + ), + ] diff --git a/app/apps/rules/migrations/0007_alter_updateorcreatetransactionruleaction_options_and_more.py b/app/apps/rules/migrations/0007_alter_updateorcreatetransactionruleaction_options_and_more.py new file mode 100644 index 0000000..ed27332 --- /dev/null +++ b/app/apps/rules/migrations/0007_alter_updateorcreatetransactionruleaction_options_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.5 on 2025-02-08 04:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rules', '0006_updateorcreatetransactionruleaction'), + ] + + operations = [ + migrations.AlterModelOptions( + name='updateorcreatetransactionruleaction', + options={'verbose_name': 'Update or Create Transaction Action', 'verbose_name_plural': 'Update or Create Transaction Action Actions'}, + ), + migrations.AddField( + model_name='updateorcreatetransactionruleaction', + name='filter', + field=models.TextField(blank=True, help_text='Generic expression to enable or disable execution. Should evaluate to True or False', verbose_name='Filter'), + ), + ] diff --git a/app/apps/rules/migrations/0008_updateorcreatetransactionruleaction_search_entities_and_more.py b/app/apps/rules/migrations/0008_updateorcreatetransactionruleaction_search_entities_and_more.py new file mode 100644 index 0000000..73a0bfd --- /dev/null +++ b/app/apps/rules/migrations/0008_updateorcreatetransactionruleaction_search_entities_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.5 on 2025-02-08 06:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('rules', '0007_alter_updateorcreatetransactionruleaction_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='updateorcreatetransactionruleaction', + name='search_entities', + field=models.TextField(blank=True, help_text='Expression to match transaction entities (list of IDs or names)', verbose_name='Search Entities'), + ), + migrations.AddField( + model_name='updateorcreatetransactionruleaction', + name='search_entities_operator', + field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Entities Operator'), + ), + migrations.AddField( + model_name='updateorcreatetransactionruleaction', + name='search_tags', + field=models.TextField(blank=True, help_text='Expression to match transaction tags (list of IDs or names)', verbose_name='Search Tags'), + ), + migrations.AddField( + model_name='updateorcreatetransactionruleaction', + name='search_tags_operator', + field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='contains', max_length=10, verbose_name='Tags Operator'), + ), + ] diff --git a/app/apps/rules/migrations/0009_alter_transactionrule_options_and_more.py b/app/apps/rules/migrations/0009_alter_transactionrule_options_and_more.py new file mode 100644 index 0000000..7ec3046 --- /dev/null +++ b/app/apps/rules/migrations/0009_alter_transactionrule_options_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.5 on 2025-02-08 06:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('rules', '0008_updateorcreatetransactionruleaction_search_entities_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='transactionrule', + options={'verbose_name': 'Transaction rule', 'verbose_name_plural': 'Transaction rules'}, + ), + migrations.AlterModelOptions( + name='transactionruleaction', + options={'verbose_name': 'Edit transaction action', 'verbose_name_plural': 'Edit transaction actions'}, + ), + migrations.AlterModelOptions( + name='updateorcreatetransactionruleaction', + options={'verbose_name': 'Update or create transaction action', 'verbose_name_plural': 'Update or create transaction actions'}, + ), + ]