Compare commits

...

23 Commits
0.9.0 ... 0.9.2

Author SHA1 Message Date
Herculino Trotta
e93969c035 Merge pull request #151
feat(import:v1): add XLS and XLSX support
2025-02-09 00:51:46 -03:00
Herculino Trotta
6ec5b5df1e feat(import:v1): add XLS and XLSX support
Closes #47
2025-02-09 00:51:26 -03:00
Herculino Trotta
93e7adeea8 Merge pull request #150 from eitchtee/dev
feat(import): add Cajamar preset
2025-02-09 00:50:38 -03:00
Herculino Trotta
37b5a43c1f feat(import): add Cajamar preset
Thanks to Pablo Hinojosa for sharing his file
2025-02-09 00:50:11 -03:00
Herculino Trotta
87a07c25d1 Merge pull request #149
feat(import:v1): add "add" and "subtract" transformations
2025-02-08 18:30:25 -03:00
Herculino Trotta
9e27fef5e5 feat(import:v1): add "add" and "subtract" transformations 2025-02-08 18:30:06 -03:00
Herculino Trotta
2cbba53e06 Merge pull request #148
feat(import:v1): allow to source previously mapped data by prefixing it with "__" on transformations
2025-02-08 16:38:57 -03:00
Herculino Trotta
d9e8be7efb feat(import:v1): allow to source previously mapped data by prefixing it with "__" on transformations 2025-02-08 16:38:36 -03:00
Herculino Trotta
7dc9ef9950 Merge pull request #147 from eitchtee/dev
refactor(import:v1): remove forced "required" from some fields
2025-02-08 16:36:48 -03:00
Herculino Trotta
00e83cf6a2 refactor(import:v1): remove forced "required" from some fields 2025-02-08 16:35:46 -03:00
Herculino Trotta
039242b48a Merge pull request #146 from eitchtee/dev
fix(dev): django-browser-reload not working
2025-02-08 16:01:06 -03:00
Herculino Trotta
94e2bdf93d fix(dev): django-browser-reload not working 2025-02-08 16:00:45 -03:00
Herculino Trotta
79b387ce60 Merge pull request #145 from eitchtee/dev
feat(import:v1): allow to source previously mapped data by prefixing it with "__"
2025-02-08 15:59:56 -03:00
Herculino Trotta
43eb87d3ba feat(import:v1): allow to source previously mapped data by prefixing it with "__" 2025-02-08 15:59:27 -03:00
Herculino Trotta
0110220b72 Merge pull request #144 from eitchtee/dev
feat: account and currency cards will no longer display unneeded zeros, only for totals
2025-02-08 11:43:24 -03:00
Herculino Trotta
f5c86f3d97 feat: account and currency cards will no longer display unneeded zeros, only for totals 2025-02-08 11:42:46 -03:00
Herculino Trotta
7b7f58d34d Merge pull request #143 from eitchtee/dev
fix(logging): procrastinate job logs not showing up
2025-02-08 04:19:03 -03:00
Herculino Trotta
86112931d9 fix(logging): procrastinate job logs not showing up 2025-02-08 04:18:33 -03:00
Herculino Trotta
e6e0e4caea Merge pull request #142
feat(rules): add Update or Create Transaction action
2025-02-08 04:18:00 -03:00
Herculino Trotta
942154480e feat(rules): add Update or Create Transaction action 2025-02-08 04:17:28 -03:00
Herculino Trotta
467131d9f1 feat(rules): add Update or Create Transaction action 2025-02-08 04:16:28 -03:00
Herculino Trotta
fee1db8660 Merge pull request #141
fix(automatic-exchange-rates): skipping hours due to minutes
2025-02-07 14:34:58 -03:00
Herculino Trotta
4f7fc1c9c8 fix(automatic-exchange-rates): skipping hours due to minutes 2025-02-07 14:34:38 -03:00
26 changed files with 1760 additions and 278 deletions

View File

