mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 08:54:52 +01:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e93969c035 | ||
|
|
6ec5b5df1e | ||
|
|
93e7adeea8 | ||
|
|
37b5a43c1f | ||
|
|
87a07c25d1 | ||
|
|
9e27fef5e5 | ||
|
|
2cbba53e06 | ||
|
|
d9e8be7efb | ||
|
|
7dc9ef9950 | ||
|
|
00e83cf6a2 | ||
|
|
039242b48a | ||
|
|
94e2bdf93d | ||
|
|
79b387ce60 | ||
|
|
43eb87d3ba | ||
|
|
0110220b72 | ||
|
|
f5c86f3d97 | ||
|
|
7b7f58d34d | ||
|
|
86112931d9 | ||
|
|
e6e0e4caea | ||
|
|
942154480e | ||
|
|
467131d9f1 | ||
|
|
fee1db8660 | ||
|
|
4f7fc1c9c8 |
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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}, "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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'},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
46
app/import_presets/cajamar/config.yml
Normal file
46
app/import_presets/cajamar/config.yml
Normal 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
|
||||
7
app/import_presets/cajamar/manifest.json
Normal file
7
app/import_presets/cajamar/manifest.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user