mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-02-25 00:44:52 +01:00
Compare commits
144 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba47f0eed | ||
|
|
2010ccc92d | ||
|
|
d73d6cbf22 | ||
|
|
e5a9b6e921 | ||
|
|
dbd9774681 | ||
|
|
5a93a907e1 | ||
|
|
e0e159166b | ||
|
|
6c7594ad14 | ||
|
|
d3ea0e43da | ||
|
|
dde75416ca | ||
|
|
c9b346b791 | ||
|
|
9896044a15 | ||
|
|
eb65eb4590 | ||
|
|
017c70e8b2 | ||
|
|
64b0830909 | ||
|
|
25d99cbece | ||
|
|
033f0e1b0d | ||
|
|
35027ee0ae | ||
|
|
91904e959b | ||
|
|
a6a85ae3a2 | ||
|
|
b0f53f45f9 | ||
|
|
0f60f8d486 | ||
|
|
efb207a109 | ||
|
|
95b1481dd5 | ||
|
|
8de340b68b | ||
|
|
ef15b85386 | ||
|
|
45d939237d | ||
|
|
6bf262e514 | ||
|
|
f9d9137336 | ||
|
|
b532521f27 | ||
|
|
1e06e2d34d | ||
|
|
a33fa5e184 | ||
|
|
a2453695d8 | ||
|
|
3e929d0433 | ||
|
|
185fc464a5 | ||
|
|
647c009525 | ||
|
|
ba75492dcc | ||
|
|
8312baaf45 | ||
|
|
4d346dc278 | ||
|
|
70ff7fab38 | ||
|
|
6947c6affd | ||
|
|
dcab83f936 | ||
|
|
b228e4ec26 | ||
|
|
4071a1301f | ||
|
|
5c9db10710 | ||
|
|
19c92e0014 | ||
|
|
6459f2eb46 | ||
|
|
7926e081ef | ||
|
|
ceefe7075f | ||
|
|
ad3230fd83 | ||
|
|
c89b07ed93 | ||
|
|
201ccea842 | ||
|
|
32ada488b4 | ||
|
|
794d11a355 | ||
|
|
67f8f5fe89 | ||
|
|
9ac69fd92a | ||
|
|
069f1b450c | ||
|
|
2f388af928 | ||
|
|
beeb0579ce | ||
|
|
a8666da57b | ||
|
|
835316d0f3 | ||
|
|
f5feeb9617 | ||
|
|
09e380a480 | ||
|
|
3080df9b66 | ||
|
|
ebc41a8049 | ||
|
|
635628e30e | ||
|
|
819a58ac06 | ||
|
|
d433375522 | ||
|
|
c0150f71a8 | ||
|
|
6119698d38 | ||
|
|
f5ae231601 | ||
|
|
972d23abbd | ||
|
|
9a514a8a69 | ||
|
|
7325231548 | ||
|
|
570657371a | ||
|
|
67da60b5b0 | ||
|
|
84c047c5ab | ||
|
|
23f5d09bec | ||
|
|
2a19075e23 | ||
|
|
7f231175b2 | ||
|
|
062e84f864 | ||
|
|
5521eb20bf | ||
|
|
627b5d250b | ||
|
|
195a8a68d6 | ||
|
|
daf1f68b82 | ||
|
|
dd24fd56d3 | ||
|
|
7a2acb6497 | ||
|
|
9c339faa72 | ||
|
|
02376ad02b | ||
|
|
b53a4a0286 | ||
|
|
a1f618434b | ||
|
|
7b5be29f0d | ||
|
|
56a73b181a | ||
|
|
865618e054 | ||
|
|
9e912b2736 | ||
|
|
da7680e70f | ||
|
|
ab594eb511 | ||
|
|
cffaaa369a | ||
|
|
5f414e82ee | ||
|
|
f3bcef534e | ||
|
|
d140ff5b70 | ||
|
|
7eceacfe68 | ||
|
|
038438fba7 | ||
|
|
ee98a5ef12 | ||
|
|
28b12faaf0 | ||
|
|
d0f2742637 | ||
|
|
9c55dac866 | ||
|
|
e6d8b548b7 | ||
|
|
4f8c2215c1 | ||
|
|
851b34f07a | ||
|
|
546ed5c6af | ||
|
|
04ae7337f5 | ||
|
|
a3a8791e96 | ||
|
|
63069f0ec9 | ||
|
|
32b522dad2 | ||
|
|
0c20a079e3 | ||
|
|
7c9697f683 | ||
|
|
15d04230ae | ||
|
|
ecc09ca6a6 | ||
|
|
cd753c5dd5 | ||
|
|
a3b9952f80 | ||
|
|
e93969c035 | ||
|
|
6ec5b5df1e | ||
|
|
93e7adeea8 | ||
|
|
37b5a43c1f | ||
|
|
87a07c25d1 | ||
|
|
9e27fef5e5 | ||
|
|
2cbba53e06 | ||
|
|
d9e8be7efb | ||
|
|
7dc9ef9950 | ||
|
|
00e83cf6a2 | ||
|
|
039242b48a | ||
|
|
94e2bdf93d | ||
|
|
79b387ce60 | ||
|
|
43eb87d3ba | ||
|
|
0110220b72 | ||
|
|
f5c86f3d97 | ||
|
|
7b7f58d34d | ||
|
|
86112931d9 | ||
|
|
e6e0e4caea | ||
|
|
942154480e | ||
|
|
467131d9f1 | ||
|
|
fee1db8660 | ||
|
|
4f7fc1c9c8 |
15
README.md
15
README.md
@@ -13,6 +13,7 @@
|
||||
<a href="#key-features">Features</a> •
|
||||
<a href="#how-to-use">Usage</a> •
|
||||
<a href="#how-it-works">How</a> •
|
||||
<a href="#help-us-translate-wygiwyh">Translate</a> •
|
||||
<a href="#caveats-and-warnings">Caveats and Warnings</a> •
|
||||
<a href="#built-with">Built with</a>
|
||||
</p>
|
||||
@@ -80,7 +81,7 @@ $ docker compose exec -it web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Enviroment Variables](#enviroment-variables) for an explanation of all available variables
|
||||
> If you're using Unraid, you don't need to follow these steps, use the app on the store. Make sure to read the [Unraid section](#unraid) and [Environment Variables](#environment-variables) for an explanation of all available variables
|
||||
|
||||
## Running locally
|
||||
|
||||
@@ -110,7 +111,7 @@ WYGIWYH is available on the Unraid Store. You'll need to provision your own post
|
||||
|
||||
To create the first user, open the container's console using Unraid's UI, by clicking on WYGIWYH icon on the Docker page and selecting `Console`, then type `python manage.py createsuperuser`, you'll them be prompted to input your e-mail and password.
|
||||
|
||||
## Enviroment Variables
|
||||
## Environment Variables
|
||||
|
||||
| variable | type | default | explanation |
|
||||
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
@@ -122,7 +123,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
| SQL_DATABASE | string | None *required | The name of your postgres database |
|
||||
| SQL_USER | string | user | The username used to connect to your postgres database |
|
||||
| SQL_PASSWORD | string | password | The password used to connect to your postgres database |
|
||||
| SQL_HOST | string | localhost | The adress used to connect to your postgres database |
|
||||
| SQL_HOST | string | localhost | The address used to connect to your postgres database |
|
||||
| SQL_PORT | string | 5432 | The port used to connect to your postgres database |
|
||||
| SESSION_EXPIRY_TIME | int | 2678400 (31 days) | The age of session cookies, in seconds. E.g. how long you will stay logged in |
|
||||
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
|
||||
@@ -133,6 +134,14 @@ To create the first user, open the container's console using Unraid's UI, by cli
|
||||
|
||||
Check out our [Wiki](https://github.com/eitchtee/WYGIWYH/wiki) for more information.
|
||||
|
||||
# Help us translate WYGIWYH!
|
||||
<a href="https://translations.herculino.com/engage/wygiwyh/">
|
||||
<img src="https://translations.herculino.com/widget/wygiwyh/open-graph.png" alt="Translation status" />
|
||||
</a>
|
||||
|
||||
> [!NOTE]
|
||||
> Login with your github account
|
||||
|
||||
# Caveats and Warnings
|
||||
|
||||
- I'm not an accountant, some terms and even calculations might be wrong. Make sure to open an issue if you see anything that could be improved.
|
||||
|
||||
@@ -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
|
||||
@@ -55,6 +55,7 @@ INSTALLED_APPS = [
|
||||
"hijack",
|
||||
"hijack.contrib.admin",
|
||||
"django_filters",
|
||||
"import_export",
|
||||
"apps.users.apps.UsersConfig",
|
||||
"procrastinate.contrib.django",
|
||||
"apps.transactions.apps.TransactionsConfig",
|
||||
@@ -63,6 +64,7 @@ INSTALLED_APPS = [
|
||||
"apps.common.apps.CommonConfig",
|
||||
"apps.net_worth.apps.NetWorthConfig",
|
||||
"apps.import_app.apps.ImportConfig",
|
||||
"apps.export_app.apps.ExportConfig",
|
||||
"apps.api.apps.ApiConfig",
|
||||
"cachalot",
|
||||
"rest_framework",
|
||||
@@ -75,6 +77,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 +90,6 @@ MIDDLEWARE = [
|
||||
"apps.common.middleware.localization.LocalizationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django_browser_reload.middleware.BrowserReloadMiddleware",
|
||||
"hijack.middleware.HijackUserMiddleware",
|
||||
]
|
||||
|
||||
@@ -161,6 +163,7 @@ AUTH_USER_MODEL = "users.User"
|
||||
|
||||
LANGUAGE_CODE = "en"
|
||||
LANGUAGES = (
|
||||
("de", "Deutsch"),
|
||||
("en", "English"),
|
||||
("nl", "Nederlands"),
|
||||
("pt-br", "Português (Brasil)"),
|
||||
@@ -277,27 +280,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 +300,10 @@ if "procrastinate" in sys.argv:
|
||||
"loggers": {
|
||||
"procrastinate": {
|
||||
"handlers": ["procrastinate"],
|
||||
"propagate": True,
|
||||
"propagate": False,
|
||||
},
|
||||
"root": {
|
||||
"handlers": None,
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
"propagate": False,
|
||||
},
|
||||
|
||||
@@ -49,4 +49,6 @@ urlpatterns = [
|
||||
path("", include("apps.dca.urls")),
|
||||
path("", include("apps.mini_tools.urls")),
|
||||
path("", include("apps.import_app.urls")),
|
||||
path("", include("apps.export_app.urls")),
|
||||
path("", include("apps.insights.urls")),
|
||||
]
|
||||
|
||||
@@ -12,15 +12,14 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
self.to_field_name = kwargs.pop("to_field_name", "pk")
|
||||
|
||||
self.create_field = kwargs.pop("create_field", None)
|
||||
if not self.create_field:
|
||||
raise ValueError("The 'create_field' parameter is required.")
|
||||
|
||||
self.queryset = kwargs.pop("queryset", model.objects.all())
|
||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||
self._created_instance = None
|
||||
|
||||
self.widget = TomSelect(clear_button=True, create=True)
|
||||
|
||||
super().__init__(queryset=self.queryset, *args, **kwargs)
|
||||
self._created_instance = None
|
||||
|
||||
def to_python(self, value):
|
||||
if value in self.empty_values:
|
||||
return None
|
||||
@@ -53,14 +52,19 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
|
||||
else:
|
||||
raise self.model.DoesNotExist
|
||||
except self.model.DoesNotExist:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
if self.create_field:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
instance, _ = self.model.objects.update_or_create(
|
||||
**{self.create_field: value}
|
||||
)
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
self._created_instance = instance
|
||||
return instance
|
||||
except Exception as e:
|
||||
else:
|
||||
raise ValidationError(
|
||||
self.error_messages["invalid_choice"], code="invalid_choice"
|
||||
)
|
||||
|
||||
@@ -19,6 +19,8 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
format=None,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
read_only=True,
|
||||
toggle_selected=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -26,6 +28,10 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
self.read_only = read_only
|
||||
self.toggle_selected = (
|
||||
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
@@ -47,9 +53,13 @@ class AirDatePickerInput(widgets.DateInput):
|
||||
attrs["data-now-button-txt"] = _("Today")
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = django_to_airdatepicker_datetime(self._get_format())
|
||||
|
||||
if self.read_only:
|
||||
attrs["readonly"] = True
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
@@ -89,6 +99,8 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
timepicker=True,
|
||||
clear_button=True,
|
||||
auto_close=True,
|
||||
read_only=True,
|
||||
toggle_selected=None,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -97,6 +109,10 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
self.timepicker = timepicker
|
||||
self.clear_button = clear_button
|
||||
self.auto_close = auto_close
|
||||
self.read_only = read_only
|
||||
self.toggle_selected = (
|
||||
toggle_selected if isinstance(toggle_selected, bool) else self.clear_button
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_language():
|
||||
@@ -123,11 +139,15 @@ class AirDateTimePickerInput(widgets.DateTimeInput):
|
||||
attrs["data-now-button-txt"] = _("Now")
|
||||
attrs["data-timepicker"] = str(self.timepicker).lower()
|
||||
attrs["data-auto-close"] = str(self.auto_close).lower()
|
||||
attrs["data-toggle-selected"] = str(self.toggle_selected).lower()
|
||||
attrs["data-clear-button"] = str(self.clear_button).lower()
|
||||
attrs["data-language"] = self._get_current_language()
|
||||
attrs["data-date-format"] = date_format
|
||||
attrs["data-time-format"] = time_format
|
||||
|
||||
if self.read_only:
|
||||
attrs["readonly"] = True
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
@@ -227,3 +247,56 @@ class AirMonthYearPickerInput(AirDatePickerInput):
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class AirYearPickerInput(AirDatePickerInput):
|
||||
def __init__(self, attrs=None, format=None, *args, **kwargs):
|
||||
super().__init__(attrs=attrs, format=format, *args, **kwargs)
|
||||
# Store the display format for AirDatepicker
|
||||
self.display_format = "yyyy"
|
||||
# Store the Python format for internal use
|
||||
self.python_format = "%Y"
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
# Add data attributes for AirDatepicker configuration
|
||||
attrs["data-now-button-txt"] = _("Today")
|
||||
attrs["data-date-format"] = "yyyy"
|
||||
|
||||
return attrs
|
||||
|
||||
def format_value(self, value):
|
||||
"""Format the value for display in the widget."""
|
||||
if value:
|
||||
self.attrs["data-value"] = (
|
||||
value # We use this to dynamically select the initial date on AirDatePicker
|
||||
)
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = datetime.datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return value
|
||||
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||
# Use Django's date translation
|
||||
return f"{value.year}"
|
||||
return value
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""Convert the value from the widget format back to a format Django can handle."""
|
||||
value = super().value_from_datadict(data, files, name)
|
||||
if value:
|
||||
try:
|
||||
# Split the value into month name and year
|
||||
year_str = value
|
||||
year = int(year_str)
|
||||
|
||||
if year:
|
||||
# Return the first day of the month in Django's expected format
|
||||
return datetime.date(year, 1, 1).strftime("%Y-%m-%d")
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.forms import widgets, SelectMultiple
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -129,3 +130,28 @@ class TomSelect(widgets.Select):
|
||||
|
||||
class TomSelectMultiple(SelectMultiple, TomSelect):
|
||||
pass
|
||||
|
||||
|
||||
class TransactionSelect(TomSelect):
|
||||
def __init__(self, income: bool = True, expense: bool = True, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.load_income = income
|
||||
self.load_expense = expense
|
||||
self.create = False
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
|
||||
if self.load_income and self.load_expense:
|
||||
attrs["data-load"] = reverse("transactions_search")
|
||||
elif self.load_income and not self.load_expense:
|
||||
attrs["data-load"] = reverse(
|
||||
"transactions_search", kwargs={"filter_type": "income"}
|
||||
)
|
||||
elif self.load_expense and not self.load_income:
|
||||
attrs["data-load"] = reverse(
|
||||
"transactions_search", kwargs={"filter_type": "expenses"}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -6,8 +6,10 @@ from django.utils import timezone
|
||||
|
||||
from apps.currencies.exchange_rates.providers import (
|
||||
SynthFinanceProvider,
|
||||
SynthFinanceStockProvider,
|
||||
CoinGeckoFreeProvider,
|
||||
CoinGeckoProProvider,
|
||||
TransitiveRateProvider,
|
||||
)
|
||||
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||
|
||||
@@ -17,8 +19,10 @@ logger = logging.getLogger(__name__)
|
||||
# Map service types to provider classes
|
||||
PROVIDER_MAPPING = {
|
||||
"synth_finance": SynthFinanceProvider,
|
||||
"synth_finance_stock": SynthFinanceStockProvider,
|
||||
"coingecko_free": CoinGeckoFreeProvider,
|
||||
"coingecko_pro": CoinGeckoProProvider,
|
||||
"transitive": TransitiveRateProvider,
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +35,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 +47,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}, "
|
||||
|
||||
@@ -3,11 +3,11 @@ import time
|
||||
|
||||
import requests
|
||||
from decimal import Decimal
|
||||
from typing import Tuple, List
|
||||
from typing import Tuple, List, Optional, Dict
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -150,3 +150,159 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"x-cg-pro-api-key": api_key})
|
||||
|
||||
|
||||
class SynthFinanceStockProvider(ExchangeRateProvider):
|
||||
"""Implementation for Synth Finance API Real-Time Prices endpoint (synthfinance.com)"""
|
||||
|
||||
BASE_URL = "https://api.synthfinance.com/tickers"
|
||||
rates_inverted = True
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
super().__init__(api_key)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{"Authorization": f"Bearer {self.api_key}", "accept": "application/json"}
|
||||
)
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
|
||||
for currency in target_currencies:
|
||||
if currency.exchange_currency not in exchange_currencies:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Same currency has rate of 1
|
||||
if currency.code == currency.exchange_currency.code:
|
||||
rate = Decimal("1")
|
||||
results.append((currency.exchange_currency, currency, rate))
|
||||
continue
|
||||
|
||||
# Fetch real-time price for this ticker
|
||||
response = self.session.get(
|
||||
f"{self.BASE_URL}/{currency.code}/real-time"
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Use fair market value as the rate
|
||||
rate = Decimal(data["data"]["fair_market_value"])
|
||||
results.append((currency.exchange_currency, currency, rate))
|
||||
|
||||
# Log API usage
|
||||
credits_used = data["meta"]["credits_used"]
|
||||
credits_remaining = data["meta"]["credits_remaining"]
|
||||
logger.info(
|
||||
f"Synth Finance API call for {currency.code}: {credits_used} credits used, {credits_remaining} remaining"
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
f"Error fetching rate from Synth Finance API for ticker {currency.code}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except KeyError as e:
|
||||
logger.error(
|
||||
f"Unexpected response structure from Synth Finance API for ticker {currency.code}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Unexpected error processing Synth Finance data for ticker {currency.code}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class TransitiveRateProvider(ExchangeRateProvider):
|
||||
"""Calculates exchange rates through paths of existing rates"""
|
||||
|
||||
rates_inverted = True
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
super().__init__(api_key) # API key not needed but maintaining interface
|
||||
|
||||
@classmethod
|
||||
def requires_api_key(cls) -> bool:
|
||||
return False
|
||||
|
||||
def get_rates(
|
||||
self, target_currencies: QuerySet, exchange_currencies: set
|
||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||
results = []
|
||||
|
||||
# Get recent rates for building the graph
|
||||
recent_rates = ExchangeRate.objects.all()
|
||||
|
||||
# Build currency graph
|
||||
currency_graph = self._build_currency_graph(recent_rates)
|
||||
|
||||
for target in target_currencies:
|
||||
if (
|
||||
not target.exchange_currency
|
||||
or target.exchange_currency not in exchange_currencies
|
||||
):
|
||||
continue
|
||||
|
||||
# Find path and calculate rate
|
||||
from_id = target.exchange_currency.id
|
||||
to_id = target.id
|
||||
|
||||
path, rate = self._find_conversion_path(currency_graph, from_id, to_id)
|
||||
|
||||
if path and rate:
|
||||
path_codes = [Currency.objects.get(id=cid).code for cid in path]
|
||||
logger.info(
|
||||
f"Found conversion path: {' -> '.join(path_codes)}, rate: {rate}"
|
||||
)
|
||||
results.append((target.exchange_currency, target, rate))
|
||||
else:
|
||||
logger.debug(
|
||||
f"No conversion path found for {target.exchange_currency.code}->{target.code}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _build_currency_graph(rates) -> Dict[int, Dict[int, Decimal]]:
|
||||
"""Build a graph representation of currency relationships"""
|
||||
graph = {}
|
||||
|
||||
for rate in rates:
|
||||
# Add both directions to make the graph bidirectional
|
||||
if rate.from_currency_id not in graph:
|
||||
graph[rate.from_currency_id] = {}
|
||||
graph[rate.from_currency_id][rate.to_currency_id] = rate.rate
|
||||
|
||||
if rate.to_currency_id not in graph:
|
||||
graph[rate.to_currency_id] = {}
|
||||
graph[rate.to_currency_id][rate.from_currency_id] = Decimal("1") / rate.rate
|
||||
|
||||
return graph
|
||||
|
||||
@staticmethod
|
||||
def _find_conversion_path(
|
||||
graph, from_id, to_id
|
||||
) -> Tuple[Optional[list], Optional[Decimal]]:
|
||||
"""Find the shortest path between currencies using breadth-first search"""
|
||||
if from_id not in graph or to_id not in graph:
|
||||
return None, None
|
||||
|
||||
queue = [(from_id, [from_id], Decimal("1"))]
|
||||
visited = {from_id}
|
||||
|
||||
while queue:
|
||||
current, path, current_rate = queue.pop(0)
|
||||
|
||||
if current == to_id:
|
||||
return path, current_rate
|
||||
|
||||
for neighbor, rate in graph.get(current, {}).items():
|
||||
if neighbor not in visited:
|
||||
visited.add(neighbor)
|
||||
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
||||
|
||||
return None, None
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 01:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0011_remove_exchangerateservice_fetch_interval_hours_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-02 01:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('currencies', '0012_alter_exchangerateservice_service_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exchangerateservice',
|
||||
name='service_type',
|
||||
field=models.CharField(choices=[('synth_finance', 'Synth Finance'), ('synth_finance_stock', 'Synth Finance Stock'), ('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)')], max_length=255, verbose_name='Service Type'),
|
||||
),
|
||||
]
|
||||
@@ -92,8 +92,10 @@ class ExchangeRateService(models.Model):
|
||||
|
||||
class ServiceType(models.TextChoices):
|
||||
SYNTH_FINANCE = "synth_finance", "Synth Finance"
|
||||
SYNTH_FINANCE_STOCK = "synth_finance_stock", "Synth Finance Stock"
|
||||
COINGECKO_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
||||
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
||||
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
||||
|
||||
class IntervalType(models.TextChoices):
|
||||
ON = "on", _("On")
|
||||
@@ -204,11 +206,11 @@ class ExchangeRateService(models.Model):
|
||||
}
|
||||
)
|
||||
hours = int(self.fetch_interval)
|
||||
if hours < 0 or hours > 23:
|
||||
if hours < 1 or hours > 24:
|
||||
raise ValidationError(
|
||||
{
|
||||
"fetch_interval": _(
|
||||
"'Every X hours' interval must be between 0 and 23."
|
||||
"'Every X hours' interval must be between 1 and 24."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
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, Row, Column
|
||||
from crispy_forms.layout import Layout, Row, Column, HTML
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.datepicker import AirDatePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.common.widgets.tom_select import TransactionSelect
|
||||
from apps.transactions.models import Transaction, TransactionTag, TransactionCategory
|
||||
from apps.common.fields.forms.dynamic_select import (
|
||||
DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
|
||||
|
||||
class DCAStrategyForm(forms.ModelForm):
|
||||
@@ -53,6 +61,75 @@ class DCAStrategyForm(forms.ModelForm):
|
||||
|
||||
|
||||
class DCAEntryForm(forms.ModelForm):
|
||||
create_transaction = forms.BooleanField(
|
||||
label=_("Create transaction"), initial=False, required=False
|
||||
)
|
||||
|
||||
from_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
label=_("From Account"),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
required=False,
|
||||
)
|
||||
to_account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
label=_("To Account"),
|
||||
widget=TomSelect(clear_button=False, group_by="group"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
from_category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
to_category = DynamicModelChoiceField(
|
||||
create_field="name",
|
||||
model=TransactionCategory,
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
)
|
||||
|
||||
from_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
to_tags = DynamicModelMultipleChoiceField(
|
||||
model=TransactionTag,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Tags"),
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
|
||||
expense_transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Expense Transaction"),
|
||||
required=False,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=True, income=False, expense=True),
|
||||
help_text=_("Type to search for a transaction to link to this entry"),
|
||||
)
|
||||
|
||||
income_transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
label=_("Income Transaction"),
|
||||
required=False,
|
||||
queryset=Transaction.objects.none(),
|
||||
widget=TransactionSelect(clear_button=True, income=True, expense=False),
|
||||
help_text=_("Type to search for a transaction to link to this entry"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAEntry
|
||||
fields = [
|
||||
@@ -60,13 +137,19 @@ class DCAEntryForm(forms.ModelForm):
|
||||
"amount_paid",
|
||||
"amount_received",
|
||||
"notes",
|
||||
"expense_transaction",
|
||||
"income_transaction",
|
||||
]
|
||||
widgets = {
|
||||
"notes": forms.Textarea(attrs={"rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
strategy = kwargs.pop("strategy", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.strategy = strategy if strategy else self.instance.strategy
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.layout = Layout(
|
||||
@@ -75,18 +158,66 @@ class DCAEntryForm(forms.ModelForm):
|
||||
Column("amount_paid", css_class="form-group col-md-6"),
|
||||
Column("amount_received", css_class="form-group col-md-6"),
|
||||
),
|
||||
Row(
|
||||
Column("expense_transaction", css_class="form-group col-md-6"),
|
||||
Column("income_transaction", css_class="form-group col-md-6"),
|
||||
),
|
||||
"notes",
|
||||
BS5Accordion(
|
||||
AccordionGroup(
|
||||
_("Create transaction"),
|
||||
Switch("create_transaction"),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column(
|
||||
"from_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
"from_category",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
Column(
|
||||
"from_tags", css_class="form-group col-md-6 mb-0"
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
Row(
|
||||
Column(
|
||||
"to_account",
|
||||
css_class="form-group col-md-6 mb-0",
|
||||
),
|
||||
css_class="form-row",
|
||||
),
|
||||
Row(
|
||||
Column(
|
||||
"to_category", css_class="form-group col-md-6 mb-0"
|
||||
),
|
||||
Column("to_tags", css_class="form-group col-md-6 mb-0"),
|
||||
css_class="form-row",
|
||||
),
|
||||
),
|
||||
css_class="p-1 mx-1 my-3 border rounded-3",
|
||||
),
|
||||
active=False,
|
||||
),
|
||||
AccordionGroup(
|
||||
_("Link transaction"),
|
||||
"income_transaction",
|
||||
"expense_transaction",
|
||||
),
|
||||
flush=False,
|
||||
always_open=False,
|
||||
css_class="mb-3",
|
||||
),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
# decimal_places = self.instance.account.currency.decimal_places
|
||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput(
|
||||
# decimal_places=decimal_places
|
||||
# )
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
@@ -95,7 +226,6 @@ class DCAEntryForm(forms.ModelForm):
|
||||
),
|
||||
)
|
||||
else:
|
||||
# self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
@@ -107,3 +237,118 @@ class DCAEntryForm(forms.ModelForm):
|
||||
self.fields["amount_paid"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
expense_transaction = None
|
||||
income_transaction = None
|
||||
if self.instance and self.instance.pk:
|
||||
# Edit mode - get from instance
|
||||
expense_transaction = self.instance.expense_transaction
|
||||
income_transaction = self.instance.income_transaction
|
||||
elif self.data.get("expense_transaction"):
|
||||
# Form validation - get from submitted data
|
||||
try:
|
||||
expense_transaction = Transaction.objects.get(
|
||||
id=self.data["expense_transaction"]
|
||||
)
|
||||
income_transaction = Transaction.objects.get(
|
||||
id=self.data["income_transaction"]
|
||||
)
|
||||
except Transaction.DoesNotExist:
|
||||
pass
|
||||
|
||||
# If we have a current transaction, ensure it's in the queryset
|
||||
if income_transaction:
|
||||
self.fields["income_transaction"].queryset = Transaction.objects.filter(
|
||||
id=income_transaction.id
|
||||
)
|
||||
if expense_transaction:
|
||||
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
|
||||
id=expense_transaction.id
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
if cleaned_data.get("create_transaction"):
|
||||
from_account = cleaned_data.get("from_account")
|
||||
to_account = cleaned_data.get("to_account")
|
||||
|
||||
if not from_account and not to_account:
|
||||
raise forms.ValidationError(
|
||||
{
|
||||
"from_account": _("You must provide an account."),
|
||||
"to_account": _("You must provide an account."),
|
||||
}
|
||||
)
|
||||
elif not from_account and to_account:
|
||||
raise forms.ValidationError(
|
||||
{"from_account": _("You must provide an account.")}
|
||||
)
|
||||
elif not to_account and from_account:
|
||||
raise forms.ValidationError(
|
||||
{"to_account": _("You must provide an account.")}
|
||||
)
|
||||
|
||||
if from_account == to_account:
|
||||
raise forms.ValidationError(
|
||||
_("From and To accounts must be different.")
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, **kwargs):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
if self.cleaned_data.get("create_transaction"):
|
||||
from_account = self.cleaned_data["from_account"]
|
||||
to_account = self.cleaned_data["to_account"]
|
||||
from_amount = instance.amount_paid
|
||||
to_amount = instance.amount_received
|
||||
date = instance.date
|
||||
description = _("DCA for %(strategy_name)s") % {
|
||||
"strategy_name": self.strategy.name
|
||||
}
|
||||
from_category = self.cleaned_data.get("from_category")
|
||||
to_category = self.cleaned_data.get("to_category")
|
||||
notes = self.cleaned_data.get("notes")
|
||||
|
||||
# Create "From" transaction
|
||||
from_transaction = Transaction.objects.create(
|
||||
account=from_account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
is_paid=True,
|
||||
date=date,
|
||||
amount=from_amount,
|
||||
description=description,
|
||||
category=from_category,
|
||||
notes=notes,
|
||||
)
|
||||
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
||||
|
||||
# Create "To" transaction
|
||||
to_transaction = Transaction.objects.create(
|
||||
account=to_account,
|
||||
type=Transaction.Type.INCOME,
|
||||
is_paid=True,
|
||||
date=date,
|
||||
amount=to_amount,
|
||||
description=description,
|
||||
category=to_category,
|
||||
notes=notes,
|
||||
)
|
||||
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||
|
||||
instance.expense_transaction = from_transaction
|
||||
instance.income_transaction = to_transaction
|
||||
else:
|
||||
if instance.expense_transaction:
|
||||
instance.expense_transaction.amount = instance.amount_paid
|
||||
instance.expense_transaction.save()
|
||||
if instance.income_transaction:
|
||||
instance.income_transaction.amount = instance.amount_received
|
||||
instance.income_transaction.save()
|
||||
|
||||
instance.strategy = self.strategy
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
||||
@@ -155,11 +155,9 @@ def strategy_detail(request, strategy_id):
|
||||
def strategy_entry_add(request, strategy_id):
|
||||
strategy = get_object_or_404(DCAStrategy, id=strategy_id)
|
||||
if request.method == "POST":
|
||||
form = DCAEntryForm(request.POST)
|
||||
form = DCAEntryForm(request.POST, strategy=strategy)
|
||||
if form.is_valid():
|
||||
entry = form.save(commit=False)
|
||||
entry.strategy = strategy
|
||||
entry.save()
|
||||
entry = form.save()
|
||||
messages.success(request, _("Entry added successfully"))
|
||||
|
||||
return HttpResponse(
|
||||
@@ -169,7 +167,7 @@ def strategy_entry_add(request, strategy_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = DCAEntryForm()
|
||||
form = DCAEntryForm(strategy=strategy)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
3
app/apps/export_app/admin.py
Normal file
3
app/apps/export_app/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/apps/export_app/apps.py
Normal file
6
app/apps/export_app/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ExportConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.export_app"
|
||||
189
app/apps/export_app/forms.py
Normal file
189
app/apps/export_app/forms.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, HTML
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
|
||||
|
||||
class ExportForm(forms.Form):
|
||||
accounts = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Accounts"),
|
||||
initial=True,
|
||||
)
|
||||
currencies = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Currencies"),
|
||||
initial=True,
|
||||
)
|
||||
transactions = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Transactions"),
|
||||
initial=True,
|
||||
)
|
||||
categories = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Categories"),
|
||||
initial=True,
|
||||
)
|
||||
tags = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Tags"),
|
||||
initial=False,
|
||||
)
|
||||
entities = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Entities"),
|
||||
initial=False,
|
||||
)
|
||||
recurring_transactions = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Recurring Transactions"),
|
||||
initial=True,
|
||||
)
|
||||
installment_plans = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Installment Plans"),
|
||||
initial=True,
|
||||
)
|
||||
exchange_rates = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Exchange Rates"),
|
||||
initial=False,
|
||||
)
|
||||
exchange_rates_services = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Automatic Exchange Rates"),
|
||||
initial=False,
|
||||
)
|
||||
rules = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Rules"),
|
||||
initial=True,
|
||||
)
|
||||
dca = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("DCA"),
|
||||
initial=False,
|
||||
)
|
||||
import_profiles = forms.BooleanField(
|
||||
required=False,
|
||||
widget=forms.CheckboxInput(),
|
||||
label=_("Import Profiles"),
|
||||
initial=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"accounts",
|
||||
"currencies",
|
||||
"transactions",
|
||||
"categories",
|
||||
"entities",
|
||||
"tags",
|
||||
"installment_plans",
|
||||
"recurring_transactions",
|
||||
"exchange_rates_services",
|
||||
"exchange_rates",
|
||||
"rules",
|
||||
"dca",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Export"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RestoreForm(forms.Form):
|
||||
zip_file = forms.FileField(
|
||||
required=False,
|
||||
help_text=_("Import a ZIP file exported from WYGIWYH"),
|
||||
label=_("ZIP File"),
|
||||
)
|
||||
accounts = forms.FileField(required=False, label=_("Accounts"))
|
||||
currencies = forms.FileField(required=False, label=_("Currencies"))
|
||||
transactions_categories = forms.FileField(required=False, label=_("Categories"))
|
||||
transactions_tags = forms.FileField(required=False, label=_("Tags"))
|
||||
transactions_entities = forms.FileField(required=False, label=_("Entities"))
|
||||
transactions = forms.FileField(required=False, label=_("Transactions"))
|
||||
installment_plans = forms.FileField(required=False, label=_("Installment Plans"))
|
||||
recurring_transactions = forms.FileField(
|
||||
required=False, label=_("Recurring Transactions")
|
||||
)
|
||||
automatic_exchange_rates = forms.FileField(
|
||||
required=False, label=_("Automatic Exchange Rates")
|
||||
)
|
||||
exchange_rates = forms.FileField(required=False, label=_("Exchange Rates"))
|
||||
transaction_rules = forms.FileField(required=False, label=_("Transaction rules"))
|
||||
transaction_rules_actions = forms.FileField(
|
||||
required=False, label=_("Edit transaction action")
|
||||
)
|
||||
transaction_rules_update_or_create = forms.FileField(
|
||||
required=False, label=_("Update or create transaction actions")
|
||||
)
|
||||
dca_strategies = forms.FileField(required=False, label=_("DCA Strategies"))
|
||||
dca_entries = forms.FileField(required=False, label=_("DCA Entries"))
|
||||
import_profiles = forms.FileField(required=False, label=_("Import Profiles"))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"zip_file",
|
||||
HTML("<hr />"),
|
||||
"accounts",
|
||||
"currencies",
|
||||
"transactions",
|
||||
"transactions_categories",
|
||||
"transactions_entities",
|
||||
"transactions_tags",
|
||||
"installment_plans",
|
||||
"recurring_transactions",
|
||||
"automatic_exchange_rates",
|
||||
"exchange_rates",
|
||||
"transaction_rules",
|
||||
"transaction_rules_actions",
|
||||
"transaction_rules_update_or_create",
|
||||
"dca_strategies",
|
||||
"dca_entries",
|
||||
"import_profiles",
|
||||
FormActions(
|
||||
NoClassSubmit(
|
||||
"submit", _("Restore"), css_class="btn btn-outline-primary w-100"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if not cleaned_data.get("zip_file") and not any(
|
||||
cleaned_data.get(field) for field in self.fields if field != "zip_file"
|
||||
):
|
||||
raise forms.ValidationError(
|
||||
_("Please upload either a ZIP file or at least one CSV file")
|
||||
)
|
||||
return cleaned_data
|
||||
0
app/apps/export_app/migrations/__init__.py
Normal file
0
app/apps/export_app/migrations/__init__.py
Normal file
3
app/apps/export_app/models.py
Normal file
3
app/apps/export_app/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
0
app/apps/export_app/resources/__init__.py
Normal file
0
app/apps/export_app/resources/__init__.py
Normal file
26
app/apps/export_app/resources/accounts.py
Normal file
26
app/apps/export_app/resources/accounts.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account, AccountGroup
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
class AccountResource(resources.ModelResource):
|
||||
group = fields.Field(
|
||||
attribute="group",
|
||||
column_name="group",
|
||||
widget=AutoCreateForeignKeyWidget(AccountGroup, "name"),
|
||||
)
|
||||
currency = fields.Field(
|
||||
attribute="currency",
|
||||
column_name="currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
exchange_currency = fields.Field(
|
||||
attribute="exchange_currency",
|
||||
column_name="exchange_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Account
|
||||
52
app/apps/export_app/resources/currencies.py
Normal file
52
app/apps/export_app/resources/currencies.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from import_export import fields, resources, widgets
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
|
||||
from apps.export_app.widgets.foreign_key import SkipMissingForeignKeyWidget
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
class CurrencyResource(resources.ModelResource):
|
||||
exchange_currency = fields.Field(
|
||||
attribute="exchange_currency",
|
||||
column_name="exchange_currency",
|
||||
widget=SkipMissingForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Currency
|
||||
|
||||
|
||||
class ExchangeRateResource(resources.ModelResource):
|
||||
from_currency = fields.Field(
|
||||
attribute="from_currency",
|
||||
column_name="from_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
to_currency = fields.Field(
|
||||
attribute="to_currency",
|
||||
column_name="to_currency",
|
||||
widget=widgets.ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
rate = fields.Field(
|
||||
attribute="rate", column_name="rate", widget=UniversalDecimalWidget()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRate
|
||||
|
||||
|
||||
class ExchangeRateServiceResource(resources.ModelResource):
|
||||
target_currencies = fields.Field(
|
||||
attribute="target_currencies",
|
||||
column_name="target_currencies",
|
||||
widget=widgets.ManyToManyWidget(Currency, field="name"),
|
||||
)
|
||||
target_accounts = fields.Field(
|
||||
attribute="target_accounts",
|
||||
column_name="target_accounts",
|
||||
widget=widgets.ManyToManyWidget(Account, field="name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExchangeRateService
|
||||
38
app/apps/export_app/resources/dca.py
Normal file
38
app/apps/export_app/resources/dca.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.dca.models import DCAStrategy, DCAEntry
|
||||
from apps.currencies.models import Currency
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
class DCAStrategyResource(resources.ModelResource):
|
||||
target_currency = fields.Field(
|
||||
attribute="target_currency",
|
||||
column_name="target_currency",
|
||||
widget=ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
payment_currency = fields.Field(
|
||||
attribute="payment_currency",
|
||||
column_name="payment_currency",
|
||||
widget=ForeignKeyWidget(Currency, "name"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAStrategy
|
||||
|
||||
|
||||
class DCAEntryResource(resources.ModelResource):
|
||||
amount_paid = fields.Field(
|
||||
attribute="amount_paid",
|
||||
column_name="amount_paid",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
amount_received = fields.Field(
|
||||
attribute="amount_received",
|
||||
column_name="amount_received",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DCAEntry
|
||||
8
app/apps/export_app/resources/import_app.py
Normal file
8
app/apps/export_app/resources/import_app.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from import_export import resources
|
||||
|
||||
from apps.import_app.models import ImportProfile
|
||||
|
||||
|
||||
class ImportProfileResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = ImportProfile
|
||||
25
app/apps/export_app/resources/rules.py
Normal file
25
app/apps/export_app/resources/rules.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||
from apps.rules.models import (
|
||||
TransactionRule,
|
||||
TransactionRuleAction,
|
||||
UpdateOrCreateTransactionRuleAction,
|
||||
)
|
||||
|
||||
|
||||
class TransactionRuleResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionRule
|
||||
|
||||
|
||||
class TransactionRuleActionResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionRuleAction
|
||||
|
||||
|
||||
class UpdateOrCreateTransactionRuleResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = UpdateOrCreateTransactionRuleAction
|
||||
143
app/apps/export_app/resources/transactions.py
Normal file
143
app/apps/export_app/resources/transactions.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from import_export import fields, resources
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
|
||||
from apps.export_app.widgets.many_to_many import AutoCreateManyToManyWidget
|
||||
from apps.export_app.widgets.string import EmptyStringToNoneField
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionTag,
|
||||
TransactionEntity,
|
||||
RecurringTransaction,
|
||||
InstallmentPlan,
|
||||
)
|
||||
from apps.export_app.widgets.numbers import UniversalDecimalWidget
|
||||
|
||||
|
||||
class TransactionResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
internal_id = EmptyStringToNoneField(
|
||||
column_name="internal_id", attribute="internal_id"
|
||||
)
|
||||
|
||||
amount = fields.Field(
|
||||
attribute="amount",
|
||||
column_name="amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Transaction
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.all_objects.all()
|
||||
|
||||
|
||||
class TransactionTagResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionTag
|
||||
|
||||
|
||||
class TransactionEntityResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionEntity
|
||||
|
||||
|
||||
class TransactionCategoyResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = TransactionCategory
|
||||
|
||||
|
||||
class RecurringTransactionResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
amount = fields.Field(
|
||||
attribute="amount",
|
||||
column_name="amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RecurringTransaction
|
||||
|
||||
|
||||
class InstallmentPlanResource(resources.ModelResource):
|
||||
account = fields.Field(
|
||||
attribute="account",
|
||||
column_name="account",
|
||||
widget=ForeignKeyWidget(Account, "name"),
|
||||
)
|
||||
|
||||
category = fields.Field(
|
||||
attribute="category",
|
||||
column_name="category",
|
||||
widget=AutoCreateForeignKeyWidget(TransactionCategory, "name"),
|
||||
)
|
||||
|
||||
tags = fields.Field(
|
||||
attribute="tags",
|
||||
column_name="tags",
|
||||
widget=AutoCreateManyToManyWidget(TransactionTag, field="name"),
|
||||
)
|
||||
|
||||
entities = fields.Field(
|
||||
attribute="entities",
|
||||
column_name="entities",
|
||||
widget=AutoCreateManyToManyWidget(TransactionEntity, field="name"),
|
||||
)
|
||||
|
||||
installment_amount = fields.Field(
|
||||
attribute="installment_amount",
|
||||
column_name="installment_amount",
|
||||
widget=UniversalDecimalWidget(),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InstallmentPlan
|
||||
3
app/apps/export_app/tests.py
Normal file
3
app/apps/export_app/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
app/apps/export_app/urls.py
Normal file
8
app/apps/export_app/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
import apps.export_app.views as views
|
||||
|
||||
urlpatterns = [
|
||||
path("export/", views.export_index, name="export_index"),
|
||||
path("export/form/", views.export_form, name="export_form"),
|
||||
path("export/restore/", views.import_form, name="restore_form"),
|
||||
]
|
||||
284
app/apps/export_app/views.py
Normal file
284
app/apps/export_app/views.py
Normal file
@@ -0,0 +1,284 @@
|
||||
import logging
|
||||
import zipfile
|
||||
from io import BytesIO, TextIOWrapper
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from tablib import Dataset
|
||||
|
||||
from apps.export_app.forms import ExportForm, RestoreForm
|
||||
from apps.export_app.resources.accounts import AccountResource
|
||||
from apps.export_app.resources.transactions import (
|
||||
TransactionResource,
|
||||
TransactionTagResource,
|
||||
TransactionEntityResource,
|
||||
TransactionCategoyResource,
|
||||
InstallmentPlanResource,
|
||||
RecurringTransactionResource,
|
||||
)
|
||||
from apps.export_app.resources.currencies import (
|
||||
CurrencyResource,
|
||||
ExchangeRateResource,
|
||||
ExchangeRateServiceResource,
|
||||
)
|
||||
from apps.export_app.resources.rules import (
|
||||
TransactionRuleResource,
|
||||
TransactionRuleActionResource,
|
||||
UpdateOrCreateTransactionRuleResource,
|
||||
)
|
||||
from apps.export_app.resources.dca import (
|
||||
DCAStrategyResource,
|
||||
DCAEntryResource,
|
||||
)
|
||||
from apps.export_app.resources.import_app import (
|
||||
ImportProfileResource,
|
||||
)
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def export_index(request):
|
||||
return render(request, "export_app/pages/index.html")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def export_form(request):
|
||||
timestamp = timezone.localtime(timezone.now()).strftime("%Y-%m-%dT%H-%M-%S")
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExportForm(request.POST)
|
||||
if form.is_valid():
|
||||
zip_buffer = BytesIO()
|
||||
|
||||
export_accounts = form.cleaned_data.get("accounts", False)
|
||||
export_currencies = form.cleaned_data.get("currencies", False)
|
||||
export_transactions = form.cleaned_data.get("transactions", False)
|
||||
export_categories = form.cleaned_data.get("categories", False)
|
||||
export_tags = form.cleaned_data.get("tags", False)
|
||||
export_entities = form.cleaned_data.get("entities", False)
|
||||
export_installment_plans = form.cleaned_data.get("installment_plans", False)
|
||||
export_recurring_transactions = form.cleaned_data.get(
|
||||
"recurring_transactions", False
|
||||
)
|
||||
|
||||
export_exchange_rates_services = form.cleaned_data.get(
|
||||
"exchange_rates_services", False
|
||||
)
|
||||
export_exchange_rates = form.cleaned_data.get("exchange_rates", False)
|
||||
export_rules = form.cleaned_data.get("rules", False)
|
||||
export_dca = form.cleaned_data.get("dca", False)
|
||||
export_import_profiles = form.cleaned_data.get("import_profiles", False)
|
||||
|
||||
exports = []
|
||||
if export_accounts:
|
||||
exports.append((AccountResource().export(), "accounts"))
|
||||
if export_currencies:
|
||||
exports.append((CurrencyResource().export(), "currencies"))
|
||||
if export_transactions:
|
||||
exports.append((TransactionResource().export(), "transactions"))
|
||||
if export_categories:
|
||||
exports.append(
|
||||
(TransactionCategoyResource().export(), "transactions_categories")
|
||||
)
|
||||
if export_tags:
|
||||
exports.append((TransactionTagResource().export(), "transactions_tags"))
|
||||
if export_entities:
|
||||
exports.append(
|
||||
(TransactionEntityResource().export(), "transactions_entities")
|
||||
)
|
||||
if export_installment_plans:
|
||||
exports.append(
|
||||
(InstallmentPlanResource().export(), "installment_plans")
|
||||
)
|
||||
if export_recurring_transactions:
|
||||
exports.append(
|
||||
(RecurringTransactionResource().export(), "recurring_transactions")
|
||||
)
|
||||
if export_exchange_rates_services:
|
||||
exports.append(
|
||||
(ExchangeRateServiceResource().export(), "automatic_exchange_rates")
|
||||
)
|
||||
if export_exchange_rates:
|
||||
exports.append((ExchangeRateResource().export(), "exchange_rates"))
|
||||
if export_rules:
|
||||
exports.append(
|
||||
(TransactionRuleResource().export(), "transaction_rules")
|
||||
)
|
||||
exports.append(
|
||||
(
|
||||
TransactionRuleActionResource().export(),
|
||||
"transaction_rules_actions",
|
||||
)
|
||||
)
|
||||
exports.append(
|
||||
(
|
||||
UpdateOrCreateTransactionRuleResource().export(),
|
||||
"transaction_rules_update_or_create",
|
||||
)
|
||||
)
|
||||
if export_dca:
|
||||
exports.append((DCAStrategyResource().export(), "dca_strategies"))
|
||||
exports.append(
|
||||
(
|
||||
DCAEntryResource().export(),
|
||||
"dca_entries",
|
||||
)
|
||||
)
|
||||
if export_import_profiles:
|
||||
exports.append((ImportProfileResource().export(), "import_profiles"))
|
||||
|
||||
if len(exports) >= 2:
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for dataset, name in exports:
|
||||
zip_file.writestr(f"{name}.csv", dataset.csv)
|
||||
|
||||
response = HttpResponse(
|
||||
zip_buffer.getvalue(),
|
||||
content_type="application/zip",
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export.zip"',
|
||||
},
|
||||
)
|
||||
return response
|
||||
elif len(exports) == 1:
|
||||
dataset, name = exports[0]
|
||||
|
||||
response = HttpResponse(
|
||||
dataset.csv,
|
||||
content_type="text/csv",
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
"Content-Disposition": f'attachment; filename="{timestamp}_WYGIWYH_export_{name}.csv"',
|
||||
},
|
||||
)
|
||||
return response
|
||||
else:
|
||||
return HttpResponse(
|
||||
_("You have to select at least one export"),
|
||||
)
|
||||
|
||||
else:
|
||||
form = ExportForm()
|
||||
|
||||
return render(request, "export_app/fragments/export.html", context={"form": form})
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def import_form(request):
|
||||
if request.method == "POST":
|
||||
form = RestoreForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
try:
|
||||
process_imports(request, form.cleaned_data)
|
||||
messages.success(request, _("Data restored successfully"))
|
||||
return HttpResponse(
|
||||
status=204,
|
||||
headers={
|
||||
"HX-Trigger": "hide_offcanvas, updated",
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Error importing", exc_info=e)
|
||||
messages.error(
|
||||
request,
|
||||
_(
|
||||
"There was an error restoring your data. Check the logs for more details."
|
||||
),
|
||||
)
|
||||
else:
|
||||
form = RestoreForm()
|
||||
|
||||
response = render(request, "export_app/fragments/restore.html", {"form": form})
|
||||
response["HX-Trigger"] = "updated"
|
||||
return response
|
||||
|
||||
|
||||
def process_imports(request, cleaned_data):
|
||||
# Define import order to handle dependencies
|
||||
import_order = [
|
||||
("currencies", CurrencyResource),
|
||||
(
|
||||
"currencies",
|
||||
CurrencyResource,
|
||||
), # We do a double pass because exchange_currency may not exist when currency is initially created
|
||||
("accounts", AccountResource),
|
||||
("transactions_categories", TransactionCategoyResource),
|
||||
("transactions_tags", TransactionTagResource),
|
||||
("transactions_entities", TransactionEntityResource),
|
||||
("automatic_exchange_rates", ExchangeRateServiceResource),
|
||||
("exchange_rates", ExchangeRateResource),
|
||||
("installment_plans", InstallmentPlanResource),
|
||||
("recurring_transactions", RecurringTransactionResource),
|
||||
("transactions", TransactionResource),
|
||||
("dca_strategies", DCAStrategyResource),
|
||||
("dca_entries", DCAEntryResource),
|
||||
("import_profiles", ImportProfileResource),
|
||||
("transaction_rules", TransactionRuleResource),
|
||||
("transaction_rules_actions", TransactionRuleActionResource),
|
||||
("transaction_rules_update_or_create", UpdateOrCreateTransactionRuleResource),
|
||||
]
|
||||
|
||||
def import_dataset(content, resource_class, field_name):
|
||||
try:
|
||||
# Create a new resource instance
|
||||
resource = resource_class()
|
||||
|
||||
# Create dataset from CSV content
|
||||
dataset = Dataset()
|
||||
dataset.load(content, format="csv")
|
||||
|
||||
# Perform the import
|
||||
result = resource.import_data(
|
||||
dataset,
|
||||
dry_run=False,
|
||||
raise_errors=True,
|
||||
collect_failed_rows=True,
|
||||
use_transactions=False,
|
||||
skip_unchanged=True,
|
||||
)
|
||||
|
||||
if result.has_errors():
|
||||
raise ImportError(f"Failed rows: {result.failed_dataset}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error importing {field_name}: {str(e)}")
|
||||
raise ImportError(f"Error importing {field_name}: {str(e)}")
|
||||
|
||||
with transaction.atomic():
|
||||
files = {}
|
||||
|
||||
if zip_file := cleaned_data.get("zip_file"):
|
||||
# Process ZIP file
|
||||
with zipfile.ZipFile(zip_file) as z:
|
||||
for filename in z.namelist():
|
||||
name = filename.replace(".csv", "")
|
||||
with z.open(filename) as f:
|
||||
content = f.read().decode("utf-8")
|
||||
|
||||
files[name] = content
|
||||
|
||||
for field_name, resource_class in import_order:
|
||||
if field_name in files.keys():
|
||||
content = files[field_name]
|
||||
import_dataset(content, resource_class, field_name)
|
||||
else:
|
||||
# Process individual files
|
||||
for field_name, resource_class in import_order:
|
||||
if csv_file := cleaned_data.get(field_name):
|
||||
content = csv_file.read().decode("utf-8")
|
||||
import_dataset(content, resource_class, field_name)
|
||||
0
app/apps/export_app/widgets/__init__.py
Normal file
0
app/apps/export_app/widgets/__init__.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
22
app/apps/export_app/widgets/foreign_key.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
|
||||
class AutoCreateForeignKeyWidget(ForeignKeyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if value:
|
||||
try:
|
||||
return super().clean(value, row, **kwargs)
|
||||
except self.model.DoesNotExist:
|
||||
return self.model.objects.create(name=value)
|
||||
return None
|
||||
|
||||
|
||||
class SkipMissingForeignKeyWidget(ForeignKeyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
try:
|
||||
return super().clean(value, row, *args, **kwargs)
|
||||
except self.model.DoesNotExist:
|
||||
return None
|
||||
21
app/apps/export_app/widgets/many_to_many.py
Normal file
21
app/apps/export_app/widgets/many_to_many.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from import_export.widgets import ManyToManyWidget
|
||||
|
||||
|
||||
class AutoCreateManyToManyWidget(ManyToManyWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if not value:
|
||||
return []
|
||||
|
||||
values = value.split(self.separator)
|
||||
cleaned_values = []
|
||||
|
||||
for val in values:
|
||||
val = val.strip()
|
||||
if val:
|
||||
try:
|
||||
obj = self.model.objects.get(**{self.field: val})
|
||||
except self.model.DoesNotExist:
|
||||
obj = self.model.objects.create(name=val)
|
||||
cleaned_values.append(obj)
|
||||
|
||||
return cleaned_values
|
||||
18
app/apps/export_app/widgets/numbers.py
Normal file
18
app/apps/export_app/widgets/numbers.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from import_export.widgets import NumberWidget
|
||||
|
||||
|
||||
class UniversalDecimalWidget(NumberWidget):
|
||||
def clean(self, value, row=None, *args, **kwargs):
|
||||
if self.is_empty(value):
|
||||
return None
|
||||
# Replace comma with dot if present
|
||||
if isinstance(value, str):
|
||||
value = value.replace(",", ".")
|
||||
return Decimal(str(value))
|
||||
|
||||
def render(self, value, obj=None, **kwargs):
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).replace(",", ".")
|
||||
7
app/apps/export_app/widgets/string.py
Normal file
7
app/apps/export_app/widgets/string.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from import_export import fields
|
||||
|
||||
|
||||
class EmptyStringToNoneField(fields.Field):
|
||||
def clean(self, data, **kwargs):
|
||||
value = super().clean(data)
|
||||
return None if value == "" else value
|
||||
@@ -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")
|
||||
|
||||
0
app/apps/insights/__init__.py
Normal file
0
app/apps/insights/__init__.py
Normal file
3
app/apps/insights/admin.py
Normal file
3
app/apps/insights/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
6
app/apps/insights/apps.py
Normal file
6
app/apps/insights/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InsightsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.insights"
|
||||
131
app/apps/insights/forms.py
Normal file
131
app/apps/insights/forms.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import Layout, Field, Row, Column
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from apps.common.widgets.datepicker import (
|
||||
AirMonthYearPickerInput,
|
||||
AirYearPickerInput,
|
||||
AirDatePickerInput,
|
||||
)
|
||||
from apps.transactions.models import TransactionCategory
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
|
||||
|
||||
class SingleMonthForm(forms.Form):
|
||||
month = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(Field("month"))
|
||||
|
||||
|
||||
class SingleYearForm(forms.Form):
|
||||
year = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(Field("year"))
|
||||
|
||||
|
||||
class MonthRangeForm(forms.Form):
|
||||
month_from = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
month_to = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("month_from", css_class="form-group col-md-6"),
|
||||
Column("month_to", css_class="form-group col-md-6"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class YearRangeForm(forms.Form):
|
||||
year_from = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
year_to = forms.DateField(
|
||||
widget=AirYearPickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("year_from", css_class="form-group col-md-6"),
|
||||
Column("year_to", css_class="form-group col-md-6"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DateRangeForm(forms.Form):
|
||||
date_from = forms.DateField(
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
date_to = forms.DateField(
|
||||
widget=AirDatePickerInput(clear_button=False), label="", required=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout(
|
||||
Row(
|
||||
Column("date_from", css_class="form-group col-md-6"),
|
||||
Column("date_to", css_class="form-group col-md-6"),
|
||||
css_class="mb-0",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CategoryForm(forms.Form):
|
||||
category = forms.ModelChoiceField(
|
||||
required=False,
|
||||
label=_("Category"),
|
||||
empty_label=_("Uncategorized"),
|
||||
queryset=TransactionCategory.objects.filter(active=True),
|
||||
widget=TomSelect(clear_button=True),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.disable_csrf = True
|
||||
|
||||
self.helper.layout = Layout("category")
|
||||
0
app/apps/insights/migrations/__init__.py
Normal file
0
app/apps/insights/migrations/__init__.py
Normal file
3
app/apps/insights/models.py
Normal file
3
app/apps/insights/models.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
3
app/apps/insights/tests.py
Normal file
3
app/apps/insights/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
47
app/apps/insights/urls.py
Normal file
47
app/apps/insights/urls.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path("insights/", views.index, name="insights_index"),
|
||||
path(
|
||||
"insights/sankey/account/",
|
||||
views.sankey_by_account,
|
||||
name="insights_sankey_by_account",
|
||||
),
|
||||
path(
|
||||
"insights/sankey/currency/",
|
||||
views.sankey_by_currency,
|
||||
name="insights_sankey_by_currency",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/",
|
||||
views.category_explorer_index,
|
||||
name="category_explorer_index",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/account/",
|
||||
views.category_sum_by_account,
|
||||
name="category_sum_by_account",
|
||||
),
|
||||
path(
|
||||
"insights/category-explorer/currency/",
|
||||
views.category_sum_by_currency,
|
||||
name="category_sum_by_currency",
|
||||
),
|
||||
path(
|
||||
"insights/category-overview/",
|
||||
views.category_overview,
|
||||
name="category_overview",
|
||||
),
|
||||
path(
|
||||
"insights/late-transactions/",
|
||||
views.late_transactions,
|
||||
name="insights_late_transactions",
|
||||
),
|
||||
path(
|
||||
"insights/latest-transactions/",
|
||||
views.latest_transactions,
|
||||
name="insights_latest_transactions",
|
||||
),
|
||||
]
|
||||
0
app/apps/insights/utils/__init__.py
Normal file
0
app/apps/insights/utils/__init__.py
Normal file
161
app/apps/insights/utils/category_explorer.py
Normal file
161
app/apps/insights/utils/category_explorer.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from django.db.models import Sum, Case, When, F, DecimalField, Value
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def get_category_sums_by_account(queryset, category=None):
|
||||
"""
|
||||
Returns income/expense sums per account for a specific category.
|
||||
"""
|
||||
sums = (
|
||||
queryset.filter(category=category)
|
||||
.values("account__name")
|
||||
.annotate(
|
||||
current_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
current_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=True, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=False, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__name")
|
||||
)
|
||||
|
||||
return {
|
||||
"labels": [item["account__name"] for item in sums],
|
||||
"datasets": [
|
||||
{
|
||||
"label": _("Current Income"),
|
||||
"data": [float(item["current_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Current Expenses"),
|
||||
"data": [float(item["current_expense"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Income"),
|
||||
"data": [float(item["projected_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Expenses"),
|
||||
"data": [float(item["projected_expense"]) for item in sums],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_category_sums_by_currency(queryset, category=None):
|
||||
"""
|
||||
Returns income/expense sums per currency for a specific category.
|
||||
"""
|
||||
sums = (
|
||||
queryset.filter(category=category)
|
||||
.values("account__currency__name")
|
||||
.annotate(
|
||||
current_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
current_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=True, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_income=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="IN", is_paid=False, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
projected_expense=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type="EX", is_paid=False, then=-F("amount")),
|
||||
default=Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
)
|
||||
),
|
||||
Value(0),
|
||||
output_field=DecimalField(max_digits=42, decimal_places=30),
|
||||
),
|
||||
)
|
||||
.order_by("account__currency__name")
|
||||
)
|
||||
|
||||
return {
|
||||
"labels": [item["account__currency__name"] for item in sums],
|
||||
"datasets": [
|
||||
{
|
||||
"label": _("Current Income"),
|
||||
"data": [float(item["current_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Current Expenses"),
|
||||
"data": [float(item["current_expense"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Income"),
|
||||
"data": [float(item["projected_income"]) for item in sums],
|
||||
},
|
||||
{
|
||||
"label": _("Projected Expenses"),
|
||||
"data": [float(item["projected_expense"]) for item in sums],
|
||||
},
|
||||
],
|
||||
}
|
||||
165
app/apps/insights/utils/category_overview.py
Normal file
165
app/apps/insights/utils/category_overview.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import Sum, Case, When, Value, DecimalField
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
|
||||
|
||||
def get_categories_totals(transactions_queryset, ignore_empty=False):
|
||||
# Get metrics for each category and currency in a single query
|
||||
category_currency_metrics = (
|
||||
transactions_queryset.values(
|
||||
"category",
|
||||
"category__name",
|
||||
"account__currency",
|
||||
"account__currency__code",
|
||||
"account__currency__name",
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
)
|
||||
.annotate(
|
||||
expense_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.EXPENSE, is_paid=True, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
expense_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.EXPENSE, is_paid=False, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_current=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(type=Transaction.Type.INCOME, is_paid=True, then="amount"),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
income_projected=Coalesce(
|
||||
Sum(
|
||||
Case(
|
||||
When(
|
||||
type=Transaction.Type.INCOME, is_paid=False, then="amount"
|
||||
),
|
||||
default=Value(0),
|
||||
output_field=models.DecimalField(),
|
||||
)
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
)
|
||||
.order_by("category__name")
|
||||
)
|
||||
|
||||
# Process the results to structure by category
|
||||
result = {}
|
||||
|
||||
for metric in category_currency_metrics:
|
||||
# Skip empty categories if ignore_empty is True
|
||||
if ignore_empty and all(
|
||||
metric[field] == Decimal("0")
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
]
|
||||
):
|
||||
continue
|
||||
|
||||
# Calculate derived totals
|
||||
total_current = metric["income_current"] - metric["expense_current"]
|
||||
total_projected = metric["income_projected"] - metric["expense_projected"]
|
||||
total_income = metric["income_current"] + metric["income_projected"]
|
||||
total_expense = metric["expense_current"] + metric["expense_projected"]
|
||||
total_final = total_current + total_projected
|
||||
|
||||
category_id = metric["category"]
|
||||
currency_id = metric["account__currency"]
|
||||
|
||||
if category_id not in result:
|
||||
result[category_id] = {"name": metric["category__name"], "currencies": {}}
|
||||
|
||||
# Add currency data
|
||||
currency_data = {
|
||||
"currency": {
|
||||
"code": metric["account__currency__code"],
|
||||
"name": metric["account__currency__name"],
|
||||
"decimal_places": metric["account__currency__decimal_places"],
|
||||
"prefix": metric["account__currency__prefix"],
|
||||
"suffix": metric["account__currency__suffix"],
|
||||
},
|
||||
"expense_current": metric["expense_current"],
|
||||
"expense_projected": metric["expense_projected"],
|
||||
"total_expense": total_expense,
|
||||
"income_current": metric["income_current"],
|
||||
"income_projected": metric["income_projected"],
|
||||
"total_income": total_income,
|
||||
"total_current": total_current,
|
||||
"total_projected": total_projected,
|
||||
"total_final": total_final,
|
||||
}
|
||||
|
||||
# Add exchanged values if exchange_currency exists
|
||||
if metric["account__currency__exchange_currency"]:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=metric["account__currency__exchange_currency"]
|
||||
)
|
||||
|
||||
exchanged = {}
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
"income_current",
|
||||
"income_projected",
|
||||
"total_income",
|
||||
"total_expense",
|
||||
"total_current",
|
||||
"total_projected",
|
||||
"total_final",
|
||||
]:
|
||||
amount, prefix, suffix, decimal_places = convert(
|
||||
amount=currency_data[field],
|
||||
from_currency=from_currency,
|
||||
to_currency=exchange_currency,
|
||||
)
|
||||
if amount is not None:
|
||||
exchanged[field] = amount
|
||||
if "currency" not in exchanged:
|
||||
exchanged["currency"] = {
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
"code": exchange_currency.code,
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
if exchanged:
|
||||
currency_data["exchanged"] = exchanged
|
||||
|
||||
result[category_id]["currencies"][currency_id] = currency_data
|
||||
|
||||
return result
|
||||
280
app/apps/insights/utils/sankey.py
Normal file
280
app/apps/insights/utils/sankey.py
Normal file
@@ -0,0 +1,280 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, TypedDict
|
||||
|
||||
|
||||
class SankeyNode(TypedDict):
|
||||
name: str
|
||||
|
||||
|
||||
class SankeyFlow(TypedDict):
|
||||
from_node: str
|
||||
to_node: str
|
||||
flow: float
|
||||
currency: Dict
|
||||
original_amount: float
|
||||
percentage: float
|
||||
|
||||
|
||||
def generate_sankey_data_by_account(transactions_queryset):
|
||||
"""
|
||||
Generates Sankey diagram data from transaction queryset using account as intermediary.
|
||||
"""
|
||||
nodes: Dict[str, Dict] = {}
|
||||
flows: List[SankeyFlow] = []
|
||||
|
||||
# Aggregate transactions
|
||||
income_data = {} # {(category, currency, account) -> amount}
|
||||
expense_data = {} # {(category, currency, account) -> amount}
|
||||
total_income_by_currency = {} # {currency -> amount}
|
||||
total_expense_by_currency = {} # {currency -> amount}
|
||||
total_volume_by_currency = {} # {currency -> amount}
|
||||
|
||||
for transaction in transactions_queryset:
|
||||
currency = transaction.account.currency
|
||||
account = transaction.account
|
||||
category = transaction.category or _("Uncategorized")
|
||||
key = (category, currency, account)
|
||||
amount = transaction.amount
|
||||
|
||||
if transaction.type == "IN":
|
||||
income_data[key] = income_data.get(key, Decimal("0")) + amount
|
||||
total_income_by_currency[currency] = (
|
||||
total_income_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
else:
|
||||
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
|
||||
total_expense_by_currency[currency] = (
|
||||
total_expense_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
total_volume_by_currency[currency] = (
|
||||
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
unique_accounts = {
|
||||
account_id: idx
|
||||
for idx, account_id in enumerate(
|
||||
transactions_queryset.values_list("account", flat=True).distinct()
|
||||
)
|
||||
}
|
||||
|
||||
def get_node_priority(node_id: str) -> int:
|
||||
"""Get priority based on the account ID embedded in the node ID."""
|
||||
account_id = int(node_id.split("_")[-1])
|
||||
return unique_accounts[account_id]
|
||||
|
||||
def get_node_id(node_type: str, name: str, account_id: int) -> str:
|
||||
"""Generate unique node ID."""
|
||||
return f"{node_type}_{name}_{account_id}".lower().replace(" ", "_")
|
||||
|
||||
def add_node(node_id: str, display_name: str) -> None:
|
||||
"""Add node with ID, display name and priority."""
|
||||
nodes[node_id] = {
|
||||
"id": node_id,
|
||||
"name": display_name,
|
||||
"priority": get_node_priority(node_id),
|
||||
}
|
||||
|
||||
def add_flow(
|
||||
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||
) -> None:
|
||||
"""
|
||||
Add flow with percentage based on total transaction volume for the specific currency.
|
||||
"""
|
||||
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
|
||||
percentage = (amount / total_volume) * 100 if total_volume else 0
|
||||
scaled_flow = percentage / 100
|
||||
|
||||
flows.append(
|
||||
{
|
||||
"from_node": from_node_id,
|
||||
"to_node": to_node_id,
|
||||
"flow": float(scaled_flow),
|
||||
"currency": {
|
||||
"code": currency.code,
|
||||
"prefix": currency.prefix,
|
||||
"suffix": currency.suffix,
|
||||
"decimal_places": currency.decimal_places,
|
||||
},
|
||||
"original_amount": float(amount),
|
||||
"percentage": float(percentage),
|
||||
}
|
||||
)
|
||||
|
||||
# Process income
|
||||
for (category, currency, account), amount in income_data.items():
|
||||
category_node_id = get_node_id("income", category, account.id)
|
||||
account_node_id = get_node_id("account", account.name, account.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(account_node_id, account.name)
|
||||
add_flow(category_node_id, account_node_id, amount, currency, is_income=True)
|
||||
|
||||
# Process expenses
|
||||
for (category, currency, account), amount in expense_data.items():
|
||||
category_node_id = get_node_id("expense", category, account.id)
|
||||
account_node_id = get_node_id("account", account.name, account.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(account_node_id, account.name)
|
||||
add_flow(account_node_id, category_node_id, amount, currency, is_income=False)
|
||||
|
||||
# Calculate and add savings flows
|
||||
savings_data = {} # {(account, currency) -> amount}
|
||||
for (category, currency, account), amount in income_data.items():
|
||||
key = (account, currency)
|
||||
savings_data[key] = savings_data.get(key, Decimal("0")) + amount
|
||||
for (category, currency, account), amount in expense_data.items():
|
||||
key = (account, currency)
|
||||
savings_data[key] = savings_data.get(key, Decimal("0")) - amount
|
||||
|
||||
for (account, currency), amount in savings_data.items():
|
||||
if amount > 0:
|
||||
account_node_id = get_node_id("account", account.name, account.id)
|
||||
savings_node_id = get_node_id("savings", _("Saved"), account.id)
|
||||
add_node(savings_node_id, str(_("Saved")))
|
||||
add_flow(account_node_id, savings_node_id, amount, currency, is_income=True)
|
||||
|
||||
# Calculate total across all currencies (for reference only)
|
||||
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
|
||||
|
||||
return {
|
||||
"nodes": list(nodes.values()),
|
||||
"flows": flows,
|
||||
"total_amount": total_amount,
|
||||
"total_by_currency": {
|
||||
curr.code: float(amount)
|
||||
for curr, amount in total_income_by_currency.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def generate_sankey_data_by_currency(transactions_queryset):
|
||||
"""
|
||||
Generates Sankey diagram data from transaction queryset, using currency as intermediary.
|
||||
"""
|
||||
nodes: Dict[str, Dict] = {}
|
||||
flows: List[SankeyFlow] = []
|
||||
|
||||
# Aggregate transactions
|
||||
income_data = {} # {(category, currency) -> amount}
|
||||
expense_data = {} # {(category, currency) -> amount}
|
||||
total_income_by_currency = {} # {currency -> amount}
|
||||
total_expense_by_currency = {} # {currency -> amount}
|
||||
total_volume_by_currency = {} # {currency -> amount}
|
||||
|
||||
for transaction in transactions_queryset:
|
||||
currency = transaction.account.currency
|
||||
category = transaction.category or _("Uncategorized")
|
||||
key = (category, currency)
|
||||
amount = transaction.amount
|
||||
|
||||
if transaction.type == "IN":
|
||||
income_data[key] = income_data.get(key, Decimal("0")) + amount
|
||||
total_income_by_currency[currency] = (
|
||||
total_income_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
else:
|
||||
expense_data[key] = expense_data.get(key, Decimal("0")) + amount
|
||||
total_expense_by_currency[currency] = (
|
||||
total_expense_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
total_volume_by_currency[currency] = (
|
||||
total_volume_by_currency.get(currency, Decimal("0")) + amount
|
||||
)
|
||||
|
||||
unique_currencies = {
|
||||
currency_id: idx
|
||||
for idx, currency_id in enumerate(
|
||||
transactions_queryset.values_list("account__currency", flat=True).distinct()
|
||||
)
|
||||
}
|
||||
|
||||
def get_node_priority(node_id: str) -> int:
|
||||
"""Get priority based on the currency ID embedded in the node ID."""
|
||||
currency_id = int(node_id.split("_")[-1])
|
||||
return unique_currencies[currency_id]
|
||||
|
||||
def get_node_id(node_type: str, name: str, currency_id: int) -> str:
|
||||
"""Generate unique node ID including currency information."""
|
||||
return f"{node_type}_{name}_{currency_id}".lower().replace(" ", "_")
|
||||
|
||||
def add_node(node_id: str, display_name: str) -> None:
|
||||
"""Add node with ID, display name and priority."""
|
||||
nodes[node_id] = {
|
||||
"id": node_id,
|
||||
"name": display_name,
|
||||
"priority": get_node_priority(node_id),
|
||||
}
|
||||
|
||||
def add_flow(
|
||||
from_node_id: str, to_node_id: str, amount: Decimal, currency, is_income: bool
|
||||
) -> None:
|
||||
"""
|
||||
Add flow with percentage based on total transaction volume for the specific currency.
|
||||
"""
|
||||
total_volume = total_volume_by_currency.get(currency, Decimal("0"))
|
||||
percentage = (amount / total_volume) * 100 if total_volume else 0
|
||||
scaled_flow = percentage / 100
|
||||
|
||||
flows.append(
|
||||
{
|
||||
"from_node": from_node_id,
|
||||
"to_node": to_node_id,
|
||||
"flow": float(scaled_flow),
|
||||
"currency": {
|
||||
"code": currency.code,
|
||||
"name": currency.name,
|
||||
"prefix": currency.prefix,
|
||||
"suffix": currency.suffix,
|
||||
"decimal_places": currency.decimal_places,
|
||||
},
|
||||
"original_amount": float(amount),
|
||||
"percentage": float(percentage),
|
||||
}
|
||||
)
|
||||
|
||||
# Process income
|
||||
for (category, currency), amount in income_data.items():
|
||||
category_node_id = get_node_id("income", category, currency.id)
|
||||
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(currency_node_id, currency.name)
|
||||
add_flow(category_node_id, currency_node_id, amount, currency, is_income=True)
|
||||
|
||||
# Process expenses
|
||||
for (category, currency), amount in expense_data.items():
|
||||
category_node_id = get_node_id("expense", category, currency.id)
|
||||
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||
add_node(category_node_id, str(category))
|
||||
add_node(currency_node_id, currency.name)
|
||||
add_flow(currency_node_id, category_node_id, amount, currency, is_income=False)
|
||||
|
||||
# Calculate and add savings flows
|
||||
savings_data = {} # {currency -> amount}
|
||||
for (category, currency), amount in income_data.items():
|
||||
savings_data[currency] = savings_data.get(currency, Decimal("0")) + amount
|
||||
for (category, currency), amount in expense_data.items():
|
||||
savings_data[currency] = savings_data.get(currency, Decimal("0")) - amount
|
||||
|
||||
for currency, amount in savings_data.items():
|
||||
if amount > 0:
|
||||
currency_node_id = get_node_id("currency", currency.name, currency.id)
|
||||
savings_node_id = get_node_id("savings", _("Saved"), currency.id)
|
||||
add_node(savings_node_id, str(_("Saved")))
|
||||
add_flow(
|
||||
currency_node_id, savings_node_id, amount, currency, is_income=True
|
||||
)
|
||||
|
||||
# Calculate total across all currencies (for reference only)
|
||||
total_amount = sum(float(amount) for amount in total_income_by_currency.values())
|
||||
|
||||
return {
|
||||
"nodes": list(nodes.values()),
|
||||
"flows": flows,
|
||||
"total_amount": total_amount,
|
||||
"total_by_currency": {
|
||||
curr.name: float(amount)
|
||||
for curr, amount in total_income_by_currency.items()
|
||||
},
|
||||
}
|
||||
96
app/apps/insights/utils/transactions.py
Normal file
96
app/apps/insights/utils/transactions.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.insights.forms import (
|
||||
SingleMonthForm,
|
||||
SingleYearForm,
|
||||
MonthRangeForm,
|
||||
YearRangeForm,
|
||||
DateRangeForm,
|
||||
)
|
||||
|
||||
|
||||
def get_transactions(request, include_unpaid=True, include_silent=False):
|
||||
transactions = Transaction.objects.all()
|
||||
|
||||
filter_type = request.GET.get("type", None)
|
||||
|
||||
if filter_type is not None:
|
||||
if filter_type == "month":
|
||||
form = SingleMonthForm(request.GET)
|
||||
|
||||
if form.is_valid():
|
||||
month = form.cleaned_data["month"].replace(day=1)
|
||||
else:
|
||||
month = timezone.localdate(timezone.now()).replace(day=1)
|
||||
|
||||
transactions = transactions.filter(
|
||||
reference_date__month=month.month, reference_date__year=month.year
|
||||
)
|
||||
elif filter_type == "year":
|
||||
form = SingleYearForm(request.GET)
|
||||
if form.is_valid():
|
||||
year = form.cleaned_data["year"].replace(day=1, month=1)
|
||||
else:
|
||||
year = timezone.localdate(timezone.now()).replace(day=1, month=1)
|
||||
|
||||
transactions = transactions.filter(reference_date__year=year.year)
|
||||
elif filter_type == "month-range":
|
||||
form = MonthRangeForm(request.GET)
|
||||
if form.is_valid():
|
||||
month_from = form.cleaned_data["month_from"].replace(day=1)
|
||||
month_to = form.cleaned_data["month_to"].replace(day=1)
|
||||
else:
|
||||
month_from = timezone.localdate(timezone.now()).replace(day=1)
|
||||
month_to = (
|
||||
timezone.localdate(timezone.now()) + relativedelta(months=1)
|
||||
).replace(day=1)
|
||||
|
||||
transactions = transactions.filter(
|
||||
reference_date__gte=month_from,
|
||||
reference_date__lte=month_to,
|
||||
)
|
||||
elif filter_type == "year-range":
|
||||
form = YearRangeForm(request.GET)
|
||||
if form.is_valid():
|
||||
year_from = form.cleaned_data["year_from"].replace(day=1, month=1)
|
||||
year_to = form.cleaned_data["year_to"].replace(day=31, month=12)
|
||||
else:
|
||||
year_from = timezone.localdate(timezone.now()).replace(day=1, month=1)
|
||||
year_to = (
|
||||
timezone.localdate(timezone.now()) + relativedelta(years=1)
|
||||
).replace(day=31, month=12)
|
||||
|
||||
transactions = transactions.filter(
|
||||
reference_date__gte=year_from,
|
||||
reference_date__lte=year_to,
|
||||
)
|
||||
elif filter_type == "date-range":
|
||||
form = DateRangeForm(request.GET)
|
||||
if form.is_valid():
|
||||
date_from = form.cleaned_data["date_from"]
|
||||
date_to = form.cleaned_data["date_to"]
|
||||
else:
|
||||
date_from = timezone.localdate(timezone.now())
|
||||
date_to = timezone.localdate(timezone.now()) + relativedelta(months=1)
|
||||
|
||||
transactions = transactions.filter(
|
||||
date__gte=date_from,
|
||||
date__lte=date_to,
|
||||
)
|
||||
else: # Default to current month
|
||||
month = timezone.localdate(timezone.now())
|
||||
transactions = transactions.filter(
|
||||
reference_date__month=month.month, reference_date__year=month.year
|
||||
)
|
||||
|
||||
if not include_unpaid:
|
||||
transactions = transactions.filter(is_paid=True)
|
||||
|
||||
if not include_silent:
|
||||
transactions = transactions.exclude(Q(category__mute=True) & ~Q(category=None))
|
||||
|
||||
return transactions
|
||||
211
app/apps/insights/views.py
Normal file
211
app/apps/insights/views.py
Normal file
@@ -0,0 +1,211 @@
|
||||
import decimal
|
||||
import json
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.shortcuts import render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.insights.forms import (
|
||||
SingleMonthForm,
|
||||
SingleYearForm,
|
||||
MonthRangeForm,
|
||||
YearRangeForm,
|
||||
DateRangeForm,
|
||||
CategoryForm,
|
||||
)
|
||||
from apps.insights.utils.category_explorer import (
|
||||
get_category_sums_by_account,
|
||||
get_category_sums_by_currency,
|
||||
)
|
||||
from apps.insights.utils.sankey import (
|
||||
generate_sankey_data_by_account,
|
||||
generate_sankey_data_by_currency,
|
||||
)
|
||||
from apps.insights.utils.transactions import get_transactions
|
||||
from apps.transactions.models import TransactionCategory, Transaction
|
||||
from apps.insights.utils.category_overview import get_categories_totals
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def index(request):
|
||||
date = timezone.localdate(timezone.now())
|
||||
month_form = SingleMonthForm(initial={"month": date.replace(day=1)})
|
||||
year_form = SingleYearForm(initial={"year": date.replace(day=1)})
|
||||
month_range_form = MonthRangeForm(
|
||||
initial={
|
||||
"month_from": date.replace(day=1),
|
||||
"month_to": date.replace(day=1) + relativedelta(months=1),
|
||||
}
|
||||
)
|
||||
year_range_form = YearRangeForm(
|
||||
initial={
|
||||
"year_from": date.replace(day=1, month=1),
|
||||
"year_to": date.replace(day=1, month=1) + relativedelta(years=1),
|
||||
}
|
||||
)
|
||||
date_range_form = DateRangeForm(
|
||||
initial={
|
||||
"date_from": date,
|
||||
"date_to": date + relativedelta(months=1),
|
||||
}
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/pages/index.html",
|
||||
context={
|
||||
"month_form": month_form,
|
||||
"year_form": year_form,
|
||||
"month_range_form": month_range_form,
|
||||
"year_range_form": year_range_form,
|
||||
"date_range_form": date_range_form,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_account(request):
|
||||
# Get filtered transactions
|
||||
|
||||
transactions = get_transactions(request)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_account(transactions)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/sankey.html",
|
||||
{"sankey_data": sankey_data, "type": "account"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def sankey_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request)
|
||||
|
||||
# Generate Sankey data
|
||||
sankey_data = generate_sankey_data_by_currency(transactions)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/sankey.html",
|
||||
{"sankey_data": sankey_data, "type": "currency"},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_explorer_index(request):
|
||||
category_form = CategoryForm()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/index.html",
|
||||
{"category_form": category_form},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_sum_by_account(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
category = request.GET.get("category")
|
||||
|
||||
if category:
|
||||
category = TransactionCategory.objects.get(id=category)
|
||||
|
||||
# Generate data
|
||||
account_data = get_category_sums_by_account(transactions, category)
|
||||
else:
|
||||
account_data = get_category_sums_by_account(transactions, category=None)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/charts/account.html",
|
||||
{"account_data": account_data},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_sum_by_currency(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
category = request.GET.get("category")
|
||||
|
||||
if category:
|
||||
category = TransactionCategory.objects.get(id=category)
|
||||
|
||||
# Generate data
|
||||
currency_data = get_category_sums_by_currency(transactions, category)
|
||||
else:
|
||||
currency_data = get_category_sums_by_currency(transactions, category=None)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_explorer/charts/currency.html",
|
||||
{"currency_data": currency_data},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def category_overview(request):
|
||||
# Get filtered transactions
|
||||
transactions = get_transactions(request, include_silent=True)
|
||||
|
||||
total_table = get_categories_totals(
|
||||
transactions_queryset=transactions, ignore_empty=False
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/category_overview/index.html",
|
||||
{"total_table": total_table},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def latest_transactions(request):
|
||||
limit = timezone.now() - relativedelta(days=3)
|
||||
transactions = Transaction.objects.filter(created_at__gte=limit).order_by("-id")[
|
||||
:30
|
||||
]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/latest_transactions.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def late_transactions(request):
|
||||
now = timezone.localdate(timezone.now())
|
||||
transactions = Transaction.objects.filter(is_paid=False, date__lt=now)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"insights/fragments/late_transactions.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
0
app/apps/mini_tools/utils/__init__.py
Normal file
0
app/apps/mini_tools/utils/__init__.py
Normal file
85
app/apps/mini_tools/utils/exchange_rate_map.py
Normal file
85
app/apps/mini_tools/utils/exchange_rate_map.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from typing import Dict
|
||||
|
||||
from django.db.models import Func, F, Value
|
||||
from django.db.models.functions import Extract
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.currencies.models import ExchangeRate
|
||||
|
||||
|
||||
def get_currency_exchange_map(date=None) -> Dict[str, dict]:
|
||||
"""
|
||||
Creates a nested dictionary of exchange rates and currency information.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'BTC': {
|
||||
'decimal_places': 8,
|
||||
'prefix': '₿',
|
||||
'suffix': '',
|
||||
'rates': {'USD': Decimal('34000.00'), 'EUR': Decimal('31000.00')}
|
||||
},
|
||||
'USD': {
|
||||
'decimal_places': 2,
|
||||
'prefix': '$',
|
||||
'suffix': '',
|
||||
'rates': {'BTC': Decimal('0.0000294'), 'EUR': Decimal('0.91')}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if date is None:
|
||||
date = timezone.localtime(timezone.now())
|
||||
|
||||
# Get all exchange rates for the closest date
|
||||
exchange_rates = (
|
||||
ExchangeRate.objects.select_related(
|
||||
"from_currency", "to_currency"
|
||||
) # Optimize currency queries
|
||||
.annotate(
|
||||
date_diff=Func(Extract(F("date") - Value(date), "epoch"), function="ABS"),
|
||||
effective_rate=F("rate"),
|
||||
)
|
||||
.order_by("from_currency", "to_currency", "date_diff")
|
||||
.distinct("from_currency", "to_currency")
|
||||
)
|
||||
|
||||
# Initialize the result dictionary
|
||||
rate_map = {}
|
||||
|
||||
# Build the exchange rate mapping with currency info
|
||||
for rate in exchange_rates:
|
||||
# Add from_currency info if not exists
|
||||
if rate.from_currency.name not in rate_map:
|
||||
rate_map[rate.from_currency.name] = {
|
||||
"decimal_places": rate.from_currency.decimal_places,
|
||||
"prefix": rate.from_currency.prefix,
|
||||
"suffix": rate.from_currency.suffix,
|
||||
"rates": {},
|
||||
}
|
||||
|
||||
# Add to_currency info if not exists
|
||||
if rate.to_currency.name not in rate_map:
|
||||
rate_map[rate.to_currency.name] = {
|
||||
"decimal_places": rate.to_currency.decimal_places,
|
||||
"prefix": rate.to_currency.prefix,
|
||||
"suffix": rate.to_currency.suffix,
|
||||
"rates": {},
|
||||
}
|
||||
|
||||
# Add direct rate
|
||||
rate_map[rate.from_currency.name]["rates"][rate.to_currency.name] = {
|
||||
"rate": rate.rate,
|
||||
"decimal_places": rate.to_currency.decimal_places,
|
||||
"prefix": rate.to_currency.prefix,
|
||||
"suffix": rate.to_currency.suffix,
|
||||
}
|
||||
# Add inverse rate
|
||||
rate_map[rate.to_currency.name]["rates"][rate.from_currency.name] = {
|
||||
"rate": 1 / rate.rate,
|
||||
"decimal_places": rate.from_currency.decimal_places,
|
||||
"prefix": rate.from_currency.prefix,
|
||||
"suffix": rate.from_currency.suffix,
|
||||
}
|
||||
|
||||
return rate_map
|
||||
@@ -5,6 +5,7 @@ from apps.common.widgets.decimal import convert_to_decimal
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.mini_tools.forms import CurrencyConverterForm
|
||||
from apps.mini_tools.utils.exchange_rate_map import get_currency_exchange_map
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -14,11 +15,13 @@ def unit_price_calculator(request):
|
||||
|
||||
@login_required
|
||||
def currency_converter(request):
|
||||
rate_map = get_currency_exchange_map()
|
||||
|
||||
form = CurrencyConverterForm()
|
||||
return render(
|
||||
request,
|
||||
"mini_tools/currency_converter/currency_converter.html",
|
||||
context={"form": form},
|
||||
context={"form": form, "rate_map": rate_map},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +92,8 @@ def transactions_list(request, month: int, year: int):
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-09 20:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0009_alter_transactionrule_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_account',
|
||||
field=models.TextField(blank=True, verbose_name='Search Account'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_amount',
|
||||
field=models.TextField(blank=True, verbose_name='Search Amount'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_category',
|
||||
field=models.TextField(blank=True, verbose_name='Search Category'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_description',
|
||||
field=models.TextField(blank=True, verbose_name='Search Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_entities',
|
||||
field=models.TextField(blank=True, verbose_name='Search Entities'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_internal_id',
|
||||
field=models.TextField(blank=True, verbose_name='Search Internal ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_internal_note',
|
||||
field=models.TextField(blank=True, verbose_name='Search Internal Note'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_is_paid',
|
||||
field=models.TextField(blank=True, verbose_name='Search Is Paid'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_notes',
|
||||
field=models.TextField(blank=True, verbose_name='Search Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_reference_date',
|
||||
field=models.TextField(blank=True, verbose_name='Search Reference Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_tags',
|
||||
field=models.TextField(blank=True, verbose_name='Search Tags'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='search_type',
|
||||
field=models.TextField(blank=True, verbose_name='Search Type'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_account',
|
||||
field=models.TextField(blank=True, verbose_name='Account'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_amount',
|
||||
field=models.TextField(blank=True, verbose_name='Amount'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_category',
|
||||
field=models.TextField(blank=True, verbose_name='Category'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_date',
|
||||
field=models.TextField(blank=True, verbose_name='Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_description',
|
||||
field=models.TextField(blank=True, verbose_name='Description'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_entities',
|
||||
field=models.TextField(blank=True, verbose_name='Entities'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_internal_id',
|
||||
field=models.TextField(blank=True, verbose_name='Internal ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_internal_note',
|
||||
field=models.TextField(blank=True, verbose_name='Internal Note'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_is_paid',
|
||||
field=models.TextField(blank=True, verbose_name='Is Paid'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_notes',
|
||||
field=models.TextField(blank=True, verbose_name='Notes'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_reference_date',
|
||||
field=models.TextField(blank=True, verbose_name='Reference Date'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_tags',
|
||||
field=models.TextField(blank=True, verbose_name='Tags'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_type',
|
||||
field=models.TextField(blank=True, verbose_name='Type'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-09 20:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rules', '0010_alter_updateorcreatetransactionruleaction_search_account_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='updateorcreatetransactionruleaction',
|
||||
name='set_is_paid',
|
||||
field=models.TextField(blank=True, verbose_name='Paid'),
|
||||
),
|
||||
]
|
||||
@@ -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,348 @@ 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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=_("Account"),
|
||||
blank=True,
|
||||
)
|
||||
set_type = models.TextField(
|
||||
verbose_name=_("Type"),
|
||||
blank=True,
|
||||
)
|
||||
set_is_paid = models.TextField(
|
||||
verbose_name=_("Paid"),
|
||||
blank=True,
|
||||
)
|
||||
set_date = models.TextField(
|
||||
verbose_name=_("Date"),
|
||||
blank=True,
|
||||
)
|
||||
set_reference_date = models.TextField(
|
||||
verbose_name=_("Reference Date"),
|
||||
blank=True,
|
||||
)
|
||||
set_amount = models.TextField(
|
||||
verbose_name=_("Amount"),
|
||||
blank=True,
|
||||
)
|
||||
set_description = models.TextField(
|
||||
verbose_name=_("Description"),
|
||||
blank=True,
|
||||
)
|
||||
set_notes = models.TextField(
|
||||
verbose_name=_("Notes"),
|
||||
blank=True,
|
||||
)
|
||||
set_internal_note = models.TextField(
|
||||
verbose_name=_("Internal Note"),
|
||||
blank=True,
|
||||
)
|
||||
set_internal_id = models.TextField(
|
||||
verbose_name=_("Internal ID"),
|
||||
blank=True,
|
||||
)
|
||||
set_entities = models.TextField(
|
||||
verbose_name=_("Entities"),
|
||||
blank=True,
|
||||
)
|
||||
set_category = models.TextField(
|
||||
verbose_name=_("Category"),
|
||||
blank=True,
|
||||
)
|
||||
set_tags = models.TextField(
|
||||
verbose_name=_("Tags"),
|
||||
blank=True,
|
||||
)
|
||||
|
||||
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):
|
||||
lookup = f"{field_name}__{operator}"
|
||||
return Q(**{lookup: value})
|
||||
|
||||
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,15 +1,23 @@
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.dispatch import receiver
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
transaction_created,
|
||||
transaction_updated,
|
||||
)
|
||||
from apps.rules.tasks import check_for_transaction_rules
|
||||
|
||||
transaction_created = Signal()
|
||||
transaction_updated = Signal()
|
||||
|
||||
|
||||
@receiver(transaction_created)
|
||||
@receiver(transaction_updated)
|
||||
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||
for dca_entry in sender.dca_expense_entries.all():
|
||||
dca_entry.amount_paid = sender.amount
|
||||
dca_entry.save()
|
||||
for dca_entry in sender.dca_income_entries.all():
|
||||
dca_entry.amount_received = sender.amount
|
||||
dca_entry.save()
|
||||
|
||||
check_for_transaction_rules.defer(
|
||||
instance_id=sender.id,
|
||||
signal=(
|
||||
|
||||
@@ -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,334 @@ 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)
|
||||
logger.info("Searching transactions using: %s", search_query)
|
||||
|
||||
# Find latest matching transaction or create new
|
||||
if search_query:
|
||||
transactions = Transaction.objects.filter(search_query).order_by("-date", "-id")
|
||||
transaction = transactions.first()
|
||||
logger.info("Found at least one matching transaction, using latest")
|
||||
else:
|
||||
transaction = None
|
||||
logger.info("No matching transaction found, creating a new transaction")
|
||||
|
||||
if not transaction:
|
||||
transaction = Transaction()
|
||||
|
||||
simple_eval.names.update(
|
||||
{
|
||||
"my_account_name": (transaction.account.name if transaction.id else None),
|
||||
"my_account_id": transaction.account.id if transaction.id else None,
|
||||
"my_account_group_name": (
|
||||
transaction.account.group.name
|
||||
if transaction.id and transaction.account.group
|
||||
else None
|
||||
),
|
||||
"my_account_group_id": (
|
||||
transaction.account.group.id
|
||||
if transaction.id and transaction.account.group
|
||||
else None
|
||||
),
|
||||
"my_is_asset_account": (
|
||||
transaction.account.is_asset if transaction.id else None
|
||||
),
|
||||
"my_is_archived_account": (
|
||||
transaction.account.is_archived if transaction.id else None
|
||||
),
|
||||
"my_category_name": (
|
||||
transaction.category.name if transaction.category else None
|
||||
),
|
||||
"my_category_id": transaction.category.id if transaction.category else None,
|
||||
"my_tag_names": (
|
||||
[x.name for x in transaction.tags.all()] if transaction.id else []
|
||||
),
|
||||
"my_tag_ids": (
|
||||
[x.id for x in transaction.tags.all()] if transaction.id else []
|
||||
),
|
||||
"my_entities_names": (
|
||||
[x.name for x in transaction.entities.all()] if transaction.id else []
|
||||
),
|
||||
"my_entities_ids": (
|
||||
[x.id for x in transaction.entities.all()] if transaction.id else []
|
||||
),
|
||||
"my_is_expense": transaction.type == Transaction.Type.EXPENSE,
|
||||
"my_is_income": transaction.type == Transaction.Type.INCOME,
|
||||
"my_is_paid": transaction.is_paid,
|
||||
"my_description": transaction.description,
|
||||
"my_amount": transaction.amount or 0,
|
||||
"my_notes": transaction.notes,
|
||||
"my_date": transaction.date,
|
||||
"my_reference_date": transaction.reference_date,
|
||||
"my_internal_note": transaction.internal_note,
|
||||
"my_internal_id": transaction.reference_date,
|
||||
}
|
||||
)
|
||||
|
||||
if action.filter:
|
||||
value = simple_eval.eval(action.filter)
|
||||
if not value:
|
||||
return # Short-circuit execution if filter evaluates to false
|
||||
|
||||
# Set fields if provided
|
||||
if action.set_account:
|
||||
value = simple_eval.eval(action.set_account)
|
||||
if isinstance(value, int):
|
||||
transaction.account = Account.objects.get(id=value)
|
||||
else:
|
||||
transaction.account = Account.objects.get(name=value)
|
||||
|
||||
if action.set_type:
|
||||
transaction.type = simple_eval.eval(action.set_type)
|
||||
|
||||
if action.set_is_paid:
|
||||
transaction.is_paid = simple_eval.eval(action.set_is_paid)
|
||||
|
||||
if action.set_date:
|
||||
transaction.date = simple_eval.eval(action.set_date)
|
||||
|
||||
if action.set_reference_date:
|
||||
transaction.reference_date = simple_eval.eval(action.set_reference_date)
|
||||
|
||||
if action.set_amount:
|
||||
transaction.amount = simple_eval.eval(action.set_amount)
|
||||
|
||||
if action.set_description:
|
||||
transaction.description = simple_eval.eval(action.set_description)
|
||||
|
||||
if action.set_internal_note:
|
||||
transaction.internal_note = simple_eval.eval(action.set_internal_note)
|
||||
|
||||
if action.set_internal_id:
|
||||
transaction.internal_id = simple_eval.eval(action.set_internal_id)
|
||||
|
||||
if action.set_notes:
|
||||
transaction.notes = simple_eval.eval(action.set_notes)
|
||||
|
||||
if action.set_category:
|
||||
value = simple_eval.eval(action.set_category)
|
||||
if isinstance(value, int):
|
||||
transaction.category = TransactionCategory.objects.get(id=value)
|
||||
else:
|
||||
transaction.category = TransactionCategory.objects.get(name=value)
|
||||
|
||||
transaction.save()
|
||||
|
||||
# Handle M2M fields after save
|
||||
if action.set_tags:
|
||||
tags_value = simple_eval.eval(action.set_tags)
|
||||
transaction.tags.clear()
|
||||
if isinstance(tags_value, (list, tuple)):
|
||||
for tag in tags_value:
|
||||
if isinstance(tag, int):
|
||||
transaction.tags.add(TransactionTag.objects.get(id=tag))
|
||||
else:
|
||||
transaction.tags.add(TransactionTag.objects.get(name=tag))
|
||||
elif isinstance(tags_value, (int, str)):
|
||||
if isinstance(tags_value, int):
|
||||
transaction.tags.add(TransactionTag.objects.get(id=tags_value))
|
||||
else:
|
||||
transaction.tags.add(TransactionTag.objects.get(name=tags_value))
|
||||
|
||||
if action.set_entities:
|
||||
entities_value = simple_eval.eval(action.set_entities)
|
||||
transaction.entities.clear()
|
||||
if isinstance(entities_value, (list, tuple)):
|
||||
for entity in entities_value:
|
||||
if isinstance(entity, int):
|
||||
transaction.entities.add(TransactionEntity.objects.get(id=entity))
|
||||
else:
|
||||
transaction.entities.add(TransactionEntity.objects.get(name=entity))
|
||||
elif isinstance(entities_value, (int, str)):
|
||||
if isinstance(entities_value, int):
|
||||
transaction.entities.add(
|
||||
TransactionEntity.objects.get(id=entities_value)
|
||||
)
|
||||
else:
|
||||
transaction.entities.add(
|
||||
TransactionEntity.objects.get(name=entities_value)
|
||||
)
|
||||
|
||||
|
||||
def _process_edit_transaction_action(instance, action, simple_eval) -> Transaction:
|
||||
if action.field in [
|
||||
TransactionRuleAction.Field.type,
|
||||
TransactionRuleAction.Field.is_paid,
|
||||
TransactionRuleAction.Field.date,
|
||||
TransactionRuleAction.Field.reference_date,
|
||||
TransactionRuleAction.Field.amount,
|
||||
TransactionRuleAction.Field.description,
|
||||
TransactionRuleAction.Field.notes,
|
||||
]:
|
||||
setattr(
|
||||
instance,
|
||||
action.field,
|
||||
simple_eval.eval(action.value),
|
||||
)
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.account:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, int):
|
||||
account = Account.objects.get(id=value)
|
||||
instance.account = account
|
||||
elif isinstance(value, str):
|
||||
account = Account.objects.filter(name=value).first()
|
||||
instance.account = account
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.category:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, int):
|
||||
category = TransactionCategory.objects.get(id=value)
|
||||
instance.category = category
|
||||
elif isinstance(value, str):
|
||||
category = TransactionCategory.objects.get(name=value)
|
||||
instance.category = category
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.tags:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, list):
|
||||
# Clear existing tags
|
||||
instance.tags.clear()
|
||||
for tag_value in value:
|
||||
if isinstance(tag_value, int):
|
||||
tag = TransactionTag.objects.get(id=tag_value)
|
||||
instance.tags.add(tag)
|
||||
elif isinstance(tag_value, str):
|
||||
tag = TransactionTag.objects.get(name=tag_value)
|
||||
instance.tags.add(tag)
|
||||
|
||||
elif isinstance(value, (int, str)):
|
||||
# If a single value is provided, treat it as a single tag
|
||||
instance.tags.clear()
|
||||
if isinstance(value, int):
|
||||
tag = TransactionTag.objects.get(id=value)
|
||||
else:
|
||||
tag = TransactionTag.objects.get(name=value)
|
||||
|
||||
instance.tags.add(tag)
|
||||
|
||||
elif action.field == TransactionRuleAction.Field.entities:
|
||||
value = simple_eval.eval(action.value)
|
||||
if isinstance(value, list):
|
||||
# Clear existing entities
|
||||
instance.entities.clear()
|
||||
for entity_value in value:
|
||||
if isinstance(entity_value, int):
|
||||
entity = TransactionEntity.objects.get(id=entity_value)
|
||||
instance.entities.add(entity)
|
||||
elif isinstance(entity_value, str):
|
||||
entity = TransactionEntity.objects.get(name=entity_value)
|
||||
instance.entities.add(entity)
|
||||
|
||||
elif isinstance(value, (int, str)):
|
||||
# If a single value is provided, treat it as a single entity
|
||||
instance.entities.clear()
|
||||
if isinstance(value, int):
|
||||
entity = TransactionEntity.objects.get(id=value)
|
||||
else:
|
||||
entity = TransactionEntity.objects.get(name=value)
|
||||
|
||||
instance.entities.add(entity)
|
||||
|
||||
return instance
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -63,7 +63,9 @@ class TransactionForm(forms.ModelForm):
|
||||
date = forms.DateField(label=_("Date"))
|
||||
|
||||
reference_date = forms.DateField(
|
||||
widget=AirMonthYearPickerInput(), label=_("Reference Date"), required=False
|
||||
widget=AirMonthYearPickerInput(),
|
||||
label=_("Reference Date"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -176,7 +178,6 @@ class TransactionForm(forms.ModelForm):
|
||||
),
|
||||
)
|
||||
|
||||
self.fields["reference_date"].required = False
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
|
||||
@@ -1,22 +1,66 @@
|
||||
import logging
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Q
|
||||
from django.dispatch import Signal
|
||||
from django.template.defaultfilters import date
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.conf import settings
|
||||
|
||||
from apps.common.fields.month_year import MonthYearModelField
|
||||
from apps.common.functions.decimals import truncate_decimal
|
||||
from apps.common.templatetags.decimal import localize_number, drop_trailing_zeros
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.transactions.validators import validate_decimal_places, validate_non_negative
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
|
||||
transaction_created = Signal()
|
||||
transaction_updated = Signal()
|
||||
|
||||
|
||||
class SoftDeleteQuerySet(models.QuerySet):
|
||||
@staticmethod
|
||||
def _emit_signals(instances, created=False):
|
||||
"""Helper to emit signals for multiple instances"""
|
||||
for instance in instances:
|
||||
if created:
|
||||
transaction_created.send(sender=instance)
|
||||
else:
|
||||
transaction_updated.send(sender=instance)
|
||||
|
||||
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
||||
instances = super().bulk_create(objs, **kwargs)
|
||||
|
||||
if emit_signal:
|
||||
self._emit_signals(instances, created=True)
|
||||
|
||||
return instances
|
||||
|
||||
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
||||
result = super().bulk_update(objs, fields, **kwargs)
|
||||
|
||||
if emit_signal:
|
||||
self._emit_signals(objs, created=False)
|
||||
|
||||
return result
|
||||
|
||||
def update(self, emit_signal=True, **kwargs):
|
||||
# Get instances before update
|
||||
instances = list(self)
|
||||
result = super().update(**kwargs)
|
||||
|
||||
if emit_signal:
|
||||
# Refresh instances to get new values
|
||||
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
||||
self._emit_signals(refreshed, created=False)
|
||||
|
||||
return result
|
||||
|
||||
def delete(self):
|
||||
if not settings.ENABLE_SOFT_DELETE:
|
||||
# If soft deletion is disabled, perform a normal delete
|
||||
@@ -274,7 +318,13 @@ class Transaction(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
type_display = self.get_type_display()
|
||||
return f"{self.description} - {type_display} - {self.account} - {self.date}"
|
||||
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
||||
account = self.account
|
||||
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
|
||||
category = self.category or _("No category")
|
||||
amount = localize_number(drop_trailing_zeros(self.amount))
|
||||
description = self.description or _("No description")
|
||||
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
||||
|
||||
|
||||
class InstallmentPlan(models.Model):
|
||||
|
||||
@@ -86,6 +86,16 @@ urlpatterns = [
|
||||
views.transactions_bulk_edit,
|
||||
name="transactions_bulk_edit",
|
||||
),
|
||||
path(
|
||||
"transactions/json/search/",
|
||||
views.get_recent_transactions,
|
||||
name="transactions_search",
|
||||
),
|
||||
path(
|
||||
"transactions/json/search/<str:filter_type>/",
|
||||
views.get_recent_transactions,
|
||||
name="transactions_search",
|
||||
),
|
||||
path(
|
||||
"transaction/<int:transaction_id>/clone/",
|
||||
views.transaction_clone,
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.rules.signals import transaction_updated
|
||||
|
||||
|
||||
@only_htmx
|
||||
@@ -87,7 +88,7 @@ def bulk_undelete_transactions(request):
|
||||
selected_transactions = request.GET.getlist("transactions", [])
|
||||
transactions = Transaction.deleted_objects.filter(id__in=selected_transactions)
|
||||
count = transactions.count()
|
||||
transactions.update(deleted=False, deleted_at=None)
|
||||
transactions.update(deleted=False, deleted_at=None, emit_signal=False)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
|
||||
@@ -4,14 +4,14 @@ from copy import deepcopy
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import HttpResponse
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import render, get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _, ngettext_lazy
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from apps.common.decorators.htmx import only_htmx
|
||||
from apps.common.utils.dicts import remove_falsey_entries
|
||||
from apps.rules.signals import transaction_created, transaction_updated
|
||||
from apps.transactions.filters import TransactionsFilter
|
||||
from apps.transactions.forms import (
|
||||
@@ -316,6 +316,7 @@ def transaction_pay(request, transaction_id):
|
||||
new_is_paid = False if transaction.is_paid else True
|
||||
transaction.is_paid = new_is_paid
|
||||
transaction.save()
|
||||
transaction_updated.send(sender=transaction)
|
||||
|
||||
response = render(
|
||||
request,
|
||||
@@ -362,6 +363,8 @@ def transaction_all_list(request):
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
transactions = default_order(transactions, order=order)
|
||||
@@ -394,6 +397,9 @@ def transaction_all_summary(request):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
@@ -425,6 +431,9 @@ def transaction_all_account_summary(request):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
@@ -452,6 +461,9 @@ def transaction_all_currency_summary(request):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||
@@ -483,6 +495,9 @@ def transactions_trash_can_index(request):
|
||||
return render(request, "transactions/pages/trash.html")
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def transactions_trash_can_list(request):
|
||||
transactions = Transaction.deleted_objects.prefetch_related(
|
||||
"account",
|
||||
@@ -492,6 +507,10 @@ def transactions_trash_can_list(request):
|
||||
"account__exchange_currency",
|
||||
"account__currency",
|
||||
"installment_plan",
|
||||
"entities",
|
||||
"entities",
|
||||
"dca_expense_entries",
|
||||
"dca_income_entries",
|
||||
).all()
|
||||
|
||||
return render(
|
||||
@@ -499,3 +518,41 @@ def transactions_trash_can_list(request):
|
||||
"transactions/fragments/trash_list.html",
|
||||
{"transactions": transactions},
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def get_recent_transactions(request, filter_type=None):
|
||||
"""Return the 100 most recent non-deleted transactions with optional search."""
|
||||
# Get search term from query params
|
||||
search_term = request.GET.get("q", "").strip()
|
||||
|
||||
# Base queryset with selected fields
|
||||
queryset = (
|
||||
Transaction.objects.filter(deleted=False)
|
||||
.select_related("account", "category")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
if filter_type:
|
||||
if filter_type == "expenses":
|
||||
queryset = queryset.filter(type=Transaction.Type.EXPENSE)
|
||||
elif filter_type == "income":
|
||||
queryset = queryset.filter(type=Transaction.Type.INCOME)
|
||||
|
||||
# Apply search if provided
|
||||
if search_term:
|
||||
queryset = queryset.filter(
|
||||
Q(description__icontains=search_term)
|
||||
| Q(notes__icontains=search_term)
|
||||
| Q(internal_note__icontains=search_term)
|
||||
| Q(tags__name__icontains=search_term)
|
||||
| Q(category__name__icontains=search_term)
|
||||
)
|
||||
|
||||
# Prepare data for JSON response
|
||||
data = []
|
||||
for t in queryset:
|
||||
data.append({"text": str(t), "value": str(t.id)})
|
||||
|
||||
return JsonResponse(data, safe=False)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-02-24 19:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0018_alter_usersettings_start_page'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='usersettings',
|
||||
name='language',
|
||||
field=models.CharField(choices=[('auto', 'Auto'), ('de', 'Deutsch'), ('en', 'English'), ('nl', 'Nederlands'), ('pt-br', 'Português (Brasil)')], default='auto', max_length=10, verbose_name='Language'),
|
||||
),
|
||||
]
|
||||
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"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
2841
app/locale/en/LC_MESSAGES/django.po
Normal file
2841
app/locale/en/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -57,9 +57,8 @@
|
||||
</td>
|
||||
<td class="col">{{ account.name }}</td>
|
||||
<td class="col">{{ account.group.name }}</td>
|
||||
<td class="col">{{ account.currency }} ({{ account.currency.code }})</td>
|
||||
<td class="col">{% if account.exchange_currency %}{{ account.exchange_currency }} (
|
||||
{{ account.exchange_currency.code }}){% else %}-{% endif %}</td>
|
||||
<td class="col">{{ account.currency }}</td>
|
||||
<td class="col">{% if account.exchange_currency %}{{ account.exchange_currency }}{% else %}-{% endif %}</td>
|
||||
<td class="col">{% if account.is_asset %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||
<td class="col">{% if account.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="row {% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="col {% if not remove_padding %}p-5{% endif %}">
|
||||
<div class="text-center">
|
||||
<i class="fa-solid fa-circle-xmark tw-text-6xl"></i>
|
||||
<i class="{% if icon %}{{ icon }}{% else %}fa-solid fa-circle-xmark{% endif %} tw-text-6xl"></i>
|
||||
<p class="lead mt-4 mb-0">{{ title }}</p>
|
||||
<p class="tw-text-gray-500">{{ subtitle }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,172 +1,177 @@
|
||||
{% load markdown %}
|
||||
{% load i18n %}
|
||||
<div class="transaction d-flex my-1 {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
|
||||
{% if not disable_selection %}
|
||||
<label class="px-3 d-flex align-items-center justify-content-center">
|
||||
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
||||
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
||||
</label>
|
||||
{% endif %}
|
||||
<div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
|
||||
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %}">
|
||||
<div class="d-flex my-1">
|
||||
{% if not disable_selection %}
|
||||
<label class="px-3 d-flex align-items-center justify-content-center">
|
||||
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
||||
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
||||
</label>
|
||||
{% endif %}
|
||||
<div class="tw-border-s-6 tw-border-e-0 tw-border-t-0 tw-border-b-0 border-bottom
|
||||
hover:tw-bg-zinc-900 p-2 {% if transaction.account.is_asset %}tw-border-dashed{% else %}tw-border-solid{% endif %}
|
||||
{% if transaction.type == "EX" %}tw-border-red-500{% else %}tw-border-green-500{% endif %} tw-relative
|
||||
w-100 transaction-item"
|
||||
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
|
||||
_="on mouseover remove .tw-invisible from the first .transaction-actions in me end
|
||||
on mouseout add .tw-invisible to the first .transaction-actions in me end">
|
||||
<div class="row font-monospace tw-text-sm align-items-center">
|
||||
<div class="col-lg-1 col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
||||
hx-target="closest .transaction"
|
||||
hx-swap="outerHTML">
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||
class="fa-regular fa-circle"></i>{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-decoration-none my-lg-3 mx-lg-3 mx-2 my-2 tw-text-gray-500"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}">
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||
class="fa-regular fa-circle"></i>{% endif %}
|
||||
<div class="row font-monospace tw-text-sm align-items-center">
|
||||
<div class="col-lg-auto col-12 d-flex align-items-center tw-text-2xl lg:tw-text-xl text-lg-center text-center p-0 ps-1">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="text-decoration-none p-3 tw-text-gray-500"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
||||
role="button"
|
||||
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
||||
hx-target="closest .transaction"
|
||||
hx-swap="outerHTML">
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||
class="fa-regular fa-circle"></i>{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="text-decoration-none p-3 tw-text-gray-500"
|
||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}">
|
||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||
class="fa-regular fa-circle"></i>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg col-12">
|
||||
{# Date#}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
||||
<div
|
||||
class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-8 col-12">
|
||||
{# Date#}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
||||
<div
|
||||
class="col ps-0">{{ transaction.date|date:"SHORT_DATE_FORMAT" }} • {{ transaction.reference_date|date:"b/Y" }}</div>
|
||||
</div>
|
||||
{# Description#}
|
||||
<div class="mb-2 mb-lg-1 text-white tw-text-base">
|
||||
{% spaceless %}
|
||||
<span>{{ transaction.description }}</span>
|
||||
{% if transaction.installment_plan and transaction.installment_id %}
|
||||
<span
|
||||
class="badge text-bg-secondary ms-2">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
|
||||
{% endif %}
|
||||
{% if transaction.recurring_transaction %}
|
||||
<span class="text-primary tw-text-xs ms-2"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
|
||||
{% endif %}
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
<div class="tw-text-gray-400 tw-text-sm">
|
||||
{# Entities #}
|
||||
{% with transaction.entities.all as entities %}
|
||||
{% if entities %}
|
||||
{# Description#}
|
||||
<div class="mb-2 mb-lg-1 text-white tw-text-base">
|
||||
{% spaceless %}
|
||||
<span class="{% if transaction.description %}me-2{% endif %}">{{ transaction.description }}</span>
|
||||
{% if transaction.installment_plan and transaction.installment_id %}
|
||||
<span
|
||||
class="badge text-bg-secondary">{{ transaction.installment_id }}/{{ transaction.installment_plan.installment_total_number }}</span>
|
||||
{% endif %}
|
||||
{% if transaction.recurring_transaction %}
|
||||
<span class="text-primary tw-text-xs"><i class="fa-solid fa-arrows-rotate fa-fw"></i></span>
|
||||
{% endif %}
|
||||
{% if transaction.dca_expense_entries.all or transaction.dca_income_entries.all %}
|
||||
<span class="badge text-bg-secondary">{% trans 'DCA' %}</span>
|
||||
{% endif %}
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
<div class="tw-text-gray-400 tw-text-sm">
|
||||
{# Entities #}
|
||||
{% with transaction.entities.all as entities %}
|
||||
{% if entities %}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ entities|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{# Notes#}
|
||||
{% if transaction.notes %}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ entities|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{# Notes#}
|
||||
{% if transaction.notes %}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-align-left fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Category#}
|
||||
{% if transaction.category %}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-icons fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.category.name }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Tags#}
|
||||
{% with transaction.tags.all as tags %}
|
||||
{% if tags %}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ tags|join:", " }}</div>
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-align-left fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Category#}
|
||||
{% if transaction.category %}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-icons fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ transaction.category.name }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Tags#}
|
||||
{% with transaction.tags.all as tags %}
|
||||
{% if tags %}
|
||||
<div class="row mb-2 mb-lg-1 tw-text-gray-400">
|
||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||
<div class="col ps-0">{{ tags|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-auto col-12 text-lg-end align-self-end">
|
||||
<div class="main-amount mb-2 mb-lg-0">
|
||||
<c-amount.display
|
||||
:amount="transaction.amount"
|
||||
:prefix="transaction.account.currency.prefix"
|
||||
:suffix="transaction.account.currency.suffix"
|
||||
:decimal_places="transaction.account.currency.decimal_places"
|
||||
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
{# Exchange Rate#}
|
||||
{% with exchanged=transaction.exchanged_amount %}
|
||||
{% if exchanged %}
|
||||
<div class="exchanged-amount mb-2 mb-lg-0">
|
||||
<c-amount.display
|
||||
:amount="exchanged.amount"
|
||||
:prefix="exchanged.prefix"
|
||||
:suffix="exchanged.suffix"
|
||||
:decimal_places="exchanged.decimal_places"
|
||||
color="grey"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div>
|
||||
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-3 col-12 text-lg-end align-self-end">
|
||||
<div class="main-amount mb-2 mb-lg-0">
|
||||
<c-amount.display
|
||||
:amount="transaction.amount"
|
||||
:prefix="transaction.account.currency.prefix"
|
||||
:suffix="transaction.account.currency.suffix"
|
||||
:decimal_places="transaction.account.currency.decimal_places"
|
||||
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
{# Exchange Rate#}
|
||||
{% with exchanged=transaction.exchanged_amount %}
|
||||
{% if exchanged %}
|
||||
<div class="exchanged-amount mb-2 mb-lg-0">
|
||||
<c-amount.display
|
||||
:amount="exchanged.amount"
|
||||
:prefix="exchanged.prefix"
|
||||
:suffix="exchanged.suffix"
|
||||
:decimal_places="exchanged.decimal_places"
|
||||
color="grey"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<div>
|
||||
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{# Item actions#}
|
||||
<div
|
||||
class="transaction-actions !tw-absolute tw-left-1/2 tw-top-0 tw--translate-x-1/2 tw--translate-y-1/2 tw-invisible d-flex flex-row card">
|
||||
<div class="card-body p-1 shadow-lg">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Duplicate" %}"
|
||||
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
|
||||
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
|
||||
hx-trigger="ready">
|
||||
<i class="fa-solid fa-clone fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Restore" %}"
|
||||
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-trash-arrow-up"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{# Item actions#}
|
||||
<div
|
||||
class="transaction-actions !tw-absolute tw-left-1/2 tw-top-0 tw--translate-x-1/2 tw--translate-y-1/2 tw-invisible d-flex flex-row card">
|
||||
<div class="card-body p-1 shadow-lg">
|
||||
{% if not transaction.deleted %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Edit" %}"
|
||||
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
|
||||
hx-target="#generic-offcanvas" hx-swap="innerHTML">
|
||||
<i class="fa-solid fa-pencil fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Duplicate" %}"
|
||||
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"
|
||||
_="on click if event.ctrlKey set @hx-get to `{% url 'transaction_clone' transaction_id=transaction.id %}?edit=true` then call htmx.process(me) end then trigger ready"
|
||||
hx-trigger="ready">
|
||||
<i class="fa-solid fa-clone fa-fw"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Restore" %}"
|
||||
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
|
||||
class="fa-solid fa-trash-arrow-up"></i></a>
|
||||
<a class="btn btn-secondary btn-sm transaction-action"
|
||||
role="button"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-title="{% translate "Delete" %}"
|
||||
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
|
||||
hx-trigger='confirmed'
|
||||
data-bypass-on-ctrl="true"
|
||||
data-title="{% translate "Are you sure?" %}"
|
||||
data-text="{% translate "You won't be able to revert this!" %}"
|
||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<div class="card tw-relative h-100 shadow">
|
||||
<div class="tw-absolute tw-h-8 tw-w-8 tw-right-2 tw-top-2 tw-bg-{{ color }}-300 tw-text-{{ color }}-800 text-center align-items-center d-flex justify-content-center rounded-2">
|
||||
<i class="{{ icon }}"></i>
|
||||
{% if icon %}<i class="{{ icon }}"></i>{% else %}<span class="fw-bold">{{ title.0 }}</span>{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="tw-text-{{ color }}-400 fw-bold tw-mr-[50px]" {{ attrs }}>{{ title }}{% if help_text %}{% include 'includes/help_icon.html' with content=help_text %}{% endif %}</h5>
|
||||
{{ slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<div class="dropdown-item px-3 tw-cursor-pointer"
|
||||
_="on click set <#transactions-list input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
_="on click set <#transactions-list .transaction:not([style*='display: none']) input[type='checkbox']/>'s checked to true then call me.blur() then trigger change">
|
||||
<i class="fa-regular fa-square-check tw-text-green-400 me-3"></i>{% translate 'Select All' %}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
13
app/templates/export_app/fragments/export.html
Normal file
13
app/templates/export_app/fragments/export.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "extends/offcanvas.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'Export' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container p-3">
|
||||
<form method="post" action="{% url 'export_form' %}" id="export-form" class="show-loading px-1" _="on submit trigger hide_offcanvas" target="_blank">
|
||||
{% crispy form %}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
17
app/templates/export_app/fragments/restore.html
Normal file
17
app/templates/export_app/fragments/restore.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "extends/offcanvas.html" %}
|
||||
{% load crispy_forms_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'Restore' %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container p-3">
|
||||
<form hx-post="{% url 'restore_form' %}"
|
||||
hx-target="#generic-offcanvas"
|
||||
id="restore-form"
|
||||
enctype="multipart/form-data"
|
||||
class="show-loading px-1">
|
||||
{% crispy form %}
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
29
app/templates/export_app/pages/index.html
Normal file
29
app/templates/export_app/pages/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% translate 'Export and Restore' %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row d-flex flex-row align-items-center justify-content-center my-5">
|
||||
<div class="text-center w-auto mb-3">
|
||||
<button class="btn btn-outline-success d-flex flex-column align-items-center justify-content-center p-3"
|
||||
style="width: 100px; height: 100px;"
|
||||
hx-get="{% url 'export_form' %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-download mb-1"></i>
|
||||
<span>{% trans 'Export' %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-center w-auto mb-3">
|
||||
<button class="btn btn-outline-primary d-flex flex-column align-items-center justify-content-center p-3"
|
||||
style="width: 100px; height: 100px;"
|
||||
hx-get="{% url 'restore_form' %}"
|
||||
hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-upload mb-1"></i>
|
||||
<span>{% trans 'Restore' %}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -46,8 +46,11 @@
|
||||
href="{% url 'net_worth_projected' %}">{% translate 'Projected' %}</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% active_link views='insights_index' %}" href="{% url 'insights_index' %}">{% trans 'Insights' %}</a>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index' %}"
|
||||
<a class="nav-link dropdown-toggle {% active_link views='installment_plans_index||recurring_trasanctions_index||transactions_all_index||transactions_trash_index' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
@@ -91,7 +94,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index' %}"
|
||||
<a class="nav-link dropdown-toggle {% active_link views='tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||export_index' %}"
|
||||
href="#" role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false">
|
||||
@@ -129,6 +132,8 @@
|
||||
href="{% url 'rules_index' %}">{% translate 'Rules' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='import_profiles_index' %}"
|
||||
href="{% url 'import_profiles_index' %}">{% translate 'Import' %} <span class="badge text-bg-primary">beta</span></a></li>
|
||||
<li><a class="dropdown-item {% active_link views='export_index' %}"
|
||||
href="{% url 'export_index' %}">{% translate 'Export and Restore' %}</a></li>
|
||||
<li><a class="dropdown-item {% active_link views='automatic_exchange_rates_index' %}"
|
||||
href="{% url 'automatic_exchange_rates_index' %}">{% translate 'Automatic Exchange Rates' %}</a></li>
|
||||
<li>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
for x in datepickers
|
||||
MonthYearPicker(it)
|
||||
end
|
||||
set datepickers to <.airyearpickerinput/> in me
|
||||
for x in datepickers
|
||||
YearPicker(it)
|
||||
end
|
||||
end
|
||||
end
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
{% load i18n %}
|
||||
{% if account_data.labels %}
|
||||
<div class="chart-container" style="position: relative; height:400px; width:100%"
|
||||
_="init call setupAccountChart() end">
|
||||
<canvas id="accountChart"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Get the data from your Django view (passed as JSON)
|
||||
var accountData = {{ account_data|safe }};
|
||||
|
||||
function setupAccountChart() {
|
||||
var chartOptions = {
|
||||
indexAxis: 'y', // This makes the chart horizontal
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true, // Enable stacking on the x-axis
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true, // Enable stacking on the y-axis
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
if (context.parsed.x !== null) {
|
||||
return `${context.dataset.label}: ${new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 30,
|
||||
roundingMode: 'trunc'
|
||||
}).format(Math.abs(context.parsed.x))}`;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
new Chart(
|
||||
document.getElementById('accountChart'),
|
||||
{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: accountData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "{% trans 'Projected Expenses' %}",
|
||||
data: accountData.datasets[3].data,
|
||||
backgroundColor: '#f8717180', // Added transparency
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Expenses' %}",
|
||||
data: accountData.datasets[1].data,
|
||||
backgroundColor: '#f87171',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Income' %}",
|
||||
data: accountData.datasets[0].data,
|
||||
backgroundColor: '#4dde80',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Projected Income' %}",
|
||||
data: accountData.datasets[2].data,
|
||||
backgroundColor: '#4dde8080', // Added transparency
|
||||
stack: 'stack0'
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
options: {
|
||||
...chartOptions,
|
||||
plugins: {
|
||||
...chartOptions.plugins,
|
||||
title: {
|
||||
display: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No information to display" %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,93 @@
|
||||
{% load i18n %}
|
||||
{% if currency_data.labels %}
|
||||
<div class="chart-container" style="position: relative; height:400px; width:100%"
|
||||
_="init call setupCurrencyChart() end">
|
||||
<canvas id="currencyChart"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Get the data from your Django view (passed as JSON)
|
||||
var currencyData = {{ currency_data|safe }};
|
||||
|
||||
function setupCurrencyChart() {
|
||||
var chartOptions = {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
title: {
|
||||
display: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
if (context.parsed.x !== null) {
|
||||
return `${context.dataset.label}: ${new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 30,
|
||||
roundingMode: 'trunc'
|
||||
}).format(Math.abs(context.parsed.x))}`;
|
||||
}
|
||||
return "";
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
new Chart(
|
||||
document.getElementById('currencyChart'),
|
||||
{
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: currencyData.labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "{% trans 'Projected Expenses' %}",
|
||||
data: currencyData.datasets[3].data,
|
||||
backgroundColor: '#f8717180', // Added transparency
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Expenses' %}",
|
||||
data: currencyData.datasets[1].data,
|
||||
backgroundColor: '#f87171',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Current Income' %}",
|
||||
data: currencyData.datasets[0].data,
|
||||
backgroundColor: '#4dde80',
|
||||
stack: 'stack0'
|
||||
},
|
||||
{
|
||||
label: "{% trans 'Projected Income' %}",
|
||||
data: currencyData.datasets[2].data,
|
||||
backgroundColor: '#4dde8080', // Added transparency
|
||||
stack: 'stack0'
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
options: chartOptions
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No information to display" %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% load i18n %}
|
||||
{% load crispy_forms_tags %}
|
||||
|
||||
<form _="install init_tom_select
|
||||
on change trigger updated
|
||||
init trigger updated" id="category-form">
|
||||
{% crispy category_form %}
|
||||
</form>
|
||||
|
||||
<div class="row row-cols-1 row-cols-lg-2 gx-3 gy-3">
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
{% trans "Income/Expense by Account" %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="account-card" class="show-loading" hx-get="{% url 'category_sum_by_account' %}"
|
||||
hx-trigger="updated from:window" hx-include="#category-form, #picker-form, #picker-type">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
{% trans "Income/Expense by Currency" %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="currency-card" class="show-loading" hx-get="{% url 'category_sum_by_currency' %}"
|
||||
hx-trigger="updated from:window" hx-include="#category-form, #picker-form, #picker-type">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
{% load i18n %}
|
||||
|
||||
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
|
||||
{% if total_table %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans 'Category' %}</th>
|
||||
<th scope="col">{% trans 'Income' %}</th>
|
||||
<th scope="col">{% trans 'Expense' %}</th>
|
||||
<th scope="col">{% trans 'Total' %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for category in total_table.values %}
|
||||
<tr>
|
||||
<th>{% if category.name %}{{ category.name }}{% else %}{% trans 'Uncategorized' %}{% endif %}</th>
|
||||
<td>
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if currency.total_income != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_income"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if currency.total_expense != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_expense"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% for currency in category.currencies.values %}
|
||||
{% if currency.total_final != 0 %}
|
||||
<c-amount.display
|
||||
:amount="currency.total_final"
|
||||
:prefix="currency.currency.prefix"
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<c-msg.empty title="{% translate "No categories" %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
18
app/templates/insights/fragments/late_transactions.html
Normal file
18
app/templates/insights/fragments/late_transactions.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
<div hx-get="{% url 'insights_late_transactions' %}" hx-trigger="updated from:window" class="show-loading"
|
||||
id="transactions-list" hx-swap="outerHTML">
|
||||
{% if transactions %}
|
||||
{% for transaction in transactions %}
|
||||
<c-transaction.item :transaction="transaction"></c-transaction.item>
|
||||
{% endfor %}
|
||||
{# Floating bar #}
|
||||
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
|
||||
{% else %}
|
||||
<c-msg.empty
|
||||
icon="fa-regular fa-hourglass"
|
||||
title="{% translate 'All good!' %}"
|
||||
subtitle="{% translate "No late transactions" %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
16
app/templates/insights/fragments/latest_transactions.html
Normal file
16
app/templates/insights/fragments/latest_transactions.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
<div hx-get="{% url 'insights_late_transactions' %}" hx-trigger="updated from:window" class="show-loading"
|
||||
id="transactions-list" hx-swap="outerHTML">
|
||||
{% if transactions %}
|
||||
{% for transaction in transactions %}
|
||||
<c-transaction.item :transaction="transaction"></c-transaction.item>
|
||||
{% endfor %}
|
||||
{# Floating bar #}
|
||||
<c-ui.transactions-action-bar></c-ui.transactions-action-bar>
|
||||
{% else %}
|
||||
<c-msg.empty
|
||||
title="{% translate 'No recent transactions' %}"></c-msg.empty>
|
||||
{% endif %}
|
||||
</div>
|
||||
117
app/templates/insights/fragments/sankey.html
Normal file
117
app/templates/insights/fragments/sankey.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% if type == 'account' %}
|
||||
<div class="show-loading" hx-get="{% url 'insights_sankey_by_account' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
|
||||
{% else %}
|
||||
<div class="show-loading" hx-get="{% url 'insights_sankey_by_currency' %}" hx-trigger="updated from:window"
|
||||
hx-swap="outerHTML" hx-include="#picker-form, #picker-type">
|
||||
{% endif %}
|
||||
<div class="chart-container position-relative tw-min-h-[85vh] tw-max-h-[85vh] tw-h-full tw-w-full"
|
||||
id="sankeyContainer"
|
||||
_="init call setupSankeyChart() end">
|
||||
<canvas id="sankeyChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var data = {{ sankey_data|safe }};
|
||||
|
||||
function setupSankeyChart(chartId = 'sankeyChart') {
|
||||
function formatCurrency(value, currency) {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: currency.decimal_places,
|
||||
maximumFractionDigits: currency.decimal_places
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
// Create labels object mapping node IDs to display names
|
||||
const labels = data.nodes.reduce((acc, node) => {
|
||||
acc[node.id] = node.name;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Define colors for each node based on its type
|
||||
const colors = {};
|
||||
data.nodes.forEach(node => {
|
||||
if (node.id.startsWith('income_')) {
|
||||
colors[node.id] = '#4dde80'; // Green for income
|
||||
} else if (node.id.startsWith('expense_')) {
|
||||
colors[node.id] = '#f87171'; // Red for expenses
|
||||
} else {
|
||||
colors[node.id] = '#fbb700'; // Primary for others
|
||||
}
|
||||
});
|
||||
|
||||
// Color getter functions
|
||||
const getColor = (nodeId) => colors[nodeId];
|
||||
const getHover = (nodeId) => colors[nodeId];
|
||||
|
||||
// Format data for Chart.js
|
||||
const chartData = {
|
||||
datasets: [{
|
||||
data: data.flows.map(flow => ({
|
||||
from: flow.from_node,
|
||||
to: flow.to_node,
|
||||
flow: flow.flow
|
||||
})),
|
||||
labels: labels,
|
||||
colorFrom: (c) => getColor(c.dataset.data[c.dataIndex].from),
|
||||
colorTo: (c) => getColor(c.dataset.data[c.dataIndex].to),
|
||||
hoverColorFrom: (c) => getHover(c.dataset.data[c.dataIndex].from),
|
||||
hoverColorTo: (c) => getHover(c.dataset.data[c.dataIndex].to),
|
||||
colorMode: 'gradient',
|
||||
alpha: 0.5,
|
||||
size: 'max',
|
||||
color: "white",
|
||||
nodePadding: 30,
|
||||
priority: data.nodes.reduce((acc, node) => {
|
||||
acc[node.id] = node.priority;
|
||||
return acc;
|
||||
}, {}),
|
||||
}]
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: 'sankey',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: 20
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const flow = data.flows[context.dataIndex];
|
||||
const fromNode = data.nodes.find(n => n.id === flow.from_node);
|
||||
const toNode = data.nodes.find(n => n.id === flow.to_node);
|
||||
const formattedValue = formatCurrency(flow.original_amount, flow.currency);
|
||||
return [
|
||||
`{% trans 'From' %}: ${fromNode.name}`,
|
||||
`{% trans 'To' %}: ${toNode.name}`,
|
||||
`{% trans 'Amount' %}: ${flow.currency.prefix}${formattedValue}${flow.currency.suffix}`,
|
||||
`{% trans 'Percentage' %}: ${flow.percentage.toFixed(2)}%`
|
||||
];
|
||||
}
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create new chart
|
||||
new Chart(
|
||||
document.getElementById(chartId),
|
||||
config
|
||||
);
|
||||
}
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user