@@ -1,19 +0,0 @@
import logging
class ProcrastinateFilter(logging.Filter):
# from https://github.com/madzak/python-json-logger/blob/master/src/pythonjsonlogger/jsonlogger.py#L19
_reserved_log_keys = frozenset(
"""args asctime created exc_info exc_text filename
funcName levelname levelno lineno module msecs message msg name pathname
process processName relativeCreated stack_info thread threadName""".split()
)
def filter(self, record: logging.LogRecord):
record.procrastinate = {}
for key, value in vars(record).items():
if not key.startswith("_") and key not in self._reserved_log_keys | {
"procrastinate"
}:
record.procrastinate[key] = value # type: ignore
return True

View File

@@ -75,6 +75,7 @@ INSTALLED_APPS = [
]
MIDDLEWARE = [
"django_browser_reload.middleware.BrowserReloadMiddleware",
"apps.common.middleware.thread_local.ThreadLocalMiddleware",
"debug_toolbar.middleware.DebugToolbarMiddleware",
"django.middleware.security.SecurityMiddleware",
@@ -87,7 +88,6 @@ MIDDLEWARE = [
"apps.common.middleware.localization.LocalizationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_browser_reload.middleware.BrowserReloadMiddleware",
"hijack.middleware.HijackUserMiddleware",
]
@@ -277,27 +277,16 @@ if "procrastinate" in sys.argv:
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"procrastinate": {
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s -> %(procrastinate)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"standard": {
"format": "[%(asctime)s] - %(levelname)s - %(name)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
},
"filters": {
"procrastinate": {
"()": "WYGIWYH.logs.ProcrastinateFilter.ProcrastinateFilter",
"name": "procrastinate",
},
},
"handlers": {
"procrastinate": {
"level": "INFO",
"class": "logging.StreamHandler",
"formatter": "procrastinate",
"filters": ["procrastinate"],
"formatter": "standard",
},
"console": {
"class": "logging.StreamHandler",
@@ -308,10 +297,10 @@ if "procrastinate" in sys.argv:
"loggers": {
"procrastinate": {
"handlers": ["procrastinate"],
"propagate": True,
"propagate": False,
},
"root": {
"handlers": None,
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},

View File

@@ -31,7 +31,7 @@ class ExchangeRateFetcher:
service.fetch_interval
)
should_fetch = current_hour not in blocked_hours
logger.debug(
logger.info(
f"NOT_ON check for {service.name}: "
f"current_hour={current_hour}, "
f"blocked_hours={blocked_hours}, "
@@ -43,18 +43,35 @@ class ExchangeRateFetcher:
allowed_hours = ExchangeRateService._parse_hour_ranges(
service.fetch_interval
)
return current_hour in allowed_hours
should_fetch = current_hour in allowed_hours
logger.info(
f"ON check for {service.name}: "
f"current_hour={current_hour}, "
f"allowed_hours={allowed_hours}, "
f"should_fetch={should_fetch}"
)
return should_fetch
if service.interval_type == ExchangeRateService.IntervalType.EVERY:
try:
interval_hours = int(service.fetch_interval)
if service.last_fetch is None:
return True
hours_since_last = (
timezone.now() - service.last_fetch
).total_seconds() / 3600
# Round down to nearest hour
now = timezone.now().replace(minute=0, second=0, microsecond=0)
last_fetch = service.last_fetch.replace(
minute=0, second=0, microsecond=0
)
hours_since_last = (now - last_fetch).total_seconds() / 3600
should_fetch = hours_since_last >= interval_hours
logger.debug(
logger.info(
f"EVERY check for {service.name}: "
f"hours_since_last={hours_since_last:.1f}, "
f"interval={interval_hours}, "

View File

@@ -47,6 +47,34 @@ class SplitTransformationRule(BaseModel):
)
class AddTransformationRule(BaseModel):
type: Literal["add"]
field: str = Field(..., description="Field to add to the source value")
absolute_values: bool = Field(
default=False, description="Use absolute values for addition"
)
thousand_separator: str = Field(
default="", description="Thousand separator character"
)
decimal_separator: str = Field(
default=".", description="Decimal separator character"
)
class SubtractTransformationRule(BaseModel):
type: Literal["subtract"]
field: str = Field(..., description="Field to subtract from the source value")
absolute_values: bool = Field(
default=False, description="Use absolute values for subtraction"
)
thousand_separator: str = Field(
default="", description="Thousand separator character"
)
decimal_separator: str = Field(
default=".", description="Decimal separator character"
)
class CSVImportSettings(BaseModel):
skip_errors: bool = Field(
default=False,
@@ -64,6 +92,20 @@ class CSVImportSettings(BaseModel):
]
class ExcelImportSettings(BaseModel):
skip_errors: bool = Field(
default=False,
description="If True, errors during import will be logged and skipped",
)
file_type: Literal["xls", "xlsx"]
trigger_transaction_rules: bool = True
importing: Literal[
"transactions", "accounts", "currencies", "categories", "tags", "entities"
]
start_row: int = Field(default=1, description="Where your header is located")
sheets: list[str] | str = "*"
class ColumnMapping(BaseModel):
source: Optional[str] | Optional[list[str]] = Field(
default=None,
@@ -78,6 +120,8 @@ class ColumnMapping(BaseModel):
| HashTransformationRule
| MergeTransformationRule
| SplitTransformationRule
| AddTransformationRule
| SubtractTransformationRule
]
] = Field(default_factory=list)
@@ -86,7 +130,6 @@ class TransactionAccountMapping(ColumnMapping):
target: Literal["account"] = Field(..., description="Transaction field to map to")
type: Literal["id", "name"] = "name"
coerce_to: Literal["str|int"] = Field("str|int", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionTypeMapping(ColumnMapping):
@@ -105,7 +148,6 @@ class TransactionDateMapping(ColumnMapping):
target: Literal["date"] = Field(..., description="Transaction field to map to")
format: List[str] | str
coerce_to: Literal["date"] = Field("date", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionReferenceDateMapping(ColumnMapping):
@@ -119,7 +161,6 @@ class TransactionReferenceDateMapping(ColumnMapping):
class TransactionAmountMapping(ColumnMapping):
target: Literal["amount"] = Field(..., description="Transaction field to map to")
coerce_to: Literal["positive_decimal"] = Field("positive_decimal", frozen=True)
required: bool = Field(True, frozen=True)
class TransactionDescriptionMapping(ColumnMapping):
@@ -301,7 +342,7 @@ class CurrencyExchangeMapping(ColumnMapping):
class ImportProfileSchema(BaseModel):
settings: CSVImportSettings
settings: CSVImportSettings | ExcelImportSettings
mapping: Dict[
str,
TransactionAccountMapping

View File

@@ -3,14 +3,16 @@ import hashlib
import logging
import os
import re
from datetime import datetime
from decimal import Decimal
from datetime import datetime, date
from decimal import Decimal, InvalidOperation
from typing import Dict, Any, Literal, Union
import cachalot.api
import openpyxl
import xlrd
import yaml
from cachalot.api import cachalot_disabled
from django.utils import timezone
from openpyxl.utils.exceptions import InvalidFileException
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
@@ -40,7 +42,9 @@ class ImportService:
self.import_run: ImportRun = import_run
self.profile: ImportProfile = import_run.profile
self.config: version_1.ImportProfileSchema = self._load_config()
self.settings: version_1.CSVImportSettings = self.config.settings
self.settings: version_1.CSVImportSettings | version_1.ExcelImportSettings = (
self.config.settings
)
self.deduplication: list[version_1.CompareDeduplicationRule] = (
self.config.deduplication
)
@@ -75,6 +79,13 @@ class ImportService:
self.import_run.logs += log_line
self.import_run.save(update_fields=["logs"])
if level == "info":
logger.info(log_line)
elif level == "warning":
logger.warning(log_line)
elif level == "error":
logger.error(log_line, exc_info=True)
def _update_totals(
self,
field: Literal["total", "processed", "successful", "skipped", "failed"],
@@ -129,9 +140,12 @@ class ImportService:
self.import_run.save(update_fields=["status"])
@staticmethod
def _transform_value(
value: str, mapping: version_1.ColumnMapping, row: Dict[str, str] = None
self,
value: str,
mapping: version_1.ColumnMapping,
row: Dict[str, str] = None,
mapped_data: Dict[str, Any] = None,
) -> Any:
transformed = value
@@ -142,8 +156,12 @@ class ImportService:
for field in transform.fields:
if field in row:
values_to_hash.append(str(row[field]))
# Create hash from concatenated values
elif (
field.startswith("__")
and mapped_data
and field[2:] in mapped_data
):
values_to_hash.append(str(mapped_data[field[2:]]))
if values_to_hash:
concatenated = "|".join(values_to_hash)
transformed = hashlib.sha256(concatenated.encode()).hexdigest()
@@ -157,6 +175,7 @@ class ImportService:
transformed = transformed.replace(
transform.pattern, transform.replacement
)
elif transform.type == "regex":
if transform.exclusive:
transformed = re.sub(
@@ -166,16 +185,25 @@ class ImportService:
transformed = re.sub(
transform.pattern, transform.replacement, transformed
)
elif transform.type == "date_format":
transformed = datetime.strptime(
transformed, transform.original_format
).strftime(transform.new_format)
elif transform.type == "merge":
values_to_merge = []
for field in transform.fields:
if field in row:
values_to_merge.append(str(row[field]))
elif (
field.startswith("__")
and mapped_data
and field[2:] in mapped_data
):
values_to_merge.append(str(mapped_data[field[2:]]))
transformed = transform.separator.join(values_to_merge)
elif transform.type == "split":
parts = transformed.split(transform.separator)
if transform.index is not None:
@@ -183,6 +211,38 @@ class ImportService:
else:
transformed = parts
elif transform.type in ["add", "subtract"]:
try:
source_value = Decimal(transformed)
# First check row data, then mapped data if not found
field_value = row.get(transform.field)
if field_value is None and transform.field.startswith("__"):
field_value = mapped_data.get(transform.field[2:])
if field_value is None:
raise KeyError(
f"Field '{transform.field}' not found in row or mapped data"
)
field_value = self._prepare_numeric_value(
str(field_value),
transform.thousand_separator,
transform.decimal_separator,
)
if transform.absolute_values:
source_value = abs(source_value)
field_value = abs(field_value)
if transform.type == "add":
transformed = str(source_value + field_value)
else: # subtract
transformed = str(source_value - field_value)
except (InvalidOperation, KeyError, AttributeError) as e:
logger.warning(
f"Error in {transform.type} transformation: {e}. Values: {transformed}, {transform.field}"
)
return transformed
def _create_transaction(self, data: Dict[str, Any]) -> Transaction:
@@ -399,7 +459,7 @@ class ImportService:
def _coerce_type(
self, value: str, mapping: version_1.ColumnMapping
) -> Union[str, int, bool, Decimal, datetime, list]:
) -> Union[str, int, bool, Decimal, datetime, list, None]:
if not value:
return None
@@ -434,6 +494,11 @@ class ImportService:
version_1.TransactionReferenceDateMapping,
),
):
if isinstance(value, datetime):
return value.date()
elif isinstance(value, date):
return value
formats = (
mapping.format
if isinstance(mapping.format, list)
@@ -484,28 +549,30 @@ class ImportService:
def _map_row(self, row: Dict[str, str]) -> Dict[str, Any]:
mapped_data = {}
for field, mapping in self.mapping.items():
value = None
if isinstance(mapping.source, str):
value = row.get(mapping.source, None)
if mapping.source in row:
value = row[mapping.source]
elif (
mapping.source.startswith("__")
and mapping.source[2:] in mapped_data
):
value = mapped_data[mapping.source[2:]]
elif isinstance(mapping.source, list):
for source in mapping.source:
value = row.get(source, None)
if value:
if source in row:
value = row[source]
break
elif source.startswith("__") and source[2:] in mapped_data:
value = mapped_data[source[2:]]
break
else:
# If source is None, use None as the initial value
value = None
# Use default_value if value is None
if not value:
if value is None:
value = mapping.default
# Apply transformations
if mapping.transformations:
value = self._transform_value(value, mapping, row)
value = self._transform_value(value, mapping, row, mapped_data)
value = self._coerce_type(value, mapping)
@@ -513,17 +580,29 @@ class ImportService:
raise ValueError(f"Required field {field} is missing")
if value is not None:
# Remove the prefix from the target field
target = mapping.target
if self.settings.importing == "transactions":
mapped_data[target] = value
else:
# Remove the model prefix (e.g., "account_" from "account_name")
field_name = target.split("_", 1)[1]
mapped_data[field_name] = value
return mapped_data
@staticmethod
def _prepare_numeric_value(
value: str, thousand_separator: str, decimal_separator: str
) -> Decimal:
# Remove thousand separators
if thousand_separator:
value = value.replace(thousand_separator, "")
# Replace decimal separator with dot
if decimal_separator != ".":
value = value.replace(decimal_separator, ".")
return Decimal(value)
def _process_row(self, row: Dict[str, str], row_number: int) -> None:
try:
mapped_data = self._map_row(row)
@@ -589,6 +668,151 @@ class ImportService:
for row_number, row in enumerate(reader, start=1):
self._process_row(row, row_number)
def _process_excel(self, file_path):
try:
if self.settings.file_type == "xlsx":
workbook = openpyxl.load_workbook(
file_path, read_only=True, data_only=True
)
sheets_to_process = (
workbook.sheetnames
if self.settings.sheets == "*"
else (
self.settings.sheets
if isinstance(self.settings.sheets, list)
else [self.settings.sheets]
)
)
# Calculate total rows
total_rows = sum(
max(0, workbook[sheet_name].max_row - self.settings.start_row)
for sheet_name in sheets_to_process
if sheet_name in workbook.sheetnames
)
self._update_totals("total", value=total_rows)
# Process sheets
for sheet_name in sheets_to_process:
if sheet_name not in workbook.sheetnames:
self._log(
"warning",
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
)
continue
sheet = workbook[sheet_name]
self._log("info", f"Processing sheet: {sheet_name}")
headers = [
str(cell.value or "") for cell in sheet[self.settings.start_row]
]
for row_number, row in enumerate(
sheet.iter_rows(
min_row=self.settings.start_row + 1, values_only=True
),
start=1,
):
try:
row_data = {
key: str(value) if value is not None else None
for key, value in zip(headers, row)
}
self._process_row(row_data, row_number)
except Exception as e:
if self.settings.skip_errors:
self._log(
"warning",
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
)
self._increment_totals("failed", value=1)
else:
raise
workbook.close()
else: # xls
workbook = xlrd.open_workbook(file_path)
sheets_to_process = (
workbook.sheet_names()
if self.settings.sheets == "*"
else (
self.settings.sheets
if isinstance(self.settings.sheets, list)
else [self.settings.sheets]
)
)
# Calculate total rows
total_rows = sum(
max(
0,
workbook.sheet_by_name(sheet_name).nrows
- self.settings.start_row,
)
for sheet_name in sheets_to_process
if sheet_name in workbook.sheet_names()
)
self._update_totals("total", value=total_rows)
# Process sheets
for sheet_name in sheets_to_process:
if sheet_name not in workbook.sheet_names():
self._log(
"warning",
f"Sheet '{sheet_name}' not found in the Excel file. Skipping.",
)
continue
sheet = workbook.sheet_by_name(sheet_name)
self._log("info", f"Processing sheet: {sheet_name}")
headers = [
str(sheet.cell_value(self.settings.start_row - 1, col) or "")
for col in range(sheet.ncols)
]
for row_number in range(self.settings.start_row, sheet.nrows):
try:
row_data = {}
for col, key in enumerate(headers):
cell_type = sheet.cell_type(row_number, col)
cell_value = sheet.cell_value(row_number, col)
if cell_type == xlrd.XL_CELL_DATE:
# Convert Excel date to Python datetime
try:
python_date = datetime(
*xlrd.xldate_as_tuple(
cell_value, workbook.datemode
)
)
row_data[key] = python_date
except Exception:
# If date conversion fails, use the original value
row_data[key] = (
str(cell_value)
if cell_value is not None
else None
)
elif cell_value is None:
row_data[key] = None
else:
row_data[key] = str(cell_value)
self._process_row(
row_data, row_number - self.settings.start_row + 1
)
except Exception as e:
if self.settings.skip_errors:
self._log(
"warning",
f"Error processing row {row_number} in sheet '{sheet_name}': {str(e)}",
)
self._increment_totals("failed", value=1)
else:
raise
except (InvalidFileException, xlrd.XLRDError) as e:
raise ValueError(
f"Invalid {self.settings.file_type.upper()} file format: {str(e)}"
)
def _validate_file_path(self, file_path: str) -> str:
"""
Validates that the file path is within the allowed temporary directory.
@@ -611,8 +835,10 @@ class ImportService:
self._log("info", "Starting import process")
try:
if self.settings.file_type == "csv":
if isinstance(self.settings, version_1.CSVImportSettings):
self._process_csv(file_path)
elif isinstance(self.settings, version_1.ExcelImportSettings):
self._process_excel(file_path)
self._update_status("FINISHED")
self._log(
@@ -639,4 +865,3 @@ class ImportService:
self.import_run.finished_at = timezone.now()
self.import_run.save(update_fields=["finished_at"])
cachalot.api.invalidate()

View File

@@ -1,6 +1,5 @@
import logging
import cachalot.api
from procrastinate.contrib.django import app
from apps.import_app.models import ImportRun
@@ -15,7 +14,5 @@ def process_import(import_run_id: int, file_path: str):
import_run = ImportRun.objects.get(id=import_run_id)
import_service = ImportServiceV1(import_run)
import_service.process_file(file_path)
cachalot.api.invalidate()
except ImportRun.DoesNotExist:
cachalot.api.invalidate()
raise ValueError(f"ImportRun with id {import_run_id} not found")

View File

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

View File

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

View File

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

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

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

View File

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

View File

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

View File

@@ -38,18 +38,33 @@ urlpatterns = [
name="transaction_rule_delete",
),
path(
"rules/transaction/<int:transaction_rule_id>/action/add/",
"rules/transaction/<int:transaction_rule_id>/transaction-action/add/",
views.transaction_rule_action_add,
name="transaction_rule_action_add",
),
path(
"rules/transaction/action/<int:transaction_rule_action_id>/edit/",
"rules/transaction/transaction-action/<int:transaction_rule_action_id>/edit/",
views.transaction_rule_action_edit,
name="transaction_rule_action_edit",
),
path(
"rules/transaction/action/<int:transaction_rule_action_id>/delete/",
"rules/transaction/transaction-action/<int:transaction_rule_action_id>/delete/",
views.transaction_rule_action_delete,
name="transaction_rule_action_delete",
),
path(
"rules/transaction/<int:transaction_rule_id>/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/<int:pk>/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/<int:pk>/delete/",
views.update_or_create_transaction_rule_action_delete,
name="update_or_create_transaction_rule_action_delete",
),
]

View File

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

View File

@@ -0,0 +1,46 @@
settings:
file_type: xls
skip_errors: true
trigger_transaction_rules: true
importing: transactions
start_row: 1
sheets: "*"
mapping:
account:
target: account
default: "<TU NOMBRE DE CUENTA>"
type: name
type:
source: Importe
target: type
detection_method: sign
internal_id:
target: internal_id
transformations:
- type: hash
fields: ["Fecha", "Concepto", "Importe", "Saldo"]
date:
source: "Fecha"
target: date
format: "%d-%m-%Y"
description:
source: Concepto
target: description
amount:
source: Importe
target: amount
is_paid:
target: is_paid
detection_method: always_paid
deduplication:
- type: compare
fields:
- internal_id
match_type: strict

View File

@@ -0,0 +1,7 @@
{
"author": "eitchtee,Pablo Hinojosa",
"description": "Importe sus movimientos desde su cuenta de Cajamar",
"schema_version": 1,
"name": "Grupo Cooperativo Cajamar",
"message": "Cambia '<TU NOMBRE DE CUENTA>' por el nombre de tu cuenta de Cajamar dentro de WYGIWYH"
}

View File

@@ -15,6 +15,7 @@
<div class="tw-text-gray-400">{% translate 'projected income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
{% if account.income_projected != 0 %}
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_projected"
@@ -22,6 +23,9 @@
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
{% if account.exchanged and account.exchanged.income_projected %}
<div class="text-end font-monospace tw-text-gray-500">
@@ -38,6 +42,7 @@
</div>
<div class="dotted-line flex-grow-1"></div>
<div>
{% if account.expense_projected != 0 %}
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_projected"
@@ -45,6 +50,9 @@
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
</div>
{% if account.exchanged and account.exchanged.expense_projected %}
@@ -86,6 +94,7 @@
<div class="tw-text-gray-400">{% translate 'current income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
{% if account.income_current != 0 %}
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="account.income_current"
@@ -93,6 +102,9 @@
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
{% if account.exchanged and account.exchanged.income_current %}
<div class="text-end font-monospace tw-text-gray-500">
@@ -108,6 +120,7 @@
<div class="tw-text-gray-400">{% translate 'current expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
{% if account.expense_current != 0 %}
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="account.expense_current"
@@ -115,6 +128,9 @@
:suffix="account.currency.suffix"
:decimal_places="account.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
{% if account.exchanged and account.exchanged.expense_current %}
<div class="text-end font-monospace tw-text-gray-500">
@@ -130,8 +146,7 @@
<div class="tw-text-gray-400">{% translate 'current total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<div class="text-end font-monospace">
<c-amount.display
:amount="account.total_current"
:prefix="account.currency.prefix"

View File

@@ -10,6 +10,7 @@
<div class="tw-text-gray-400">{% translate 'projected income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
{% if currency.income_projected != 0 %}
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="currency.income_projected"
@@ -17,6 +18,9 @@
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
{% if currency.exchanged and currency.exchanged.income_projected %}
<div class="text-end font-monospace tw-text-gray-500">
@@ -33,6 +37,7 @@
</div>
<div class="dotted-line flex-grow-1"></div>
<div>
{% if currency.expense_projected != 0 %}
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="currency.expense_projected"
@@ -40,6 +45,9 @@
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
</div>
{% if currency.exchanged and currency.exchanged.expense_projected %}
@@ -56,8 +64,7 @@
<div class="tw-text-gray-400">{% translate 'projected total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<div class="text-end font-monospace">
<c-amount.display
:amount="currency.total_projected"
:prefix="currency.currency.prefix"
@@ -81,6 +88,7 @@
<div class="tw-text-gray-400">{% translate 'current income' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
{% if currency.income_current != 0 %}
<div class="text-end font-monospace tw-text-green-400">
<c-amount.display
:amount="currency.income_current"
@@ -88,6 +96,9 @@
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
{% if currency.exchanged and currency.exchanged.income_current %}
<div class="text-end font-monospace tw-text-gray-500">
@@ -103,6 +114,7 @@
<div class="tw-text-gray-400">{% translate 'current expenses' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
{% if currency.expense_current != 0 %}
<div class="text-end font-monospace tw-text-red-400">
<c-amount.display
:amount="currency.expense_current"
@@ -110,6 +122,9 @@
:suffix="currency.currency.suffix"
:decimal_places="currency.currency.decimal_places"></c-amount.display>
</div>
{% else %}
<div class="text-end font-monospace">-</div>
{% endif %}
</div>
{% if currency.exchanged and currency.exchanged.expense_current %}
<div class="text-end font-monospace tw-text-gray-500">
@@ -125,8 +140,7 @@
<div class="tw-text-gray-400">{% translate 'current total' %}</div>
</div>
<div class="dotted-line flex-grow-1"></div>
<div
class="text-end font-monospace">
<div class="text-end font-monospace">
<c-amount.display
:amount="currency.total_current"
:prefix="currency.currency.prefix"

View File

@@ -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 %}
<form hx-post="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule_id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% translate 'Edit transaction rule action' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}

View File

@@ -5,80 +5,121 @@
{% block title %}{% translate 'Transaction Rule' %}{% endblock %}
{% block body %}
<div hx-get="{% url 'transaction_rule_view' transaction_rule_id=transaction_rule.id %}" hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading">
<div class="tw-text-2xl">{{ transaction_rule.name }}</div>
<div class="tw-text-base tw-text-gray-400">{{ transaction_rule.description }}</div>
<hr>
<div class="my-3">
<div class="tw-text-xl">{% translate 'If transaction...' %}</div>
<div class="card">
<div class="card-body">
{{ transaction_rule.trigger }}
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_edit' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">
<div hx-get="{% url 'transaction_rule_view' transaction_rule_id=transaction_rule.id %}"
hx-trigger="updated from:window" hx-target="closest .offcanvas" class="show-loading">
<div class="tw-text-2xl">{{ transaction_rule.name }}</div>
<div class="tw-text-base tw-text-gray-400">{{ transaction_rule.description }}</div>
<hr>
<div class="my-3">
<div class="tw-text-xl mb-2">{% translate 'If transaction...' %}</div>
<div class="card">
<div class="card-body">
{{ transaction_rule.trigger }}
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_edit' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="my-3">
<div class="tw-text-xl">{% translate 'Then...' %}</div>
{% for action in transaction_rule.transaction_actions.all %}
<div class="card mb-3">
<div class="card-body">
<div class="card mb-3">
<div class="card-header">{% translate 'Set' %}</div>
<div class="card-body">{{ action.get_field_display }}</div>
<div class="my-3">
<div class="tw-text-xl mb-2">{% translate 'Then...' %}</div>
{% for action in transaction_rule.transaction_actions.all %}
<div class="card mb-3">
<div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div>
</div>
<div class="card-body">
<div>{% translate 'Set' %} <span
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div>
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
{% endfor %}
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
<div class="card mb-3">
<div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div>
</div>
<div class="card-body">
<div>{% trans 'Edit to view' %}</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
{% endfor %}
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %}
<div class="card">
<div class="card-body">
{% translate 'This rule has no actions' %}
</div>
</div>
{% endif %}
<hr>
<div class="dropdown">
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
</button>
<ul class="dropdown-menu dropdown-menu-end w-100">
<li><a class="dropdown-item" role="link"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
<li><a class="dropdown-item" role="link"
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
</ul>
</div>
<div class="card mb-3">
<div class="card-header">{% translate 'to' %}</div>
<div class="card-body">{{ action.value }}</div>
</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw-text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
{% empty %}
<div class="card">
<div class="card-body">
{% translate 'This rule has no actions' %}
</div>
</div>
{% endfor %}
<hr>
<a class="btn btn-outline-primary text-decoration-none w-100"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
role="button"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
</a>
</div>
</div>
{% endblock %}

View File

@@ -12,4 +12,4 @@ done
rm -f /tmp/migrations_complete
exec python manage.py procrastinate worker
exec watchfiles --filter python "python manage.py procrastinate worker"

View File

@@ -27,3 +27,5 @@ simpleeval~=1.0.0
pydantic~=2.10.5
PyYAML~=6.0.2
mistune~=3.1.1
openpyxl~=3.1
xlrd~=2.0