mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-05-17 13:17:05 +02:00
Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64b32316ca | |||
| efe020efb3 | |||
| 8c133f92ce | |||
| f3c9d8faea | |||
| 8f5204a17b | |||
| 2235bdeabb | |||
| d724300513 | |||
| eacafa1def | |||
| c738f5ee29 | |||
| 72904266bf | |||
| 670bee4325 | |||
| 3e2c1184ce | |||
| 731f351eef | |||
| b7056e7aa1 | |||
| accceed630 | |||
| 76346cb503 | |||
| 3df8952ea2 | |||
| 9bd067da96 | |||
| 1abe9e9f62 | |||
| 1a86b5dea4 | |||
| 8f2f5a16c2 | |||
| 4565dc770b | |||
| 23673def09 | |||
| dd2b9ead7e | |||
| 2078e9f3e4 | |||
| e6bab57ab4 | |||
| 38d50a78f4 | |||
| 0d947f9ba6 | |||
| 99c85a56bb | |||
| ab1c074f27 | |||
| abf3a148cc | |||
| 2733c92da5 | |||
| 9bfbe54ed5 | |||
| 5b27dea07c | |||
| 791e1000a3 | |||
| 7301d9f475 | |||
| 47a44e96f8 | |||
| 7d247eb737 | |||
| 373616e7bb | |||
| bf3c11d582 | |||
| b4b1c10db9 | |||
| 5ca531f47d | |||
| 07673cb528 | |||
| 67c6d81897 | |||
| 3c85da46b0 | |||
| d263936be7 | |||
| 1524063d5a | |||
| c3a403b8f0 | |||
| 1c1018adae | |||
| 350d5adf25 | |||
| 7e4defb9cc | |||
| 7121e4bc09 | |||
| 4540e48fe5 | |||
| d06b51421f | |||
| e096912e41 | |||
| f0ad6e16fe | |||
| 734a302fa7 | |||
| 89b1b7bcb7 | |||
| 37b40f89bb | |||
| 0c63552d1b | |||
| 7db517e848 | |||
| 7e3ed6cf94 | |||
| e10a88c00e | |||
| b912a33b93 | |||
| d9fb3627cc | |||
| 78ffa68ba4 | |||
| 37f4ead058 | |||
| 61630fca5b | |||
| 910d4c84a3 | |||
| be1f29d8c1 | |||
| 9784d840cc | |||
| db5ce13ff3 | |||
| a2b943d949 | |||
| d8098b4486 | |||
| f8cff6623f | |||
| 65c61f76ff | |||
| 74899f63ab | |||
| 66a5e6d613 | |||
| e0ab32ec03 | |||
| a912e4a511 | |||
| 57ba672c91 | |||
| 20c6989ffb | |||
| c6cd525c49 | |||
| 55c4b920ee | |||
| 7f8261b9cc | |||
| 9102654eab | |||
| 1ff49a8a04 | |||
| 846dd1fd73 | |||
| 9eed3b6692 |
@@ -379,7 +379,7 @@ DEBUG_TOOLBAR_PANELS = [
|
|||||||
"debug_toolbar.panels.signals.SignalsPanel",
|
"debug_toolbar.panels.signals.SignalsPanel",
|
||||||
"debug_toolbar.panels.redirects.RedirectsPanel",
|
"debug_toolbar.panels.redirects.RedirectsPanel",
|
||||||
"debug_toolbar.panels.profiling.ProfilingPanel",
|
"debug_toolbar.panels.profiling.ProfilingPanel",
|
||||||
"cachalot.panels.CachalotPanel",
|
# "cachalot.panels.CachalotPanel",
|
||||||
]
|
]
|
||||||
INTERNAL_IPS = [
|
INTERNAL_IPS = [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from crispy_forms.bootstrap import FormActions
|
|||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Column, Row
|
from crispy_forms.layout import Layout, Field, Column, Row
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.accounts.models import Account
|
from apps.accounts.models import Account
|
||||||
@@ -15,6 +16,7 @@ from apps.common.widgets.crispy.submit import NoClassSubmit
|
|||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.tom_select import TomSelect
|
||||||
from apps.transactions.models import TransactionCategory, TransactionTag
|
from apps.transactions.models import TransactionCategory, TransactionTag
|
||||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||||
|
from apps.currencies.models import Currency
|
||||||
|
|
||||||
|
|
||||||
class AccountGroupForm(forms.ModelForm):
|
class AccountGroupForm(forms.ModelForm):
|
||||||
@@ -79,6 +81,18 @@ class AccountForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.fields["group"].queryset = AccountGroup.objects.all()
|
self.fields["group"].queryset = AccountGroup.objects.all()
|
||||||
|
|
||||||
|
if self.instance.id:
|
||||||
|
qs = Currency.objects.filter(
|
||||||
|
Q(is_archived=False) | Q(accounts=self.instance.id)
|
||||||
|
).distinct()
|
||||||
|
self.fields["currency"].queryset = qs
|
||||||
|
self.fields["exchange_currency"].queryset = qs
|
||||||
|
|
||||||
|
else:
|
||||||
|
qs = Currency.objects.filter(Q(is_archived=False))
|
||||||
|
self.fields["currency"].queryset = qs
|
||||||
|
self.fields["exchange_currency"].queryset = qs
|
||||||
|
|
||||||
self.helper = FormHelper()
|
self.helper = FormHelper()
|
||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-08-09 05:52
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('accounts', '0015_alter_account_owner_alter_account_shared_with_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='account',
|
||||||
|
name='untracked_by',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='untracked_accounts', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.transactions.models import Transaction
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
from apps.common.models import SharedObject, SharedObjectManager
|
from apps.common.models import SharedObject, SharedObjectManager
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
class AccountGroup(SharedObject):
|
class AccountGroup(SharedObject):
|
||||||
@@ -62,6 +62,11 @@ class Account(SharedObject):
|
|||||||
verbose_name=_("Archived"),
|
verbose_name=_("Archived"),
|
||||||
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
help_text=_("Archived accounts don't show up nor count towards your net worth"),
|
||||||
)
|
)
|
||||||
|
untracked_by = models.ManyToManyField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
blank=True,
|
||||||
|
related_name="untracked_accounts",
|
||||||
|
)
|
||||||
|
|
||||||
objects = SharedObjectManager()
|
objects = SharedObjectManager()
|
||||||
all_objects = models.Manager() # Unfiltered manager
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
@@ -75,6 +80,10 @@ class Account(SharedObject):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def is_untracked_by(self):
|
||||||
|
user = get_current_user()
|
||||||
|
return self.untracked_by.filter(pk=user.pk).exists()
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
if self.exchange_currency == self.currency:
|
if self.exchange_currency == self.currency:
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ urlpatterns = [
|
|||||||
views.account_take_ownership,
|
views.account_take_ownership,
|
||||||
name="account_take_ownership",
|
name="account_take_ownership",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"account/<int:pk>/toggle-untracked/",
|
||||||
|
views.account_toggle_untracked,
|
||||||
|
name="account_toggle_untracked",
|
||||||
|
),
|
||||||
path("account-groups/", views.account_groups_index, name="account_groups_index"),
|
path("account-groups/", views.account_groups_index, name="account_groups_index"),
|
||||||
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
|
path("account-groups/list/", views.account_groups_list, name="account_groups_list"),
|
||||||
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
path("account-groups/add/", views.account_group_add, name="account_group_add"),
|
||||||
|
|||||||
@@ -155,6 +155,26 @@ def account_delete(request, pk):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def account_toggle_untracked(request, pk):
|
||||||
|
account = get_object_or_404(Account, id=pk)
|
||||||
|
if account.is_untracked_by():
|
||||||
|
account.untracked_by.remove(request.user)
|
||||||
|
messages.success(request, _("Account is now tracked"))
|
||||||
|
else:
|
||||||
|
account.untracked_by.add(request.user)
|
||||||
|
messages.success(request, _("Account is now untracked"))
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
headers={
|
||||||
|
"HX-Trigger": "updated",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@only_htmx
|
@only_htmx
|
||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
|
|
||||||
from apps.api.custom.pagination import CustomPageNumberPagination
|
from apps.api.custom.pagination import CustomPageNumberPagination
|
||||||
@@ -30,8 +32,9 @@ class TransactionViewSet(viewsets.ModelViewSet):
|
|||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
|
old_data = deepcopy(Transaction.objects.get(pk=serializer.data["pk"]))
|
||||||
instance = serializer.save()
|
instance = serializer.save()
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data)
|
||||||
|
|
||||||
def partial_update(self, request, *args, **kwargs):
|
def partial_update(self, request, *args, **kwargs):
|
||||||
kwargs["partial"] = True
|
kwargs["partial"] = True
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ class DynamicModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
|||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
|
||||||
raise ValidationError(_("Error creating new instance"))
|
raise ValidationError(_("Error creating new instance"))
|
||||||
|
|
||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
|
|||||||
@@ -9,5 +9,8 @@ def truncate_decimal(value, decimal_places):
|
|||||||
:param decimal_places: The number of decimal places to keep
|
:param decimal_places: The number of decimal places to keep
|
||||||
:return: Truncated Decimal value
|
:return: Truncated Decimal value
|
||||||
"""
|
"""
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
value = Decimal(str(value))
|
||||||
|
|
||||||
multiplier = Decimal(10**decimal_places)
|
multiplier = Decimal(10**decimal_places)
|
||||||
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
return (value * multiplier).to_integral_value(rounding=ROUND_DOWN) / multiplier
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from django.utils.formats import get_format as original_get_format
|
|||||||
def get_format(format_type=None, lang=None, use_l10n=None):
|
def get_format(format_type=None, lang=None, use_l10n=None):
|
||||||
user = get_current_user()
|
user = get_current_user()
|
||||||
|
|
||||||
if user and user.is_authenticated and hasattr(user, "settings") and use_l10n:
|
if (
|
||||||
|
user
|
||||||
|
and user.is_authenticated
|
||||||
|
and hasattr(user, "settings")
|
||||||
|
and use_l10n is not False
|
||||||
|
):
|
||||||
user_settings = user.settings
|
user_settings = user.settings
|
||||||
if format_type == "THOUSAND_SEPARATOR":
|
if format_type == "THOUSAND_SEPARATOR":
|
||||||
number_format = getattr(user_settings, "number_format", None)
|
number_format = getattr(user_settings, "number_format", None)
|
||||||
@@ -13,11 +18,13 @@ def get_format(format_type=None, lang=None, use_l10n=None):
|
|||||||
return "."
|
return "."
|
||||||
elif number_format == "CD":
|
elif number_format == "CD":
|
||||||
return ","
|
return ","
|
||||||
|
elif number_format == "SD" or number_format == "SC":
|
||||||
|
return " "
|
||||||
elif format_type == "DECIMAL_SEPARATOR":
|
elif format_type == "DECIMAL_SEPARATOR":
|
||||||
number_format = getattr(user_settings, "number_format", None)
|
number_format = getattr(user_settings, "number_format", None)
|
||||||
if number_format == "DC":
|
if number_format == "DC" or number_format == "SC":
|
||||||
return ","
|
return ","
|
||||||
elif number_format == "CD":
|
elif number_format == "CD" or number_format == "SD":
|
||||||
return "."
|
return "."
|
||||||
elif format_type == "SHORT_DATE_FORMAT":
|
elif format_type == "SHORT_DATE_FORMAT":
|
||||||
date_format = getattr(user_settings, "date_format", None)
|
date_format = getattr(user_settings, "date_format", None)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ async def remove_old_jobs(context, timestamp):
|
|||||||
return await builtin_tasks.remove_old_jobs(
|
return await builtin_tasks.remove_old_jobs(
|
||||||
context,
|
context,
|
||||||
max_hours=744,
|
max_hours=744,
|
||||||
remove_error=True,
|
remove_failed=True,
|
||||||
remove_cancelled=True,
|
remove_cancelled=True,
|
||||||
remove_aborted=True,
|
remove_aborted=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,6 +91,12 @@ def month_year_picker(request):
|
|||||||
for date in all_months
|
for date in all_months
|
||||||
]
|
]
|
||||||
|
|
||||||
|
today_url = (
|
||||||
|
reverse(url, kwargs={"month": current_date.month, "year": current_date.year})
|
||||||
|
if url
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"common/fragments/month_year_picker.html",
|
"common/fragments/month_year_picker.html",
|
||||||
@@ -98,6 +104,7 @@ def month_year_picker(request):
|
|||||||
"month_year_data": result,
|
"month_year_data": result,
|
||||||
"current_month": current_month,
|
"current_month": current_month,
|
||||||
"current_year": current_year,
|
"current_year": current_year,
|
||||||
|
"today_url": today_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
|
|||||||
self.attrs.update(
|
self.attrs.update(
|
||||||
{
|
{
|
||||||
"x-data": "",
|
"x-data": "",
|
||||||
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', "
|
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
|
||||||
f"'{get_format('THOUSAND_SEPARATOR')}', '30')",
|
|
||||||
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
|
"x-on:keyup": "$el.dispatchEvent(new Event('input'))",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ from datetime import timedelta
|
|||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.currencies.exchange_rates.providers import (
|
import apps.currencies.exchange_rates.providers as providers
|
||||||
SynthFinanceProvider,
|
|
||||||
SynthFinanceStockProvider,
|
|
||||||
CoinGeckoFreeProvider,
|
|
||||||
CoinGeckoProProvider,
|
|
||||||
TransitiveRateProvider,
|
|
||||||
)
|
|
||||||
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
from apps.currencies.models import ExchangeRateService, ExchangeRate, Currency
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,11 +12,12 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# Map service types to provider classes
|
# Map service types to provider classes
|
||||||
PROVIDER_MAPPING = {
|
PROVIDER_MAPPING = {
|
||||||
"synth_finance": SynthFinanceProvider,
|
"coingecko_free": providers.CoinGeckoFreeProvider,
|
||||||
"synth_finance_stock": SynthFinanceStockProvider,
|
"coingecko_pro": providers.CoinGeckoProProvider,
|
||||||
"coingecko_free": CoinGeckoFreeProvider,
|
"transitive": providers.TransitiveRateProvider,
|
||||||
"coingecko_pro": CoinGeckoProProvider,
|
"frankfurter": providers.FrankfurterProvider,
|
||||||
"transitive": TransitiveRateProvider,
|
"twelvedata": providers.TwelveDataProvider,
|
||||||
|
"twelvedatamarkets": providers.TwelveDataMarketsProvider,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,70 +13,6 @@ from apps.currencies.exchange_rates.base import ExchangeRateProvider
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SynthFinanceProvider(ExchangeRateProvider):
|
|
||||||
"""Implementation for Synth Finance API (synthfinance.com)"""
|
|
||||||
|
|
||||||
BASE_URL = "https://api.synthfinance.com/rates/live"
|
|
||||||
rates_inverted = False # SynthFinance returns non-inverted rates
|
|
||||||
|
|
||||||
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}"})
|
|
||||||
|
|
||||||
def get_rates(
|
|
||||||
self, target_currencies: QuerySet, exchange_currencies: set
|
|
||||||
) -> List[Tuple[Currency, Currency, Decimal]]:
|
|
||||||
results = []
|
|
||||||
currency_groups = {}
|
|
||||||
for currency in target_currencies:
|
|
||||||
if currency.exchange_currency in exchange_currencies:
|
|
||||||
group = currency_groups.setdefault(currency.exchange_currency.code, [])
|
|
||||||
group.append(currency)
|
|
||||||
|
|
||||||
for base_currency, currencies in currency_groups.items():
|
|
||||||
try:
|
|
||||||
to_currencies = ",".join(
|
|
||||||
currency.code
|
|
||||||
for currency in currencies
|
|
||||||
if currency.code != base_currency
|
|
||||||
)
|
|
||||||
response = self.session.get(
|
|
||||||
f"{self.BASE_URL}",
|
|
||||||
params={"from": base_currency, "to": to_currencies},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
rates = data["data"]["rates"]
|
|
||||||
|
|
||||||
for currency in currencies:
|
|
||||||
if currency.code == base_currency:
|
|
||||||
rate = Decimal("1")
|
|
||||||
else:
|
|
||||||
rate = Decimal(str(rates[currency.code]))
|
|
||||||
# Return the rate as is, without inversion
|
|
||||||
results.append((currency.exchange_currency, currency, rate))
|
|
||||||
|
|
||||||
credits_used = data["meta"]["credits_used"]
|
|
||||||
credits_remaining = data["meta"]["credits_remaining"]
|
|
||||||
logger.info(
|
|
||||||
f"Synth Finance API call: {credits_used} credits used, {credits_remaining} remaining"
|
|
||||||
)
|
|
||||||
except requests.RequestException as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error fetching rates from Synth Finance API for base {base_currency}: {e}"
|
|
||||||
)
|
|
||||||
except KeyError as e:
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected response structure from Synth Finance API for base {base_currency}: {e}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Unexpected error processing Synth Finance data for base {base_currency}: {e}"
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
class CoinGeckoFreeProvider(ExchangeRateProvider):
|
class CoinGeckoFreeProvider(ExchangeRateProvider):
|
||||||
"""Implementation for CoinGecko Free API"""
|
"""Implementation for CoinGecko Free API"""
|
||||||
|
|
||||||
@@ -152,71 +88,6 @@ class CoinGeckoProProvider(CoinGeckoFreeProvider):
|
|||||||
self.session.headers.update({"x-cg-pro-api-key": api_key})
|
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):
|
class TransitiveRateProvider(ExchangeRateProvider):
|
||||||
"""Calculates exchange rates through paths of existing rates"""
|
"""Calculates exchange rates through paths of existing rates"""
|
||||||
|
|
||||||
@@ -306,3 +177,329 @@ class TransitiveRateProvider(ExchangeRateProvider):
|
|||||||
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
queue.append((neighbor, path + [neighbor], current_rate * rate))
|
||||||
|
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
class FrankfurterProvider(ExchangeRateProvider):
|
||||||
|
"""Implementation for the Frankfurter API (frankfurter.dev)"""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.frankfurter.dev/v1/latest"
|
||||||
|
rates_inverted = (
|
||||||
|
False # Frankfurter returns non-inverted rates (e.g., 1 EUR = 1.1 USD)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, api_key: str = None):
|
||||||
|
"""
|
||||||
|
Initializes the provider. The Frankfurter API does not require an API key,
|
||||||
|
so the api_key parameter is ignored.
|
||||||
|
"""
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
@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 = []
|
||||||
|
currency_groups = {}
|
||||||
|
# Group target currencies by their exchange (base) currency to minimize API calls
|
||||||
|
for currency in target_currencies:
|
||||||
|
if currency.exchange_currency in exchange_currencies:
|
||||||
|
group = currency_groups.setdefault(currency.exchange_currency.code, [])
|
||||||
|
group.append(currency)
|
||||||
|
|
||||||
|
# Make one API call for each base currency
|
||||||
|
for base_currency, currencies in currency_groups.items():
|
||||||
|
try:
|
||||||
|
# Create a comma-separated list of target currency codes
|
||||||
|
to_currencies = ",".join(
|
||||||
|
currency.code
|
||||||
|
for currency in currencies
|
||||||
|
if currency.code != base_currency
|
||||||
|
)
|
||||||
|
|
||||||
|
# If there are no target currencies other than the base, skip the API call
|
||||||
|
if not to_currencies:
|
||||||
|
# Handle the case where the only request is for the base rate (e.g., USD to USD)
|
||||||
|
for currency in currencies:
|
||||||
|
if currency.code == base_currency:
|
||||||
|
results.append(
|
||||||
|
(currency.exchange_currency, currency, Decimal("1"))
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
response = self.session.get(
|
||||||
|
self.BASE_URL,
|
||||||
|
params={"base": base_currency, "symbols": to_currencies},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
rates = data["rates"]
|
||||||
|
|
||||||
|
# Process the returned rates
|
||||||
|
for currency in currencies:
|
||||||
|
if currency.code == base_currency:
|
||||||
|
# The rate for the base currency to itself is always 1
|
||||||
|
rate = Decimal("1")
|
||||||
|
else:
|
||||||
|
rate = Decimal(str(rates[currency.code]))
|
||||||
|
|
||||||
|
results.append((currency.exchange_currency, currency, rate))
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching rates from Frankfurter API for base {base_currency}: {e}"
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected response structure from Frankfurter API for base {base_currency}: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected error processing Frankfurter data for base {base_currency}: {e}"
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class TwelveDataProvider(ExchangeRateProvider):
|
||||||
|
"""Implementation for the Twelve Data API (twelvedata.com)"""
|
||||||
|
|
||||||
|
BASE_URL = "https://api.twelvedata.com/exchange_rate"
|
||||||
|
rates_inverted = (
|
||||||
|
False # The API returns direct rates, e.g., for EUR/USD it's 1 EUR = X USD
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
"""
|
||||||
|
Initializes the provider with an API key and a requests session.
|
||||||
|
"""
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
"""This provider requires an API key."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
"""
|
||||||
|
Fetches exchange rates from the Twelve Data API for the given currency pairs.
|
||||||
|
|
||||||
|
This provider makes one API call for each requested currency pair.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for target_currency in target_currencies:
|
||||||
|
# Ensure the target currency's exchange currency is one we're interested in
|
||||||
|
if target_currency.exchange_currency not in exchange_currencies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
base_currency = target_currency.exchange_currency
|
||||||
|
|
||||||
|
# The exchange rate for the same currency is always 1
|
||||||
|
if base_currency.code == target_currency.code:
|
||||||
|
rate = Decimal("1")
|
||||||
|
results.append((base_currency, target_currency, rate))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Construct the symbol in the format "BASE/TARGET", e.g., "EUR/USD"
|
||||||
|
symbol = f"{base_currency.code}/{target_currency.code}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"symbol": symbol,
|
||||||
|
"apikey": self.api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = self.session.get(self.BASE_URL, params=params)
|
||||||
|
response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# The API may return an error message in a JSON object
|
||||||
|
if "rate" not in data:
|
||||||
|
error_message = data.get("message", "Rate not found in response.")
|
||||||
|
logger.error(
|
||||||
|
f"Could not fetch rate for {symbol} from Twelve Data: {error_message}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert the rate to a Decimal for precision
|
||||||
|
rate = Decimal(str(data["rate"]))
|
||||||
|
results.append((base_currency, target_currency, rate))
|
||||||
|
|
||||||
|
logger.info(f"Successfully fetched rate for {symbol} from Twelve Data.")
|
||||||
|
|
||||||
|
time.sleep(
|
||||||
|
60
|
||||||
|
) # We sleep every pair as to not step over TwelveData's minute limit
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error fetching rate from Twelve Data API for symbol {symbol}: {e}"
|
||||||
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
logger.error(
|
||||||
|
f"Unexpected response structure from Twelve Data API for symbol {symbol}: Missing key {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"An unexpected error occurred while processing Twelve Data for {symbol}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
class TwelveDataMarketsProvider(ExchangeRateProvider):
|
||||||
|
"""
|
||||||
|
Provides prices for market instruments (stocks, ETFs, etc.) using the Twelve Data API.
|
||||||
|
|
||||||
|
This provider performs a multi-step process:
|
||||||
|
1. Parses instrument codes which can be symbols, FIGI, CUSIP, or ISIN.
|
||||||
|
2. For CUSIPs, it defaults the currency to USD. For all others, it searches
|
||||||
|
for the instrument to determine its native trading currency.
|
||||||
|
3. Fetches the latest price for the instrument in its native currency.
|
||||||
|
4. Converts the price to the requested target exchange currency.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SYMBOL_SEARCH_URL = "https://api.twelvedata.com/symbol_search"
|
||||||
|
PRICE_URL = "https://api.twelvedata.com/price"
|
||||||
|
EXCHANGE_RATE_URL = "https://api.twelvedata.com/exchange_rate"
|
||||||
|
|
||||||
|
rates_inverted = True
|
||||||
|
|
||||||
|
def __init__(self, api_key: str):
|
||||||
|
super().__init__(api_key)
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def requires_api_key(cls) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _parse_code(self, raw_code: str) -> Tuple[str, str]:
|
||||||
|
"""Parses the raw code to determine its type and value."""
|
||||||
|
if raw_code.startswith("figi:"):
|
||||||
|
return "figi", raw_code.removeprefix("figi:")
|
||||||
|
if raw_code.startswith("cusip:"):
|
||||||
|
return "cusip", raw_code.removeprefix("cusip:")
|
||||||
|
if raw_code.startswith("isin:"):
|
||||||
|
return "isin", raw_code.removeprefix("isin:")
|
||||||
|
return "symbol", raw_code
|
||||||
|
|
||||||
|
def get_rates(
|
||||||
|
self, target_currencies: QuerySet, exchange_currencies: set
|
||||||
|
) -> List[Tuple[Currency, Currency, Decimal]]:
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for asset in target_currencies:
|
||||||
|
if asset.exchange_currency not in exchange_currencies:
|
||||||
|
continue
|
||||||
|
|
||||||
|
code_type, code_value = self._parse_code(asset.code)
|
||||||
|
original_currency_code = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Determine the instrument's native currency
|
||||||
|
if code_type == "cusip":
|
||||||
|
# CUSIP codes always default to USD
|
||||||
|
original_currency_code = "USD"
|
||||||
|
logger.info(f"Defaulting CUSIP {code_value} to USD currency.")
|
||||||
|
else:
|
||||||
|
# For all other types, find currency via symbol search
|
||||||
|
search_params = {"symbol": code_value, "apikey": "demo"}
|
||||||
|
search_res = self.session.get(
|
||||||
|
self.SYMBOL_SEARCH_URL, params=search_params
|
||||||
|
)
|
||||||
|
search_res.raise_for_status()
|
||||||
|
search_data = search_res.json()
|
||||||
|
|
||||||
|
if not search_data.get("data"):
|
||||||
|
logger.warning(
|
||||||
|
f"TwelveDataMarkets: Symbol search for '{code_value}' returned no results."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
instrument_data = search_data["data"][0]
|
||||||
|
original_currency_code = instrument_data.get("currency")
|
||||||
|
|
||||||
|
if not original_currency_code:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Could not determine original currency for '{code_value}'."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get the instrument's price in its native currency
|
||||||
|
price_params = {code_type: code_value, "apikey": self.api_key}
|
||||||
|
price_res = self.session.get(self.PRICE_URL, params=price_params)
|
||||||
|
price_res.raise_for_status()
|
||||||
|
price_data = price_res.json()
|
||||||
|
|
||||||
|
if "price" not in price_data:
|
||||||
|
error_message = price_data.get(
|
||||||
|
"message", "Price key not found in response"
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Could not get price for {code_type} '{code_value}': {error_message}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
price_in_original_currency = Decimal(price_data["price"])
|
||||||
|
|
||||||
|
# Convert price to the target exchange currency
|
||||||
|
target_exchange_currency = asset.exchange_currency
|
||||||
|
|
||||||
|
if (
|
||||||
|
original_currency_code.upper()
|
||||||
|
== target_exchange_currency.code.upper()
|
||||||
|
):
|
||||||
|
final_price = price_in_original_currency
|
||||||
|
else:
|
||||||
|
rate_symbol = (
|
||||||
|
f"{original_currency_code}/{target_exchange_currency.code}"
|
||||||
|
)
|
||||||
|
rate_params = {"symbol": rate_symbol, "apikey": self.api_key}
|
||||||
|
rate_res = self.session.get(
|
||||||
|
self.EXCHANGE_RATE_URL, params=rate_params
|
||||||
|
)
|
||||||
|
rate_res.raise_for_status()
|
||||||
|
rate_data = rate_res.json()
|
||||||
|
|
||||||
|
if "rate" not in rate_data:
|
||||||
|
error_message = rate_data.get(
|
||||||
|
"message", "Rate key not found in response"
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Could not get conversion rate for '{rate_symbol}': {error_message}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
conversion_rate = Decimal(str(rate_data["rate"]))
|
||||||
|
final_price = price_in_original_currency * conversion_rate
|
||||||
|
|
||||||
|
results.append((target_exchange_currency, asset, final_price))
|
||||||
|
logger.info(
|
||||||
|
f"Successfully processed price for {asset.code} as {final_price} {target_exchange_currency.code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(
|
||||||
|
60
|
||||||
|
) # We sleep every pair as to not step over TwelveData's minute limit
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: API request failed for {code_value}: {e}"
|
||||||
|
)
|
||||||
|
except (KeyError, IndexError) as e:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: Error processing API response for {code_value}: {e}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"TwelveDataMarkets: An unexpected error occurred for {code_value}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
"suffix",
|
"suffix",
|
||||||
"code",
|
"code",
|
||||||
"exchange_currency",
|
"exchange_currency",
|
||||||
|
"is_archived",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"exchange_currency": TomSelect(),
|
"exchange_currency": TomSelect(),
|
||||||
@@ -40,6 +41,7 @@ class CurrencyForm(forms.ModelForm):
|
|||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
"code",
|
"code",
|
||||||
"name",
|
"name",
|
||||||
|
Switch("is_archived"),
|
||||||
"decimal_places",
|
"decimal_places",
|
||||||
"prefix",
|
"prefix",
|
||||||
"suffix",
|
"suffix",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-16 22:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0016_alter_exchangerate_automatic'),
|
||||||
|
]
|
||||||
|
|
||||||
|
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)'), ('frankfurter', 'Frankfurter')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 03:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0017_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)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:01
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0018_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)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:25
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
# The new value we are migrating to
|
||||||
|
NEW_SERVICE_TYPE = "frankfurter"
|
||||||
|
# The old values we are deprecating
|
||||||
|
OLD_SERVICE_TYPE_TO_UPDATE = "synth_finance"
|
||||||
|
OLD_SERVICE_TYPE_TO_DELETE = "synth_finance_stock"
|
||||||
|
|
||||||
|
|
||||||
|
def forwards_func(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Forward migration:
|
||||||
|
- Deletes all ExchangeRateService instances with service_type 'synth_finance_stock'.
|
||||||
|
- Updates all ExchangeRateService instances with service_type 'synth_finance' to 'frankfurter'.
|
||||||
|
"""
|
||||||
|
ExchangeRateService = apps.get_model("currencies", "ExchangeRateService")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
# 1. Delete the SYNTH_FINANCE_STOCK entries
|
||||||
|
ExchangeRateService.objects.using(db_alias).filter(
|
||||||
|
service_type=OLD_SERVICE_TYPE_TO_DELETE
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
# 2. Update the SYNTH_FINANCE entries to FRANKFURTER
|
||||||
|
ExchangeRateService.objects.using(db_alias).filter(
|
||||||
|
service_type=OLD_SERVICE_TYPE_TO_UPDATE
|
||||||
|
).update(service_type=NEW_SERVICE_TYPE, api_key=None)
|
||||||
|
|
||||||
|
|
||||||
|
def backwards_func(apps, schema_editor):
|
||||||
|
"""
|
||||||
|
Backward migration: This operation is not safely reversible.
|
||||||
|
- We cannot know which 'frankfurter' services were originally 'synth_finance'.
|
||||||
|
- The deleted 'synth_finance_stock' services cannot be recovered.
|
||||||
|
We will leave this function empty to allow migrating backwards without doing anything.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
# Add the previous migration file here
|
||||||
|
("currencies", "0019_alter_exchangerateservice_service_type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forwards_func, reverse_code=backwards_func),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-17 06:29
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0020_migrate_synth_finance_services'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='exchangerateservice',
|
||||||
|
name='service_type',
|
||||||
|
field=models.CharField(choices=[('coingecko_free', 'CoinGecko (Demo/Free)'), ('coingecko_pro', 'CoinGecko (Pro)'), ('transitive', 'Transitive (Calculated from Existing Rates)'), ('frankfurter', 'Frankfurter'), ('twelvedata', 'TwelveData'), ('twelvedatamarkets', 'TwelveData Markets')], max_length=255, verbose_name='Service Type'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-30 00:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('currencies', '0021_alter_exchangerateservice_service_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='currency',
|
||||||
|
name='is_archived',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Archived'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -32,6 +32,11 @@ class Currency(models.Model):
|
|||||||
help_text=_("Default currency for exchange calculations"),
|
help_text=_("Default currency for exchange calculations"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
is_archived = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Archived"),
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -94,11 +99,12 @@ class ExchangeRateService(models.Model):
|
|||||||
"""Configuration for exchange rate services"""
|
"""Configuration for exchange rate services"""
|
||||||
|
|
||||||
class ServiceType(models.TextChoices):
|
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_FREE = "coingecko_free", "CoinGecko (Demo/Free)"
|
||||||
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
COINGECKO_PRO = "coingecko_pro", "CoinGecko (Pro)"
|
||||||
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
TRANSITIVE = "transitive", "Transitive (Calculated from Existing Rates)"
|
||||||
|
FRANKFURTER = "frankfurter", "Frankfurter"
|
||||||
|
TWELVEDATA = "twelvedata", "TwelveData"
|
||||||
|
TWELVEDATA_MARKETS = "twelvedatamarkets", "TwelveData Markets"
|
||||||
|
|
||||||
class IntervalType(models.TextChoices):
|
class IntervalType(models.TextChoices):
|
||||||
ON = "on", _("On")
|
ON = "on", _("On")
|
||||||
|
|||||||
@@ -40,12 +40,6 @@ class CurrencyTests(TestCase):
|
|||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
currency.full_clean()
|
currency.full_clean()
|
||||||
|
|
||||||
def test_currency_unique_code(self):
|
|
||||||
"""Test that currency codes must be unique"""
|
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
|
||||||
with self.assertRaises(IntegrityError):
|
|
||||||
Currency.objects.create(code="USD", name="Another Dollar", decimal_places=2)
|
|
||||||
|
|
||||||
def test_currency_unique_name(self):
|
def test_currency_unique_name(self):
|
||||||
"""Test that currency names must be unique"""
|
"""Test that currency names must be unique"""
|
||||||
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
Currency.objects.create(code="USD", name="US Dollar", decimal_places=2)
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ from apps.currencies.models import Currency
|
|||||||
from apps.currencies.utils.convert import convert
|
from apps.currencies.utils.convert import convert
|
||||||
|
|
||||||
|
|
||||||
def get_categories_totals(transactions_queryset, ignore_empty=False):
|
def get_categories_totals(
|
||||||
# First get the category totals as before
|
transactions_queryset, ignore_empty=False, show_entities=False
|
||||||
|
):
|
||||||
|
# Step 1: Aggregate transaction data by category and currency.
|
||||||
|
# This query calculates the total current and projected income/expense for each
|
||||||
|
# category by grouping transactions and summing up their amounts based on their
|
||||||
|
# type (income/expense) and payment status (paid/unpaid).
|
||||||
category_currency_metrics = (
|
category_currency_metrics = (
|
||||||
transactions_queryset.values(
|
transactions_queryset.values(
|
||||||
"category",
|
"category",
|
||||||
@@ -74,7 +79,10 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
.order_by("category__name")
|
.order_by("category__name")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get tag totals within each category with currency details
|
# Step 2: Aggregate transaction data by tag, category, and currency.
|
||||||
|
# This is similar to the category metrics but adds tags to the grouping,
|
||||||
|
# allowing for a breakdown of totals by tag within each category. It also
|
||||||
|
# handles untagged transactions, where the 'tags' field is None.
|
||||||
tag_metrics = transactions_queryset.values(
|
tag_metrics = transactions_queryset.values(
|
||||||
"category",
|
"category",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -129,10 +137,12 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the results to structure by category
|
# Step 3: Initialize the main dictionary to structure the final results.
|
||||||
|
# The data will be organized hierarchically: category -> currency -> tags -> entities.
|
||||||
result = {}
|
result = {}
|
||||||
|
|
||||||
# Process category totals first
|
# Step 4: Process the aggregated category metrics to build the initial result structure.
|
||||||
|
# This loop iterates through each category's metrics and populates the `result` dict.
|
||||||
for metric in category_currency_metrics:
|
for metric in category_currency_metrics:
|
||||||
# Skip empty categories if ignore_empty is True
|
# Skip empty categories if ignore_empty is True
|
||||||
if ignore_empty and all(
|
if ignore_empty and all(
|
||||||
@@ -183,7 +193,7 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"total_final": total_final,
|
"total_final": total_final,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add exchanged values if exchange_currency exists
|
# Step 4a: Handle currency conversion for category totals if an exchange currency is defined.
|
||||||
if metric["account__currency__exchange_currency"]:
|
if metric["account__currency__exchange_currency"]:
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
exchange_currency = Currency.objects.get(
|
exchange_currency = Currency.objects.get(
|
||||||
@@ -222,7 +232,7 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
|
|
||||||
result[category_id]["currencies"][currency_id] = currency_data
|
result[category_id]["currencies"][currency_id] = currency_data
|
||||||
|
|
||||||
# Process tag totals and add them to the result, including untagged
|
# Step 5: Process the aggregated tag metrics and integrate them into the result structure.
|
||||||
for tag_metric in tag_metrics:
|
for tag_metric in tag_metrics:
|
||||||
category_id = tag_metric["category"]
|
category_id = tag_metric["category"]
|
||||||
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
tag_id = tag_metric["tags"] # Will be None for untagged transactions
|
||||||
@@ -240,6 +250,7 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
result[category_id]["tags"][tag_key] = {
|
result[category_id]["tags"][tag_key] = {
|
||||||
"name": tag_name,
|
"name": tag_name,
|
||||||
"currencies": {},
|
"currencies": {},
|
||||||
|
"entities": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
currency_id = tag_metric["account__currency"]
|
currency_id = tag_metric["account__currency"]
|
||||||
@@ -278,7 +289,7 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
"total_final": tag_total_final,
|
"total_final": tag_total_final,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add exchange currency support for tags
|
# Step 5a: Handle currency conversion for tag totals.
|
||||||
if tag_metric["account__currency__exchange_currency"]:
|
if tag_metric["account__currency__exchange_currency"]:
|
||||||
from_currency = Currency.objects.get(id=currency_id)
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
exchange_currency = Currency.objects.get(
|
exchange_currency = Currency.objects.get(
|
||||||
@@ -319,4 +330,175 @@ def get_categories_totals(transactions_queryset, ignore_empty=False):
|
|||||||
currency_id
|
currency_id
|
||||||
] = tag_currency_data
|
] = tag_currency_data
|
||||||
|
|
||||||
|
# Step 6: If requested, aggregate and process entity-level data.
|
||||||
|
if show_entities:
|
||||||
|
entity_metrics = transactions_queryset.values(
|
||||||
|
"category",
|
||||||
|
"tags",
|
||||||
|
"entities",
|
||||||
|
"entities__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"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for entity_metric in entity_metrics:
|
||||||
|
category_id = entity_metric["category"]
|
||||||
|
tag_id = entity_metric["tags"]
|
||||||
|
entity_id = entity_metric["entities"]
|
||||||
|
|
||||||
|
if category_id in result:
|
||||||
|
tag_key = tag_id if tag_id is not None else "untagged"
|
||||||
|
if tag_key in result[category_id]["tags"]:
|
||||||
|
entity_key = entity_id if entity_id is not None else "no_entity"
|
||||||
|
entity_name = (
|
||||||
|
entity_metric["entities__name"]
|
||||||
|
if entity_id is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if "entities" not in result[category_id]["tags"][tag_key]:
|
||||||
|
result[category_id]["tags"][tag_key]["entities"] = {}
|
||||||
|
|
||||||
|
if (
|
||||||
|
entity_key
|
||||||
|
not in result[category_id]["tags"][tag_key]["entities"]
|
||||||
|
):
|
||||||
|
result[category_id]["tags"][tag_key]["entities"][entity_key] = {
|
||||||
|
"name": entity_name,
|
||||||
|
"currencies": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
currency_id = entity_metric["account__currency"]
|
||||||
|
|
||||||
|
entity_total_current = (
|
||||||
|
entity_metric["income_current"]
|
||||||
|
- entity_metric["expense_current"]
|
||||||
|
)
|
||||||
|
entity_total_projected = (
|
||||||
|
entity_metric["income_projected"]
|
||||||
|
- entity_metric["expense_projected"]
|
||||||
|
)
|
||||||
|
entity_total_income = (
|
||||||
|
entity_metric["income_current"]
|
||||||
|
+ entity_metric["income_projected"]
|
||||||
|
)
|
||||||
|
entity_total_expense = (
|
||||||
|
entity_metric["expense_current"]
|
||||||
|
+ entity_metric["expense_projected"]
|
||||||
|
)
|
||||||
|
entity_total_final = entity_total_current + entity_total_projected
|
||||||
|
|
||||||
|
entity_currency_data = {
|
||||||
|
"currency": {
|
||||||
|
"code": entity_metric["account__currency__code"],
|
||||||
|
"name": entity_metric["account__currency__name"],
|
||||||
|
"decimal_places": entity_metric[
|
||||||
|
"account__currency__decimal_places"
|
||||||
|
],
|
||||||
|
"prefix": entity_metric["account__currency__prefix"],
|
||||||
|
"suffix": entity_metric["account__currency__suffix"],
|
||||||
|
},
|
||||||
|
"expense_current": entity_metric["expense_current"],
|
||||||
|
"expense_projected": entity_metric["expense_projected"],
|
||||||
|
"total_expense": entity_total_expense,
|
||||||
|
"income_current": entity_metric["income_current"],
|
||||||
|
"income_projected": entity_metric["income_projected"],
|
||||||
|
"total_income": entity_total_income,
|
||||||
|
"total_current": entity_total_current,
|
||||||
|
"total_projected": entity_total_projected,
|
||||||
|
"total_final": entity_total_final,
|
||||||
|
}
|
||||||
|
|
||||||
|
if entity_metric["account__currency__exchange_currency"]:
|
||||||
|
from_currency = Currency.objects.get(id=currency_id)
|
||||||
|
exchange_currency = Currency.objects.get(
|
||||||
|
id=entity_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=entity_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:
|
||||||
|
entity_currency_data["exchanged"] = exchanged
|
||||||
|
|
||||||
|
result[category_id]["tags"][tag_key]["entities"][entity_key][
|
||||||
|
"currencies"
|
||||||
|
][currency_id] = entity_currency_data
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ from apps.insights.forms import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_transactions(request, include_unpaid=True, include_silent=False):
|
def get_transactions(
|
||||||
|
request, include_unpaid=True, include_silent=False, include_untracked_accounts=False
|
||||||
|
):
|
||||||
transactions = Transaction.objects.all()
|
transactions = Transaction.objects.all()
|
||||||
|
|
||||||
filter_type = request.GET.get("type", None)
|
filter_type = request.GET.get("type", None)
|
||||||
@@ -95,4 +97,11 @@ def get_transactions(request, include_unpaid=True, include_silent=False):
|
|||||||
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
|
Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not include_untracked_accounts:
|
||||||
|
transactions = transactions.exclude(
|
||||||
|
account__in=request.user.untracked_accounts.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
transactions = transactions.exclude(account__currency__is_archived=True)
|
||||||
|
|
||||||
return transactions
|
return transactions
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def index(request):
|
|||||||
def sankey_by_account(request):
|
def sankey_by_account(request):
|
||||||
# Get filtered transactions
|
# Get filtered transactions
|
||||||
|
|
||||||
transactions = get_transactions(request)
|
transactions = get_transactions(request, include_untracked_accounts=True)
|
||||||
|
|
||||||
# Generate Sankey data
|
# Generate Sankey data
|
||||||
sankey_data = generate_sankey_data_by_account(transactions)
|
sankey_data = generate_sankey_data_by_account(transactions)
|
||||||
@@ -180,6 +180,14 @@ def category_overview(request):
|
|||||||
else:
|
else:
|
||||||
show_tags = request.session.get("insights_category_explorer_show_tags", True)
|
show_tags = request.session.get("insights_category_explorer_show_tags", True)
|
||||||
|
|
||||||
|
if "show_entities" in request.GET:
|
||||||
|
show_entities = request.GET["show_entities"] == "on"
|
||||||
|
request.session["insights_category_explorer_show_entities"] = show_entities
|
||||||
|
else:
|
||||||
|
show_entities = request.session.get(
|
||||||
|
"insights_category_explorer_show_entities", False
|
||||||
|
)
|
||||||
|
|
||||||
if "showing" in request.GET:
|
if "showing" in request.GET:
|
||||||
showing = request.GET["showing"]
|
showing = request.GET["showing"]
|
||||||
request.session["insights_category_explorer_showing"] = showing
|
request.session["insights_category_explorer_showing"] = showing
|
||||||
@@ -190,7 +198,9 @@ def category_overview(request):
|
|||||||
transactions = get_transactions(request, include_silent=True)
|
transactions = get_transactions(request, include_silent=True)
|
||||||
|
|
||||||
total_table = get_categories_totals(
|
total_table = get_categories_totals(
|
||||||
transactions_queryset=transactions, ignore_empty=False
|
transactions_queryset=transactions,
|
||||||
|
ignore_empty=False,
|
||||||
|
show_entities=show_entities,
|
||||||
)
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@@ -200,6 +210,7 @@ def category_overview(request):
|
|||||||
"total_table": total_table,
|
"total_table": total_table,
|
||||||
"view_type": view_type,
|
"view_type": view_type,
|
||||||
"show_tags": show_tags,
|
"show_tags": show_tags,
|
||||||
|
"show_entities": show_entities,
|
||||||
"showing": showing,
|
"showing": showing,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -239,11 +250,15 @@ def late_transactions(request):
|
|||||||
@login_required
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def emergency_fund(request):
|
def emergency_fund(request):
|
||||||
transactions_currency_queryset = Transaction.objects.filter(
|
transactions_currency_queryset = (
|
||||||
|
Transaction.objects.filter(
|
||||||
is_paid=True, account__is_archived=False, account__is_asset=False
|
is_paid=True, account__is_archived=False, account__is_asset=False
|
||||||
).order_by(
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
.order_by(
|
||||||
"account__currency__name",
|
"account__currency__name",
|
||||||
)
|
)
|
||||||
|
)
|
||||||
currency_net_worth = calculate_currency_totals(
|
currency_net_worth = calculate_currency_totals(
|
||||||
transactions_queryset=transactions_currency_queryset, ignore_empty=False
|
transactions_queryset=transactions_currency_queryset, ignore_empty=False
|
||||||
)
|
)
|
||||||
@@ -262,6 +277,7 @@ def emergency_fund(request):
|
|||||||
category__mute=False,
|
category__mute=False,
|
||||||
mute=False,
|
mute=False,
|
||||||
)
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
.values("reference_date", "account__currency")
|
.values("reference_date", "account__currency")
|
||||||
.annotate(monthly_total=Sum("amount"))
|
.annotate(monthly_total=Sum("amount"))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -107,9 +107,15 @@ def transactions_list(request, month: int, year: int):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def monthly_summary(request, month: int, year: int):
|
def monthly_summary(request, month: int, year: int):
|
||||||
# Base queryset with all required filters
|
# Base queryset with all required filters
|
||||||
base_queryset = Transaction.objects.filter(
|
base_queryset = (
|
||||||
reference_date__year=year, reference_date__month=month, account__is_asset=False
|
Transaction.objects.filter(
|
||||||
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
reference_date__year=year,
|
||||||
|
reference_date__month=month,
|
||||||
|
account__is_asset=False,
|
||||||
|
)
|
||||||
|
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
|
|
||||||
data = calculate_currency_totals(base_queryset, ignore_empty=True)
|
data = calculate_currency_totals(base_queryset, ignore_empty=True)
|
||||||
percentages = calculate_percentage_distribution(data)
|
percentages = calculate_percentage_distribution(data)
|
||||||
@@ -165,10 +171,14 @@ def monthly_account_summary(request, month: int, year: int):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def monthly_currency_summary(request, month: int, year: int):
|
def monthly_currency_summary(request, month: int, year: int):
|
||||||
# Base queryset with all required filters
|
# Base queryset with all required filters
|
||||||
base_queryset = Transaction.objects.filter(
|
base_queryset = (
|
||||||
|
Transaction.objects.filter(
|
||||||
reference_date__year=year,
|
reference_date__year=year,
|
||||||
reference_date__month=month,
|
reference_date__month=month,
|
||||||
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
)
|
||||||
|
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
|
|
||||||
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
|
currency_data = calculate_currency_totals(base_queryset.all(), ignore_empty=True)
|
||||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ def calculate_historical_currency_net_worth(queryset):
|
|||||||
| Q(accounts__visibility="private", accounts__owner=None),
|
| Q(accounts__visibility="private", accounts__owner=None),
|
||||||
accounts__is_archived=False,
|
accounts__is_archived=False,
|
||||||
accounts__isnull=False,
|
accounts__isnull=False,
|
||||||
|
is_archived=False,
|
||||||
)
|
)
|
||||||
.values_list("name", flat=True)
|
.values_list("name", flat=True)
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|||||||
@@ -20,18 +20,19 @@ from apps.transactions.utils.calculations import (
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def net_worth(request):
|
def net_worth(request):
|
||||||
if "view_type" in request.GET:
|
if "view_type" in request.GET:
|
||||||
print(request.GET["view_type"])
|
|
||||||
view_type = request.GET["view_type"]
|
view_type = request.GET["view_type"]
|
||||||
request.session["networth_view_type"] = view_type
|
request.session["networth_view_type"] = view_type
|
||||||
else:
|
else:
|
||||||
view_type = request.session.get("networth_view_type", "current")
|
view_type = request.session.get("networth_view_type", "current")
|
||||||
|
|
||||||
if view_type == "current":
|
if view_type == "current":
|
||||||
transactions_currency_queryset = Transaction.objects.filter(
|
transactions_currency_queryset = (
|
||||||
is_paid=True, account__is_archived=False
|
Transaction.objects.filter(is_paid=True, account__is_archived=False)
|
||||||
).order_by(
|
.order_by(
|
||||||
"account__currency__name",
|
"account__currency__name",
|
||||||
)
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
transactions_account_queryset = Transaction.objects.filter(
|
transactions_account_queryset = Transaction.objects.filter(
|
||||||
is_paid=True, account__is_archived=False
|
is_paid=True, account__is_archived=False
|
||||||
).order_by(
|
).order_by(
|
||||||
@@ -39,11 +40,13 @@ def net_worth(request):
|
|||||||
"account__name",
|
"account__name",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
transactions_currency_queryset = Transaction.objects.filter(
|
transactions_currency_queryset = (
|
||||||
account__is_archived=False
|
Transaction.objects.filter(account__is_archived=False)
|
||||||
).order_by(
|
.order_by(
|
||||||
"account__currency__name",
|
"account__currency__name",
|
||||||
)
|
)
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
|
)
|
||||||
transactions_account_queryset = Transaction.objects.filter(
|
transactions_account_queryset = Transaction.objects.filter(
|
||||||
account__is_archived=False
|
account__is_archived=False
|
||||||
).order_by(
|
).order_by(
|
||||||
|
|||||||
+137
-3
@@ -1,15 +1,19 @@
|
|||||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
from crispy_forms.bootstrap import FormActions, AccordionGroup
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
from crispy_forms.layout import Layout, Field, Row, Column
|
from crispy_forms.layout import Layout, Field, Row, Column, HTML
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
from apps.common.widgets.tom_select import TomSelect
|
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||||
|
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||||
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
from apps.rules.models import TransactionRule, UpdateOrCreateTransactionRuleAction
|
||||||
from apps.rules.models import TransactionRuleAction
|
from apps.rules.models import TransactionRuleAction
|
||||||
|
from apps.common.fields.forms.dynamic_select import DynamicModelChoiceField
|
||||||
|
from apps.transactions.forms import BulkEditTransactionForm
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
class TransactionRuleForm(forms.ModelForm):
|
class TransactionRuleForm(forms.ModelForm):
|
||||||
@@ -40,6 +44,8 @@ class TransactionRuleForm(forms.ModelForm):
|
|||||||
Column(Switch("on_create")),
|
Column(Switch("on_create")),
|
||||||
Column(Switch("on_delete")),
|
Column(Switch("on_delete")),
|
||||||
),
|
),
|
||||||
|
"order",
|
||||||
|
Switch("sequenced"),
|
||||||
"description",
|
"description",
|
||||||
"trigger",
|
"trigger",
|
||||||
)
|
)
|
||||||
@@ -65,10 +71,11 @@ class TransactionRuleForm(forms.ModelForm):
|
|||||||
class TransactionRuleActionForm(forms.ModelForm):
|
class TransactionRuleActionForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TransactionRuleAction
|
model = TransactionRuleAction
|
||||||
fields = ("value", "field")
|
fields = ("value", "field", "order")
|
||||||
labels = {
|
labels = {
|
||||||
"field": _("Set field"),
|
"field": _("Set field"),
|
||||||
"value": _("To"),
|
"value": _("To"),
|
||||||
|
"order": _("Order"),
|
||||||
}
|
}
|
||||||
widgets = {"field": TomSelect(clear_button=False)}
|
widgets = {"field": TomSelect(clear_button=False)}
|
||||||
|
|
||||||
@@ -82,6 +89,7 @@ class TransactionRuleActionForm(forms.ModelForm):
|
|||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
# TO-DO: Add helper with available commands
|
# TO-DO: Add helper with available commands
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
|
"order",
|
||||||
"field",
|
"field",
|
||||||
"value",
|
"value",
|
||||||
)
|
)
|
||||||
@@ -147,9 +155,11 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_category_operator": TomSelect(clear_button=False),
|
"search_category_operator": TomSelect(clear_button=False),
|
||||||
"search_internal_note_operator": TomSelect(clear_button=False),
|
"search_internal_note_operator": TomSelect(clear_button=False),
|
||||||
"search_internal_id_operator": TomSelect(clear_button=False),
|
"search_internal_id_operator": TomSelect(clear_button=False),
|
||||||
|
"search_mute_operator": TomSelect(clear_button=False),
|
||||||
}
|
}
|
||||||
|
|
||||||
labels = {
|
labels = {
|
||||||
|
"order": _("Order"),
|
||||||
"search_account_operator": _("Operator"),
|
"search_account_operator": _("Operator"),
|
||||||
"search_type_operator": _("Operator"),
|
"search_type_operator": _("Operator"),
|
||||||
"search_is_paid_operator": _("Operator"),
|
"search_is_paid_operator": _("Operator"),
|
||||||
@@ -163,6 +173,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_internal_id_operator": _("Operator"),
|
"search_internal_id_operator": _("Operator"),
|
||||||
"search_tags_operator": _("Operator"),
|
"search_tags_operator": _("Operator"),
|
||||||
"search_entities_operator": _("Operator"),
|
"search_entities_operator": _("Operator"),
|
||||||
|
"search_mute_operator": _("Operator"),
|
||||||
"search_account": _("Account"),
|
"search_account": _("Account"),
|
||||||
"search_type": _("Type"),
|
"search_type": _("Type"),
|
||||||
"search_is_paid": _("Paid"),
|
"search_is_paid": _("Paid"),
|
||||||
@@ -176,6 +187,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"search_internal_id": _("Internal ID"),
|
"search_internal_id": _("Internal ID"),
|
||||||
"search_tags": _("Tags"),
|
"search_tags": _("Tags"),
|
||||||
"search_entities": _("Entities"),
|
"search_entities": _("Entities"),
|
||||||
|
"search_mute": _("Mute"),
|
||||||
"set_account": _("Account"),
|
"set_account": _("Account"),
|
||||||
"set_type": _("Type"),
|
"set_type": _("Type"),
|
||||||
"set_is_paid": _("Paid"),
|
"set_is_paid": _("Paid"),
|
||||||
@@ -189,6 +201,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
"set_category": _("Category"),
|
"set_category": _("Category"),
|
||||||
"set_internal_note": _("Internal Note"),
|
"set_internal_note": _("Internal Note"),
|
||||||
"set_internal_id": _("Internal ID"),
|
"set_internal_id": _("Internal ID"),
|
||||||
|
"set_mute": _("Mute"),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -200,6 +213,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
self.helper.form_method = "post"
|
self.helper.form_method = "post"
|
||||||
|
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
|
"order",
|
||||||
BS5Accordion(
|
BS5Accordion(
|
||||||
AccordionGroup(
|
AccordionGroup(
|
||||||
_("Search Criteria"),
|
_("Search Criteria"),
|
||||||
@@ -224,6 +238,16 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
css_class="form-group col-md-8",
|
css_class="form-group col-md-8",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Row(
|
||||||
|
Column(
|
||||||
|
Field("search_mute_operator"),
|
||||||
|
css_class="form-group col-md-4",
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
Field("search_mute", rows=1),
|
||||||
|
css_class="form-group col-md-8",
|
||||||
|
),
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
Column(
|
Column(
|
||||||
Field("search_account_operator"),
|
Field("search_account_operator"),
|
||||||
@@ -340,6 +364,7 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
_("Set Values"),
|
_("Set Values"),
|
||||||
Field("set_type", rows=1),
|
Field("set_type", rows=1),
|
||||||
Field("set_is_paid", rows=1),
|
Field("set_is_paid", rows=1),
|
||||||
|
Field("set_mute", rows=1),
|
||||||
Field("set_account", rows=1),
|
Field("set_account", rows=1),
|
||||||
Field("set_entities", rows=1),
|
Field("set_entities", rows=1),
|
||||||
Field("set_date", rows=1),
|
Field("set_date", rows=1),
|
||||||
@@ -381,3 +406,112 @@ class UpdateOrCreateTransactionRuleActionForm(forms.ModelForm):
|
|||||||
if commit:
|
if commit:
|
||||||
instance.save()
|
instance.save()
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunCreatedTransacion(forms.Form):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"transaction",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunDeletedTransacion(forms.Form):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.layout = Layout(
|
||||||
|
"transaction",
|
||||||
|
FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunUpdatedTransactionForm(BulkEditTransactionForm):
|
||||||
|
transaction = DynamicModelChoiceField(
|
||||||
|
model=Transaction,
|
||||||
|
to_field_name="id",
|
||||||
|
label=_("Transaction"),
|
||||||
|
required=True,
|
||||||
|
queryset=Transaction.objects.none(),
|
||||||
|
widget=TransactionSelect(clear_button=False, income=True, expense=True),
|
||||||
|
help_text=_("Type to search for a transaction"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.helper.layout.insert(0, "transaction")
|
||||||
|
self.helper.layout.insert(1, HTML("<hr/>"))
|
||||||
|
|
||||||
|
# Change submit button
|
||||||
|
self.helper.layout[-1] = FormActions(
|
||||||
|
NoClassSubmit(
|
||||||
|
"submit", _("Test"), css_class="btn btn-outline-primary w-100"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.data.get("transaction"):
|
||||||
|
try:
|
||||||
|
transaction = Transaction.objects.get(id=self.data.get("transaction"))
|
||||||
|
except Transaction.DoesNotExist:
|
||||||
|
transaction = None
|
||||||
|
|
||||||
|
if transaction:
|
||||||
|
self.fields["transaction"].queryset = Transaction.objects.filter(
|
||||||
|
id=transaction.id
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 5.2 on 2025-08-30 18:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("rules", "0014_alter_transactionrule_owner_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="transactionruleaction",
|
||||||
|
options={
|
||||||
|
"ordering": ["order"],
|
||||||
|
"verbose_name": "Edit transaction action",
|
||||||
|
"verbose_name_plural": "Edit transaction actions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="updateorcreatetransactionruleaction",
|
||||||
|
options={
|
||||||
|
"ordering": ["order"],
|
||||||
|
"verbose_name": "Update or create transaction action",
|
||||||
|
"verbose_name_plural": "Update or create transaction actions",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="transactionruleaction",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="updateorcreatetransactionruleaction",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-31 18:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0015_alter_transactionruleaction_options_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='sequenced',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='Sequenced'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-08-31 19:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0016_transactionrule_sequenced'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='search_mute',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Search Mute'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='search_mute_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='exact', max_length=10, verbose_name='Mute Operator'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='updateorcreatetransactionruleaction',
|
||||||
|
name='set_mute',
|
||||||
|
field=models.TextField(blank=True, verbose_name='Mute'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='transactionruleaction',
|
||||||
|
name='field',
|
||||||
|
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.5 on 2025-09-02 14:52
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transactionrule',
|
||||||
|
name='order',
|
||||||
|
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -13,6 +13,11 @@ class TransactionRule(SharedObject):
|
|||||||
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
||||||
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||||
trigger = models.TextField(verbose_name=_("Trigger"))
|
trigger = models.TextField(verbose_name=_("Trigger"))
|
||||||
|
sequenced = models.BooleanField(
|
||||||
|
verbose_name=_("Sequenced"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
objects = SharedObjectManager()
|
objects = SharedObjectManager()
|
||||||
all_objects = models.Manager() # Unfiltered manager
|
all_objects = models.Manager() # Unfiltered manager
|
||||||
@@ -32,12 +37,15 @@ class TransactionRuleAction(models.Model):
|
|||||||
is_paid = "is_paid", _("Paid")
|
is_paid = "is_paid", _("Paid")
|
||||||
date = "date", _("Date")
|
date = "date", _("Date")
|
||||||
reference_date = "reference_date", _("Reference Date")
|
reference_date = "reference_date", _("Reference Date")
|
||||||
|
mute = "mute", _("Mute")
|
||||||
amount = "amount", _("Amount")
|
amount = "amount", _("Amount")
|
||||||
description = "description", _("Description")
|
description = "description", _("Description")
|
||||||
notes = "notes", _("Notes")
|
notes = "notes", _("Notes")
|
||||||
category = "category", _("Category")
|
category = "category", _("Category")
|
||||||
tags = "tags", _("Tags")
|
tags = "tags", _("Tags")
|
||||||
entities = "entities", _("Entities")
|
entities = "entities", _("Entities")
|
||||||
|
internal_note = "internal_nome", _("Internal Note")
|
||||||
|
internal_id = "internal_id", _("Internal ID")
|
||||||
|
|
||||||
rule = models.ForeignKey(
|
rule = models.ForeignKey(
|
||||||
TransactionRule,
|
TransactionRule,
|
||||||
@@ -51,6 +59,7 @@ class TransactionRuleAction(models.Model):
|
|||||||
verbose_name=_("Field"),
|
verbose_name=_("Field"),
|
||||||
)
|
)
|
||||||
value = models.TextField(verbose_name=_("Value"))
|
value = models.TextField(verbose_name=_("Value"))
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.rule} - {self.field} - {self.value}"
|
return f"{self.rule} - {self.field} - {self.value}"
|
||||||
@@ -59,6 +68,11 @@ class TransactionRuleAction(models.Model):
|
|||||||
verbose_name = _("Edit transaction action")
|
verbose_name = _("Edit transaction action")
|
||||||
verbose_name_plural = _("Edit transaction actions")
|
verbose_name_plural = _("Edit transaction actions")
|
||||||
unique_together = (("rule", "field"),)
|
unique_together = (("rule", "field"),)
|
||||||
|
ordering = ["order"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_type(self):
|
||||||
|
return "edit_transaction"
|
||||||
|
|
||||||
|
|
||||||
class UpdateOrCreateTransactionRuleAction(models.Model):
|
class UpdateOrCreateTransactionRuleAction(models.Model):
|
||||||
@@ -237,6 +251,17 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
verbose_name="Internal ID Operator",
|
verbose_name="Internal ID Operator",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
search_mute = models.TextField(
|
||||||
|
verbose_name="Search Mute",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
search_mute_operator = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=SearchOperator.choices,
|
||||||
|
default=SearchOperator.EXACT,
|
||||||
|
verbose_name="Mute Operator",
|
||||||
|
)
|
||||||
|
|
||||||
# Set fields
|
# Set fields
|
||||||
set_account = models.TextField(
|
set_account = models.TextField(
|
||||||
verbose_name=_("Account"),
|
verbose_name=_("Account"),
|
||||||
@@ -290,10 +315,21 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
verbose_name=_("Tags"),
|
verbose_name=_("Tags"),
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
set_mute = models.TextField(
|
||||||
|
verbose_name=_("Mute"),
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Order"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Update or create transaction action")
|
verbose_name = _("Update or create transaction action")
|
||||||
verbose_name_plural = _("Update or create transaction actions")
|
verbose_name_plural = _("Update or create transaction actions")
|
||||||
|
ordering = ["order"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def action_type(self):
|
||||||
|
return "update_or_create_transaction"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Update or create transaction action for {self.rule}"
|
return f"Update or create transaction action for {self.rule}"
|
||||||
@@ -325,6 +361,10 @@ class UpdateOrCreateTransactionRuleAction(models.Model):
|
|||||||
value = simple.eval(self.search_is_paid)
|
value = simple.eval(self.search_is_paid)
|
||||||
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
search_query &= add_to_query("is_paid", value, self.search_is_paid_operator)
|
||||||
|
|
||||||
|
if self.search_mute:
|
||||||
|
value = simple.eval(self.search_mute)
|
||||||
|
search_query &= add_to_query("mute", value, self.search_mute_operator)
|
||||||
|
|
||||||
if self.search_date:
|
if self.search_date:
|
||||||
value = simple.eval(self.search_date)
|
value = simple.eval(self.search_date)
|
||||||
search_query &= add_to_query("date", value, self.search_date_operator)
|
search_query &= add_to_query("date", value, self.search_date_operator)
|
||||||
|
|||||||
@@ -9,40 +9,17 @@ from apps.transactions.models import (
|
|||||||
)
|
)
|
||||||
from apps.rules.tasks import check_for_transaction_rules
|
from apps.rules.tasks import check_for_transaction_rules
|
||||||
from apps.common.middleware.thread_local import get_current_user
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
from apps.rules.utils.transactions import serialize_transaction
|
||||||
|
|
||||||
|
|
||||||
@receiver(transaction_created)
|
@receiver(transaction_created)
|
||||||
@receiver(transaction_updated)
|
@receiver(transaction_updated)
|
||||||
@receiver(transaction_deleted)
|
@receiver(transaction_deleted)
|
||||||
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
||||||
|
old_data = kwargs.get("old_data")
|
||||||
if signal is transaction_deleted:
|
if signal is transaction_deleted:
|
||||||
# Serialize transaction data for processing
|
# Serialize transaction data for processing
|
||||||
transaction_data = {
|
transaction_data = serialize_transaction(sender, deleted=True)
|
||||||
"id": sender.id,
|
|
||||||
"account": (sender.account.id, sender.account.name),
|
|
||||||
"account_group": (
|
|
||||||
sender.account.group.id if sender.account.group else None,
|
|
||||||
sender.account.group.name if sender.account.group else None,
|
|
||||||
),
|
|
||||||
"type": str(sender.type),
|
|
||||||
"is_paid": sender.is_paid,
|
|
||||||
"is_asset": sender.account.is_asset,
|
|
||||||
"is_archived": sender.account.is_archived,
|
|
||||||
"category": (
|
|
||||||
sender.category.id if sender.category else None,
|
|
||||||
sender.category.name if sender.category else None,
|
|
||||||
),
|
|
||||||
"date": sender.date.isoformat(),
|
|
||||||
"reference_date": sender.reference_date.isoformat(),
|
|
||||||
"amount": str(sender.amount),
|
|
||||||
"description": sender.description,
|
|
||||||
"notes": sender.notes,
|
|
||||||
"tags": list(sender.tags.values_list("id", "name")),
|
|
||||||
"entities": list(sender.entities.values_list("id", "name")),
|
|
||||||
"deleted": True,
|
|
||||||
"internal_note": sender.internal_note,
|
|
||||||
"internal_id": sender.internal_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
check_for_transaction_rules.defer(
|
check_for_transaction_rules.defer(
|
||||||
transaction_data=transaction_data,
|
transaction_data=transaction_data,
|
||||||
@@ -59,6 +36,9 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
|||||||
dca_entry.amount_received = sender.amount
|
dca_entry.amount_received = sender.amount
|
||||||
dca_entry.save()
|
dca_entry.save()
|
||||||
|
|
||||||
|
if signal is transaction_updated and old_data:
|
||||||
|
old_data = serialize_transaction(old_data, deleted=False)
|
||||||
|
|
||||||
check_for_transaction_rules.defer(
|
check_for_transaction_rules.defer(
|
||||||
instance_id=sender.id,
|
instance_id=sender.id,
|
||||||
user_id=get_current_user().id,
|
user_id=get_current_user().id,
|
||||||
@@ -67,4 +47,5 @@ def transaction_changed_receiver(sender: Transaction, signal, **kwargs):
|
|||||||
if signal is transaction_created
|
if signal is transaction_created
|
||||||
else "transaction_updated"
|
else "transaction_updated"
|
||||||
),
|
),
|
||||||
|
old_data=old_data,
|
||||||
)
|
)
|
||||||
|
|||||||
+648
-322
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,21 @@ urlpatterns = [
|
|||||||
views.transaction_rule_take_ownership,
|
views.transaction_rule_take_ownership,
|
||||||
name="transaction_rule_take_ownership",
|
name="transaction_rule_take_ownership",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/created/",
|
||||||
|
views.dry_run_rule_created,
|
||||||
|
name="transaction_rule_dry_run_created",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/deleted/",
|
||||||
|
views.dry_run_rule_deleted,
|
||||||
|
name="transaction_rule_dry_run_deleted",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"rules/transaction/<int:pk>/dry-run/updated/",
|
||||||
|
views.dry_run_rule_updated,
|
||||||
|
name="transaction_rule_dry_run_updated",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"rules/transaction/<int:pk>/share/",
|
"rules/transaction/<int:pk>/share/",
|
||||||
views.transaction_rule_share,
|
views.transaction_rule_share,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db.models import Sum, Value, DecimalField, Case, When, F
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
|
||||||
|
from apps.transactions.models import (
|
||||||
|
Transaction,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionsGetter:
|
||||||
|
def __init__(self, **filters):
|
||||||
|
self.__queryset = Transaction.objects.filter(**filters)
|
||||||
|
|
||||||
|
def exclude(self, **exclude_filters):
|
||||||
|
self.__queryset = self.__queryset.exclude(**exclude_filters)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sum(self):
|
||||||
|
return self.__queryset.aggregate(
|
||||||
|
total=Coalesce(
|
||||||
|
Sum("amount"), Value(Decimal("0")), output_field=DecimalField()
|
||||||
|
)
|
||||||
|
)["total"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def balance(self):
|
||||||
|
return abs(
|
||||||
|
self.__queryset.aggregate(
|
||||||
|
balance=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||||
|
default=F("amount"),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(Decimal("0")),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
)["balance"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raw_balance(self):
|
||||||
|
return self.__queryset.aggregate(
|
||||||
|
balance=Coalesce(
|
||||||
|
Sum(
|
||||||
|
Case(
|
||||||
|
When(type=Transaction.Type.EXPENSE, then=-F("amount")),
|
||||||
|
default=F("amount"),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
Value(Decimal("0")),
|
||||||
|
output_field=DecimalField(),
|
||||||
|
)
|
||||||
|
)["balance"]
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_transaction(sender: Transaction, deleted: bool):
|
||||||
|
return {
|
||||||
|
"id": sender.id,
|
||||||
|
"account": (sender.account.id, sender.account.name),
|
||||||
|
"account_group": (
|
||||||
|
sender.account.group.id if sender.account.group else None,
|
||||||
|
sender.account.group.name if sender.account.group else None,
|
||||||
|
),
|
||||||
|
"type": str(sender.type),
|
||||||
|
"is_paid": sender.is_paid,
|
||||||
|
"is_asset": sender.account.is_asset,
|
||||||
|
"is_archived": sender.account.is_archived,
|
||||||
|
"category": (
|
||||||
|
sender.category.id if sender.category else None,
|
||||||
|
sender.category.name if sender.category else None,
|
||||||
|
),
|
||||||
|
"date": sender.date.isoformat(),
|
||||||
|
"reference_date": sender.reference_date.isoformat(),
|
||||||
|
"amount": str(sender.amount),
|
||||||
|
"description": sender.description,
|
||||||
|
"notes": sender.notes,
|
||||||
|
"tags": list(sender.tags.values_list("id", "name")),
|
||||||
|
"entities": list(sender.entities.values_list("id", "name")),
|
||||||
|
"deleted": deleted,
|
||||||
|
"internal_note": sender.internal_note,
|
||||||
|
"internal_id": sender.internal_id,
|
||||||
|
"mute": sender.mute,
|
||||||
|
}
|
||||||
+178
-2
@@ -1,5 +1,10 @@
|
|||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -10,6 +15,9 @@ from apps.rules.forms import (
|
|||||||
TransactionRuleForm,
|
TransactionRuleForm,
|
||||||
TransactionRuleActionForm,
|
TransactionRuleActionForm,
|
||||||
UpdateOrCreateTransactionRuleActionForm,
|
UpdateOrCreateTransactionRuleActionForm,
|
||||||
|
DryRunCreatedTransacion,
|
||||||
|
DryRunDeletedTransacion,
|
||||||
|
DryRunUpdatedTransactionForm,
|
||||||
)
|
)
|
||||||
from apps.rules.models import (
|
from apps.rules.models import (
|
||||||
TransactionRule,
|
TransactionRule,
|
||||||
@@ -19,6 +27,11 @@ from apps.rules.models import (
|
|||||||
from apps.common.models import SharedObject
|
from apps.common.models import SharedObject
|
||||||
from apps.common.forms import SharedObjectForm
|
from apps.common.forms import SharedObjectForm
|
||||||
from apps.common.decorators.demo import disabled_on_demo
|
from apps.common.decorators.demo import disabled_on_demo
|
||||||
|
from apps.rules.tasks import check_for_transaction_rules
|
||||||
|
from apps.common.middleware.thread_local import get_current_user
|
||||||
|
from apps.rules.signals import transaction_created, transaction_updated
|
||||||
|
from apps.rules.utils.transactions import serialize_transaction
|
||||||
|
from apps.transactions.models import Transaction
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -36,7 +49,7 @@ def rules_index(request):
|
|||||||
@disabled_on_demo
|
@disabled_on_demo
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def rules_list(request):
|
def rules_list(request):
|
||||||
transaction_rules = TransactionRule.objects.all().order_by("id")
|
transaction_rules = TransactionRule.objects.all().order_by("order", "id")
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"rules/fragments/list.html",
|
"rules/fragments/list.html",
|
||||||
@@ -140,10 +153,20 @@ def transaction_rule_edit(request, transaction_rule_id):
|
|||||||
def transaction_rule_view(request, transaction_rule_id):
|
def transaction_rule_view(request, transaction_rule_id):
|
||||||
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
transaction_rule = get_object_or_404(TransactionRule, id=transaction_rule_id)
|
||||||
|
|
||||||
|
edit_actions = transaction_rule.transaction_actions.all()
|
||||||
|
update_or_create_actions = (
|
||||||
|
transaction_rule.update_or_create_transaction_actions.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
all_actions = sorted(
|
||||||
|
chain(edit_actions, update_or_create_actions),
|
||||||
|
key=lambda a: a.order,
|
||||||
|
)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"rules/fragments/transaction_rule/view.html",
|
"rules/fragments/transaction_rule/view.html",
|
||||||
{"transaction_rule": transaction_rule},
|
{"transaction_rule": transaction_rule, "all_actions": all_actions},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -406,3 +429,156 @@ def update_or_create_transaction_rule_action_delete(request, pk):
|
|||||||
"HX-Trigger": "updated, hide_offcanvas",
|
"HX-Trigger": "updated, hide_offcanvas",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_created(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunCreatedTransacion(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=form.cleaned_data["transaction"].id,
|
||||||
|
signal="transaction_created",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs)
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = DryRunCreatedTransacion()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_deleted(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunDeletedTransacion(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=form.cleaned_data["transaction"].id,
|
||||||
|
signal="transaction_deleted",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs)
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
else:
|
||||||
|
form = DryRunDeletedTransacion()
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/deleted.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@login_required
|
||||||
|
@disabled_on_demo
|
||||||
|
@require_http_methods(["GET", "POST"])
|
||||||
|
def dry_run_rule_updated(request, pk):
|
||||||
|
rule = get_object_or_404(TransactionRule, id=pk)
|
||||||
|
logs = None
|
||||||
|
results = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form = DryRunUpdatedTransactionForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
base_transaction = Transaction.objects.get(
|
||||||
|
id=request.POST.get("transaction")
|
||||||
|
)
|
||||||
|
old_data = deepcopy(base_transaction)
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
for field_name, value in form.cleaned_data.items():
|
||||||
|
if value or isinstance(
|
||||||
|
value, bool
|
||||||
|
): # Only update fields that have been filled in the form
|
||||||
|
if field_name == "tags":
|
||||||
|
base_transaction.tags.set(value)
|
||||||
|
elif field_name == "entities":
|
||||||
|
base_transaction.entities.set(value)
|
||||||
|
else:
|
||||||
|
setattr(base_transaction, field_name, value)
|
||||||
|
|
||||||
|
base_transaction.save()
|
||||||
|
|
||||||
|
logs, results = check_for_transaction_rules(
|
||||||
|
instance_id=base_transaction.id,
|
||||||
|
signal="transaction_updated",
|
||||||
|
dry_run=True,
|
||||||
|
rule_id=rule.id,
|
||||||
|
user_id=get_current_user().id,
|
||||||
|
old_data=old_data,
|
||||||
|
)
|
||||||
|
logs = "\n".join(logs) if logs else ""
|
||||||
|
|
||||||
|
response = render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/created.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|
||||||
|
# This will rollback the transaction
|
||||||
|
raise Exception("ROLLBACK")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
form = DryRunUpdatedTransactionForm(initial={"is_paid": None, "type": None})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"rules/fragments/transaction_rule/dry_run/updated.html",
|
||||||
|
{"form": form, "rule": rule, "logs": logs, "results": results},
|
||||||
|
)
|
||||||
|
|||||||
@@ -60,26 +60,20 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
label=_("Currencies"),
|
label=_("Currencies"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
)
|
)
|
||||||
category = django_filters.ModelMultipleChoiceFilter(
|
category = django_filters.MultipleChoiceFilter(
|
||||||
field_name="category__name",
|
|
||||||
queryset=TransactionCategory.objects.all(),
|
|
||||||
to_field_name="name",
|
|
||||||
label=_("Categories"),
|
label=_("Categories"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
|
method="filter_category",
|
||||||
)
|
)
|
||||||
tags = django_filters.ModelMultipleChoiceFilter(
|
tags = django_filters.MultipleChoiceFilter(
|
||||||
field_name="tags__name",
|
|
||||||
queryset=TransactionTag.objects.all(),
|
|
||||||
to_field_name="name",
|
|
||||||
label=_("Tags"),
|
label=_("Tags"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
|
method="filter_tags",
|
||||||
)
|
)
|
||||||
entities = django_filters.ModelMultipleChoiceFilter(
|
entities = django_filters.MultipleChoiceFilter(
|
||||||
field_name="entities__name",
|
|
||||||
queryset=TransactionEntity.objects.all(),
|
|
||||||
to_field_name="name",
|
|
||||||
label=_("Entities"),
|
label=_("Entities"),
|
||||||
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
widget=TomSelectMultiple(checkboxes=True, remove_button=True),
|
||||||
|
method="filter_entities",
|
||||||
)
|
)
|
||||||
is_paid = django_filters.MultipleChoiceFilter(
|
is_paid = django_filters.MultipleChoiceFilter(
|
||||||
choices=SITUACAO_CHOICES,
|
choices=SITUACAO_CHOICES,
|
||||||
@@ -125,6 +119,7 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
"is_paid",
|
"is_paid",
|
||||||
"category",
|
"category",
|
||||||
"tags",
|
"tags",
|
||||||
|
"entities",
|
||||||
"date_start",
|
"date_start",
|
||||||
"date_end",
|
"date_end",
|
||||||
"reference_date_start",
|
"reference_date_start",
|
||||||
@@ -186,6 +181,93 @@ class TransactionsFilter(django_filters.FilterSet):
|
|||||||
self.form.fields["date_end"].widget = AirDatePickerInput()
|
self.form.fields["date_end"].widget = AirDatePickerInput()
|
||||||
|
|
||||||
self.form.fields["account"].queryset = Account.objects.all()
|
self.form.fields["account"].queryset = Account.objects.all()
|
||||||
self.form.fields["category"].queryset = TransactionCategory.objects.all()
|
category_choices = list(
|
||||||
self.form.fields["tags"].queryset = TransactionTag.objects.all()
|
TransactionCategory.objects.values_list("name", "name").order_by("name")
|
||||||
self.form.fields["entities"].queryset = TransactionEntity.objects.all()
|
)
|
||||||
|
custom_choices = [
|
||||||
|
("any", _("Categorized")),
|
||||||
|
("uncategorized", _("Uncategorized")),
|
||||||
|
]
|
||||||
|
self.form.fields["category"].choices = custom_choices + category_choices
|
||||||
|
tag_choices = list(
|
||||||
|
TransactionTag.objects.values_list("name", "name").order_by("name")
|
||||||
|
)
|
||||||
|
custom_tag_choices = [("any", _("Tagged")), ("untagged", _("Untagged"))]
|
||||||
|
self.form.fields["tags"].choices = custom_tag_choices + tag_choices
|
||||||
|
entity_choices = list(
|
||||||
|
TransactionEntity.objects.values_list("name", "name").order_by("name")
|
||||||
|
)
|
||||||
|
custom_entity_choices = [
|
||||||
|
("any", _("Any entity")),
|
||||||
|
("no_entity", _("No entity")),
|
||||||
|
]
|
||||||
|
self.form.fields["entities"].choices = custom_entity_choices + entity_choices
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_category(queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
value = list(value)
|
||||||
|
|
||||||
|
if "any" in value:
|
||||||
|
return queryset.filter(category__isnull=False)
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
if "uncategorized" in value:
|
||||||
|
q |= Q(category__isnull=True)
|
||||||
|
value.remove("uncategorized")
|
||||||
|
|
||||||
|
if value:
|
||||||
|
q |= Q(category__name__in=value)
|
||||||
|
|
||||||
|
if q.children:
|
||||||
|
return queryset.filter(q)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_tags(queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
value = list(value)
|
||||||
|
|
||||||
|
if "any" in value:
|
||||||
|
return queryset.filter(tags__isnull=False).distinct()
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
if "untagged" in value:
|
||||||
|
q |= Q(tags__isnull=True)
|
||||||
|
value.remove("untagged")
|
||||||
|
|
||||||
|
if value:
|
||||||
|
q |= Q(tags__name__in=value)
|
||||||
|
|
||||||
|
if q.children:
|
||||||
|
return queryset.filter(q).distinct()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_entities(queryset, name, value):
|
||||||
|
if not value:
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
value = list(value)
|
||||||
|
|
||||||
|
if "any" in value:
|
||||||
|
return queryset.filter(entities__isnull=False).distinct()
|
||||||
|
|
||||||
|
q = Q()
|
||||||
|
if "no_entity" in value:
|
||||||
|
q |= Q(entities__isnull=True)
|
||||||
|
value.remove("no_entity")
|
||||||
|
|
||||||
|
if value:
|
||||||
|
q |= Q(entities__name__in=value)
|
||||||
|
|
||||||
|
if q.children:
|
||||||
|
return queryset.filter(q).distinct()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|||||||
+107
-22
@@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
from crispy_bootstrap5.bootstrap5 import Switch, BS5Accordion
|
||||||
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
from crispy_forms.bootstrap import FormActions, AccordionGroup, AppendedText
|
||||||
from crispy_forms.helper import FormHelper
|
from crispy_forms.helper import FormHelper
|
||||||
@@ -239,11 +241,16 @@ class TransactionForm(forms.ModelForm):
|
|||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
is_new = not self.instance.id
|
is_new = not self.instance.id
|
||||||
|
|
||||||
|
if not is_new:
|
||||||
|
old_data = deepcopy(Transaction.objects.get(pk=self.instance.id))
|
||||||
|
else:
|
||||||
|
old_data = None
|
||||||
|
|
||||||
instance = super().save(**kwargs)
|
instance = super().save(**kwargs)
|
||||||
if is_new:
|
if is_new:
|
||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
else:
|
else:
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
@@ -347,11 +354,6 @@ class QuickTransactionForm(forms.ModelForm):
|
|||||||
Column("entities", css_class="form-group col-md-6 mb-0"),
|
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||||
css_class="form-row",
|
css_class="form-row",
|
||||||
),
|
),
|
||||||
Row(
|
|
||||||
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
|
||||||
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
|
||||||
css_class="form-row",
|
|
||||||
),
|
|
||||||
"description",
|
"description",
|
||||||
Field("amount", inputmode="decimal"),
|
Field("amount", inputmode="decimal"),
|
||||||
Row(
|
Row(
|
||||||
@@ -387,35 +389,115 @@ class QuickTransactionForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BulkEditTransactionForm(TransactionForm):
|
class BulkEditTransactionForm(forms.Form):
|
||||||
is_paid = forms.NullBooleanField(required=False)
|
type = forms.ChoiceField(
|
||||||
|
choices=(Transaction.Type.choices),
|
||||||
|
required=False,
|
||||||
|
label=_("Type"),
|
||||||
|
)
|
||||||
|
is_paid = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_("Paid"),
|
||||||
|
)
|
||||||
|
account = DynamicModelChoiceField(
|
||||||
|
model=Account,
|
||||||
|
required=False,
|
||||||
|
label=_("Account"),
|
||||||
|
queryset=Account.objects.filter(is_archived=False),
|
||||||
|
widget=TomSelect(clear_button=False, group_by="group"),
|
||||||
|
)
|
||||||
|
date = forms.DateField(
|
||||||
|
label=_("Date"),
|
||||||
|
required=False,
|
||||||
|
widget=AirDatePickerInput(clear_button=False),
|
||||||
|
)
|
||||||
|
reference_date = forms.DateField(
|
||||||
|
widget=AirMonthYearPickerInput(),
|
||||||
|
label=_("Reference Date"),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
amount = forms.DecimalField(
|
||||||
|
max_digits=42,
|
||||||
|
decimal_places=30,
|
||||||
|
required=False,
|
||||||
|
label=_("Amount"),
|
||||||
|
widget=ArbitraryDecimalDisplayNumberInput(),
|
||||||
|
)
|
||||||
|
description = forms.CharField(
|
||||||
|
max_length=500, required=False, label=_("Description")
|
||||||
|
)
|
||||||
|
notes = forms.CharField(
|
||||||
|
required=False,
|
||||||
|
widget=forms.Textarea(attrs={"rows": 3}),
|
||||||
|
label=_("Notes"),
|
||||||
|
)
|
||||||
|
category = DynamicModelChoiceField(
|
||||||
|
create_field="name",
|
||||||
|
model=TransactionCategory,
|
||||||
|
required=False,
|
||||||
|
label=_("Category"),
|
||||||
|
queryset=TransactionCategory.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
tags = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionTag,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Tags"),
|
||||||
|
queryset=TransactionTag.objects.filter(active=True),
|
||||||
|
)
|
||||||
|
entities = DynamicModelMultipleChoiceField(
|
||||||
|
model=TransactionEntity,
|
||||||
|
to_field_name="name",
|
||||||
|
create_field="name",
|
||||||
|
required=False,
|
||||||
|
label=_("Entities"),
|
||||||
|
queryset=TransactionEntity.objects.all(),
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
# Make all fields optional
|
|
||||||
for field_name, field in self.fields.items():
|
|
||||||
field.required = False
|
|
||||||
|
|
||||||
del self.helper.layout[-1] # Remove button
|
self.fields["account"].queryset = Account.objects.filter(
|
||||||
del self.helper.layout[0:2] # Remove type, is_paid field
|
is_archived=False,
|
||||||
|
)
|
||||||
|
|
||||||
self.helper.layout.insert(
|
self.fields["category"].queryset = TransactionCategory.objects.filter(
|
||||||
0,
|
active=True
|
||||||
|
)
|
||||||
|
self.fields["tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||||
|
self.fields["entities"].queryset = TransactionEntity.objects.all()
|
||||||
|
|
||||||
|
self.helper = FormHelper()
|
||||||
|
self.helper.form_tag = False
|
||||||
|
self.helper.form_method = "post"
|
||||||
|
self.helper.layout = Layout(
|
||||||
Field(
|
Field(
|
||||||
"type",
|
"type",
|
||||||
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
template="transactions/widgets/unselectable_income_expense_toggle_buttons.html",
|
||||||
),
|
),
|
||||||
)
|
|
||||||
|
|
||||||
self.helper.layout.insert(
|
|
||||||
1,
|
|
||||||
Field(
|
Field(
|
||||||
"is_paid",
|
"is_paid",
|
||||||
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
template="transactions/widgets/unselectable_paid_toggle_button.html",
|
||||||
),
|
),
|
||||||
)
|
Row(
|
||||||
|
Column("account", css_class="form-group col-md-6 mb-0"),
|
||||||
self.helper.layout.append(
|
Column("entities", css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
Column(Field("date"), css_class="form-group col-md-6 mb-0"),
|
||||||
|
Column(Field("reference_date"), css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
"description",
|
||||||
|
Field("amount", inputmode="decimal"),
|
||||||
|
Row(
|
||||||
|
Column("category", css_class="form-group col-md-6 mb-0"),
|
||||||
|
Column("tags", css_class="form-group col-md-6 mb-0"),
|
||||||
|
css_class="form-row",
|
||||||
|
),
|
||||||
|
"notes",
|
||||||
FormActions(
|
FormActions(
|
||||||
NoClassSubmit(
|
NoClassSubmit(
|
||||||
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
"submit", _("Update"), css_class="btn btn-outline-primary w-100"
|
||||||
@@ -423,6 +505,9 @@ class BulkEditTransactionForm(TransactionForm):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.fields["amount"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||||
|
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||||
|
|
||||||
|
|
||||||
class TransferForm(forms.Form):
|
class TransferForm(forms.Form):
|
||||||
from_account = forms.ModelChoiceField(
|
from_account = forms.ModelChoiceField(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -33,13 +34,13 @@ transaction_deleted = Signal()
|
|||||||
|
|
||||||
class SoftDeleteQuerySet(models.QuerySet):
|
class SoftDeleteQuerySet(models.QuerySet):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _emit_signals(instances, created=False):
|
def _emit_signals(instances, created=False, old_data=None):
|
||||||
"""Helper to emit signals for multiple instances"""
|
"""Helper to emit signals for multiple instances"""
|
||||||
for instance in instances:
|
for i, instance in enumerate(instances):
|
||||||
if created:
|
if created:
|
||||||
transaction_created.send(sender=instance)
|
transaction_created.send(sender=instance)
|
||||||
else:
|
else:
|
||||||
transaction_updated.send(sender=instance)
|
transaction_updated.send(sender=instance, old_data=old_data[i])
|
||||||
|
|
||||||
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
def bulk_create(self, objs, emit_signal=True, **kwargs):
|
||||||
instances = super().bulk_create(objs, **kwargs)
|
instances = super().bulk_create(objs, **kwargs)
|
||||||
@@ -50,22 +51,25 @@ class SoftDeleteQuerySet(models.QuerySet):
|
|||||||
return instances
|
return instances
|
||||||
|
|
||||||
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
def bulk_update(self, objs, fields, emit_signal=True, **kwargs):
|
||||||
|
old_data = deepcopy(objs)
|
||||||
result = super().bulk_update(objs, fields, **kwargs)
|
result = super().bulk_update(objs, fields, **kwargs)
|
||||||
|
|
||||||
if emit_signal:
|
if emit_signal:
|
||||||
self._emit_signals(objs, created=False)
|
self._emit_signals(objs, created=False, old_data=old_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def update(self, emit_signal=True, **kwargs):
|
def update(self, emit_signal=True, **kwargs):
|
||||||
# Get instances before update
|
# Get instances before update
|
||||||
instances = list(self)
|
instances = list(self)
|
||||||
|
old_data = deepcopy(instances)
|
||||||
|
|
||||||
result = super().update(**kwargs)
|
result = super().update(**kwargs)
|
||||||
|
|
||||||
if emit_signal:
|
if emit_signal:
|
||||||
# Refresh instances to get new values
|
# Refresh instances to get new values
|
||||||
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
refreshed = self.model.objects.filter(pk__in=[obj.pk for obj in instances])
|
||||||
self._emit_signals(refreshed, created=False)
|
self._emit_signals(refreshed, created=False, old_data=old_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -376,7 +380,7 @@ class Transaction(OwnedObject):
|
|||||||
db_table = "transactions"
|
db_table = "transactions"
|
||||||
default_manager_name = "objects"
|
default_manager_name = "objects"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def clean_fields(self, *args, **kwargs):
|
||||||
self.amount = truncate_decimal(
|
self.amount = truncate_decimal(
|
||||||
value=self.amount, decimal_places=self.account.currency.decimal_places
|
value=self.amount, decimal_places=self.account.currency.decimal_places
|
||||||
)
|
)
|
||||||
@@ -386,6 +390,11 @@ class Transaction(OwnedObject):
|
|||||||
elif not self.reference_date and self.date:
|
elif not self.reference_date and self.date:
|
||||||
self.reference_date = self.date.replace(day=1)
|
self.reference_date = self.date.replace(day=1)
|
||||||
|
|
||||||
|
super().clean_fields(*args, **kwargs)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# This is not recommended as it will run twice on some cases like form and API saves.
|
||||||
|
# We only do this here because we forgot to independently call it on multiple places.
|
||||||
self.full_clean()
|
self.full_clean()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@@ -443,12 +452,58 @@ class Transaction(OwnedObject):
|
|||||||
type_display = self.get_type_display()
|
type_display = self.get_type_display()
|
||||||
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
frmt_date = date(self.date, "SHORT_DATE_FORMAT")
|
||||||
account = self.account
|
account = self.account
|
||||||
tags = ", ".join([x.name for x in self.tags.all()]) or _("No tags")
|
tags = (
|
||||||
|
", ".join([x.name for x in self.tags.all()])
|
||||||
|
if self.id
|
||||||
|
else None or _("No tags")
|
||||||
|
)
|
||||||
category = self.category or _("No category")
|
category = self.category or _("No category")
|
||||||
amount = localize_number(drop_trailing_zeros(self.amount))
|
amount = localize_number(drop_trailing_zeros(self.amount))
|
||||||
description = self.description or _("No description")
|
description = self.description or _("No description")
|
||||||
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
return f"[{frmt_date}][{type_display}][{account}] {description} • {category} • {tags} • {amount}"
|
||||||
|
|
||||||
|
def deepcopy(self, memo=None):
|
||||||
|
"""
|
||||||
|
Creates a deep copy of the transaction instance.
|
||||||
|
|
||||||
|
This method returns a new, unsaved Transaction instance with the same
|
||||||
|
values as the original, including its many-to-many relationships.
|
||||||
|
The primary key and any other unique fields are reset to avoid
|
||||||
|
database integrity errors upon saving.
|
||||||
|
"""
|
||||||
|
if memo is None:
|
||||||
|
memo = {}
|
||||||
|
|
||||||
|
# Create a new instance of the class
|
||||||
|
new_obj = self.__class__()
|
||||||
|
memo[id(self)] = new_obj
|
||||||
|
|
||||||
|
# Copy all concrete fields from the original to the new object
|
||||||
|
for field in self._meta.concrete_fields:
|
||||||
|
# Skip the primary key to allow the database to generate a new one
|
||||||
|
if field.primary_key:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reset any unique fields to None to avoid constraint violations
|
||||||
|
if field.unique and field.name == "internal_id":
|
||||||
|
setattr(new_obj, field.name, None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Copy the value of the field
|
||||||
|
setattr(new_obj, field.name, getattr(self, field.name))
|
||||||
|
|
||||||
|
# Save the new object to the database to get a primary key
|
||||||
|
new_obj.save()
|
||||||
|
|
||||||
|
# Copy the many-to-many relationships
|
||||||
|
for field in self._meta.many_to_many:
|
||||||
|
source_manager = getattr(self, field.name)
|
||||||
|
destination_manager = getattr(new_obj, field.name)
|
||||||
|
# Set the M2M relationships for the new object
|
||||||
|
destination_manager.set(source_manager.all())
|
||||||
|
|
||||||
|
return new_obj
|
||||||
|
|
||||||
|
|
||||||
class InstallmentPlan(models.Model):
|
class InstallmentPlan(models.Model):
|
||||||
class Recurrence(models.TextChoices):
|
class Recurrence(models.TextChoices):
|
||||||
|
|||||||
@@ -175,6 +175,6 @@ class RecurringTransactionTests(TestCase):
|
|||||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||||
recurrence_interval=1,
|
recurrence_interval=1,
|
||||||
)
|
)
|
||||||
self.assertFalse(recurring.paused)
|
self.assertFalse(recurring.is_paused)
|
||||||
self.assertEqual(recurring.recurrence_interval, 1)
|
self.assertEqual(recurring.recurrence_interval, 1)
|
||||||
self.assertEqual(recurring.account.currency.code, "USD")
|
self.assertEqual(recurring.account.currency.code, "USD")
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ def transactions_bulk_edit(request):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
# Apply changes from the form to all selected transactions
|
# Apply changes from the form to all selected transactions
|
||||||
for transaction in transactions:
|
for transaction in transactions:
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
for field_name, value in form.cleaned_data.items():
|
for field_name, value in form.cleaned_data.items():
|
||||||
if value or isinstance(
|
if value or isinstance(
|
||||||
value, bool
|
value, bool
|
||||||
@@ -225,7 +226,7 @@ def transactions_bulk_edit(request):
|
|||||||
setattr(transaction, field_name, value)
|
setattr(transaction, field_name, value)
|
||||||
|
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
@@ -373,10 +374,13 @@ def transactions_transfer(request):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_pay(request, transaction_id):
|
def transaction_pay(request, transaction_id):
|
||||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
new_is_paid = False if transaction.is_paid else True
|
new_is_paid = False if transaction.is_paid else True
|
||||||
transaction.is_paid = new_is_paid
|
transaction.is_paid = new_is_paid
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
|
||||||
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
@@ -394,11 +398,12 @@ def transaction_pay(request, transaction_id):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_mute(request, transaction_id):
|
def transaction_mute(request, transaction_id):
|
||||||
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
new_mute = False if transaction.mute else True
|
new_mute = False if transaction.mute else True
|
||||||
transaction.mute = new_mute
|
transaction.mute = new_mute
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
response = render(
|
response = render(
|
||||||
request,
|
request,
|
||||||
@@ -414,19 +419,20 @@ def transaction_mute(request, transaction_id):
|
|||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def transaction_change_month(request, transaction_id, change_type):
|
def transaction_change_month(request, transaction_id, change_type):
|
||||||
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
if change_type == "next":
|
if change_type == "next":
|
||||||
transaction.reference_date = transaction.reference_date + relativedelta(
|
transaction.reference_date = transaction.reference_date + relativedelta(
|
||||||
months=1
|
months=1
|
||||||
)
|
)
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
elif change_type == "previous":
|
elif change_type == "previous":
|
||||||
transaction.reference_date = transaction.reference_date - relativedelta(
|
transaction.reference_date = transaction.reference_date - relativedelta(
|
||||||
months=1
|
months=1
|
||||||
)
|
)
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
@@ -440,9 +446,11 @@ def transaction_change_month(request, transaction_id, change_type):
|
|||||||
def transaction_move_to_today(request, transaction_id):
|
def transaction_move_to_today(request, transaction_id):
|
||||||
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
transaction: Transaction = get_object_or_404(Transaction, pk=transaction_id)
|
||||||
|
|
||||||
|
old_data = deepcopy(transaction)
|
||||||
|
|
||||||
transaction.date = timezone.localdate(timezone.now())
|
transaction.date = timezone.localdate(timezone.now())
|
||||||
transaction.save()
|
transaction.save()
|
||||||
transaction_updated.send(sender=transaction)
|
transaction_updated.send(sender=transaction, old_data=old_data)
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
status=204,
|
status=204,
|
||||||
@@ -589,7 +597,10 @@ def transaction_all_currency_summary(request):
|
|||||||
|
|
||||||
f = TransactionsFilter(request.GET, queryset=transactions)
|
f = TransactionsFilter(request.GET, queryset=transactions)
|
||||||
|
|
||||||
currency_data = calculate_currency_totals(f.qs.all(), ignore_empty=True)
|
currency_data = calculate_currency_totals(
|
||||||
|
f.qs.exclude(account__in=request.user.untracked_accounts.all()),
|
||||||
|
ignore_empty=True,
|
||||||
|
)
|
||||||
currency_percentages = calculate_percentage_distribution(currency_data)
|
currency_percentages = calculate_percentage_distribution(currency_data)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
("AA", _("Default")),
|
("AA", _("Default")),
|
||||||
("DC", "1.234,50"),
|
("DC", "1.234,50"),
|
||||||
("CD", "1,234.50"),
|
("CD", "1,234.50"),
|
||||||
|
("SD", "1 234.50"),
|
||||||
|
("SC", "1 234,50"),
|
||||||
]
|
]
|
||||||
|
|
||||||
date_format = forms.ChoiceField(
|
date_format = forms.ChoiceField(
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ urlpatterns = [
|
|||||||
views.toggle_sound_playing,
|
views.toggle_sound_playing,
|
||||||
name="toggle_sound_playing",
|
name="toggle_sound_playing",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"user/toggle-sidebar/",
|
||||||
|
views.toggle_sidebar_status,
|
||||||
|
name="toggle_sidebar_status",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"user/settings/",
|
"user/settings/",
|
||||||
views.update_settings,
|
views.update_settings,
|
||||||
|
|||||||
@@ -116,6 +116,24 @@ def update_settings(request):
|
|||||||
return render(request, "users/fragments/user_settings.html", {"form": form})
|
return render(request, "users/fragments/user_settings.html", {"form": form})
|
||||||
|
|
||||||
|
|
||||||
|
@only_htmx
|
||||||
|
@htmx_login_required
|
||||||
|
def toggle_sidebar_status(request):
|
||||||
|
if not request.session.get("sidebar_status"):
|
||||||
|
request.session["sidebar_status"] = "floating"
|
||||||
|
|
||||||
|
if request.session["sidebar_status"] == "floating":
|
||||||
|
request.session["sidebar_status"] = "fixed"
|
||||||
|
elif request.session["sidebar_status"] == "fixed":
|
||||||
|
request.session["sidebar_status"] = "floating"
|
||||||
|
else:
|
||||||
|
request.session["sidebar_status"] = "fixed"
|
||||||
|
|
||||||
|
return HttpResponse(
|
||||||
|
status=204,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@htmx_login_required
|
@htmx_login_required
|
||||||
@is_superuser
|
@is_superuser
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def yearly_overview_by_currency(request, year: int):
|
|||||||
currency = request.GET.get("currency")
|
currency = request.GET.get("currency")
|
||||||
|
|
||||||
# Base query filter
|
# Base query filter
|
||||||
filter_params = {"reference_date__year": year, "account__is_archived": False}
|
filter_params = {"reference_date__year": year}
|
||||||
|
|
||||||
# Add month filter if provided
|
# Add month filter if provided
|
||||||
if month:
|
if month:
|
||||||
@@ -95,6 +95,7 @@ def yearly_overview_by_currency(request, year: int):
|
|||||||
transactions = (
|
transactions = (
|
||||||
Transaction.objects.filter(**filter_params)
|
Transaction.objects.filter(**filter_params)
|
||||||
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
.exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True))
|
||||||
|
.exclude(account__in=request.user.untracked_accounts.all())
|
||||||
.order_by("account__currency__name")
|
.order_by("account__currency__name")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+291
-217
File diff suppressed because it is too large
Load Diff
+275
-217
File diff suppressed because it is too large
Load Diff
+279
-217
File diff suppressed because it is too large
Load Diff
+644
-869
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+281
-221
File diff suppressed because it is too large
Load Diff
+283
-217
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+275
-217
File diff suppressed because it is too large
Load Diff
+277
-217
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,17 @@
|
|||||||
hx-get="{% url 'account_share_settings' pk=account.id %}">
|
hx-get="{% url 'account_share_settings' pk=account.id %}">
|
||||||
<i class="fa-solid fa-share fa-fw"></i></a>
|
<i class="fa-solid fa-share fa-fw"></i></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<a class="btn btn-secondary btn-sm"
|
||||||
|
role="button"
|
||||||
|
hx-get="{% url 'account_toggle_untracked' pk=account.id %}"
|
||||||
|
data-bs-toggle="tooltip"
|
||||||
|
data-bs-title="{% if account.is_untracked_by %}{% translate "Track" %}{% else %}{% translate "Untrack" %}{% endif %}">
|
||||||
|
{% if account.is_untracked_by %}
|
||||||
|
<i class="fa-solid fa-eye fa-fw"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="fa-solid fa-eye-slash fa-fw"></i>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="col">{{ account.name }}</td>
|
<td class="col">{{ account.name }}</td>
|
||||||
|
|||||||
@@ -48,4 +48,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div class="w-full text-end">
|
||||||
|
<a class="btn btn-outline-primary btn-sm" href="{{ today_url }}" role="button" hx-boost="true">{% trans 'Today' %}</a>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{#This is here so we can add dynamic Tailwind classes that will be required via JS/hyperscript but Tailwind has no knowledge of#}
|
||||||
|
<div class="tw:lg:w-[15vw]"></div>
|
||||||
|
<div class="tw:lg:ml-[16vw]"></div>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<li>
|
<li>
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<span class="text-muted small fw-bold text-uppercase tw:lg:hidden tw:lg:group-hover:inline me-2">{{ title }}</span>
|
<span class="sidebar-menu-header text-muted small fw-bold text-uppercase me-2">{{ title }}</span>
|
||||||
<hr class="flex-grow-1"/>
|
<hr class="flex-grow-1"/>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% load active_link %}
|
{% load active_link %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{% url url %}"
|
<a href="{% url url %}"
|
||||||
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
class="tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
||||||
{% if tooltip %}
|
{% if tooltip %}
|
||||||
data-bs-placement="right"
|
data-bs-placement="right"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
@@ -9,6 +9,6 @@
|
|||||||
{% endif %}>
|
{% endif %}>
|
||||||
<i class="{{ icon }} fa-fw"></i>
|
<i class="{{ icon }} fa-fw"></i>
|
||||||
<span
|
<span
|
||||||
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<a href="{{ url }}"
|
<a href="{{ url }}"
|
||||||
hx-boost="false"
|
hx-boost="false"
|
||||||
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
class="tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
|
||||||
{% if tooltip %}
|
{% if tooltip %}
|
||||||
data-bs-placement="right"
|
data-bs-placement="right"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
@@ -11,6 +11,6 @@
|
|||||||
|
|
||||||
<i class="{{ icon }} fa-fw"></i>
|
<i class="{{ icon }} fa-fw"></i>
|
||||||
<span
|
<span
|
||||||
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
{% load markdown %}
|
{% load markdown %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
|
<div
|
||||||
|
class="transaction {% if transaction.type == "EX" %}expense{% else %}income{% endif %} tw:group/transaction tw:relative tw:hover:z-10">
|
||||||
<div class="d-flex my-1">
|
<div class="d-flex my-1">
|
||||||
{% if not disable_selection %}
|
{% if not disable_selection or not dummy %}
|
||||||
<label class="px-3 d-flex align-items-center justify-content-center">
|
<label class="px-3 d-flex align-items-center justify-content-center">
|
||||||
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
<input class="form-check-input" type="checkbox" name="transactions" value="{{ transaction.id }}"
|
||||||
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
id="check-{{ transaction.id }}" aria-label="{% translate 'Select' %}" hx-preserve>
|
||||||
@@ -19,9 +20,10 @@
|
|||||||
<a class="text-decoration-none p-3 tw:text-gray-500!"
|
<a class="text-decoration-none p-3 tw:text-gray-500!"
|
||||||
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
title="{% if transaction.is_paid %}{% trans 'Paid' %}{% else %}{% trans 'Projected' %}{% endif %}"
|
||||||
role="button"
|
role="button"
|
||||||
|
{% if not dummy %}
|
||||||
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
hx-get="{% url 'transaction_pay' transaction_id=transaction.id %}"
|
||||||
hx-target="closest .transaction"
|
hx-target="closest .transaction"
|
||||||
hx-swap="outerHTML">
|
hx-swap="outerHTML"{% endif %}>
|
||||||
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
{% if transaction.is_paid %}<i class="fa-regular fa-circle-check"></i>{% else %}<i
|
||||||
class="fa-regular fa-circle"></i>{% endif %}
|
class="fa-regular fa-circle"></i>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
@@ -33,7 +35,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg col-12 {% if transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
<div
|
||||||
|
class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||||
{# Date#}
|
{# Date#}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
|
||||||
@@ -58,14 +61,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tw:text-gray-400 tw:text-sm">
|
<div class="tw:text-gray-400 tw:text-sm">
|
||||||
{# Entities #}
|
{# Entities #}
|
||||||
{% with transaction.entities.all as entities %}
|
{% comment %} First, check for the highest priority: a valid 'overriden_entities' list. {% endcomment %}
|
||||||
{% if entities %}
|
{% if overriden_entities %}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
|
||||||
<div class="col ps-0">{{ entities|join:", " }}</div>
|
<div class="col ps-0">{{ overriden_entities|join:", " }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
|
||||||
|
{% elif transaction.id and transaction.entities.all %}
|
||||||
|
<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">{{ transaction.entities.all|join:", " }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
|
||||||
{# Notes#}
|
{# Notes#}
|
||||||
{% if transaction.notes %}
|
{% if transaction.notes %}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
@@ -81,17 +90,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Tags#}
|
{# Tags#}
|
||||||
{% with transaction.tags.all as tags %}
|
{% comment %} First, check for the highest priority: a valid 'overriden_tags' list. {% endcomment %}
|
||||||
{% if tags %}
|
{% if overriden_tags %}
|
||||||
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
|
||||||
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
|
||||||
<div class="col ps-0">{{ tags|join:", " }}</div>
|
<div class="col ps-0">{{ overriden_tags|join:", " }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
|
||||||
|
{% elif transaction.id and transaction.tags.all %}
|
||||||
|
<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">{{ transaction.tags.all|join:", " }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
<div
|
||||||
|
class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
|
||||||
<div class="main-amount mb-2 mb-lg-0">
|
<div class="main-amount mb-2 mb-lg-0">
|
||||||
<c-amount.display
|
<c-amount.display
|
||||||
:amount="transaction.amount"
|
:amount="transaction.amount"
|
||||||
@@ -101,6 +117,7 @@
|
|||||||
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
||||||
</div>
|
</div>
|
||||||
{# Exchange Rate#}
|
{# Exchange Rate#}
|
||||||
|
{% if not dummy %}
|
||||||
{% with exchanged=transaction.exchanged_amount %}
|
{% with exchanged=transaction.exchanged_amount %}
|
||||||
{% if exchanged %}
|
{% if exchanged %}
|
||||||
<div class="exchanged-amount mb-2 mb-lg-0">
|
<div class="exchanged-amount mb-2 mb-lg-0">
|
||||||
@@ -113,15 +130,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if not dummy %}
|
||||||
<div>
|
<div>
|
||||||
{# Item actions#}
|
{# Item actions#}
|
||||||
<div
|
<div
|
||||||
class="transaction-actions tw:absolute! tw:right-[15px] tw:top-[50%] tw:md:right-auto tw:md:left-1/2 tw:md:top-0 tw:md:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
|
class="transaction-actions tw:absolute! tw:left-1/2 tw:top-0 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:invisible tw:group-hover/transaction:visible d-flex flex-row card">
|
||||||
<div class="card-body p-1 shadow-lg d-flex flex-column flex-md-row gap-1">
|
<div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
|
||||||
{% if not transaction.deleted %}
|
{% if not transaction.deleted %}
|
||||||
<a class="btn btn-secondary btn-sm transaction-action"
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -142,32 +161,67 @@
|
|||||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
data-confirm-text="{% translate "Yes, delete it!" %}"
|
||||||
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
|
||||||
</a>
|
</a>
|
||||||
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
<i class="fa-solid fa-ellipsis fa-fw"></i>
|
<i class="fa-solid fa-ellipsis fa-fw"></i>
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
|
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
|
||||||
{% if transaction.category.mute %}
|
{% if transaction.account.is_untracked_by %}
|
||||||
<li>
|
<li>
|
||||||
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||||
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||||
<div>
|
<div>
|
||||||
{% translate 'Show on summaries' %}
|
{% translate 'Show on summaries' %}
|
||||||
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
|
<div
|
||||||
|
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% elif transaction.category.mute %}
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
|
||||||
|
<i class="fa-solid fa-eye fa-fw me-2"></i>
|
||||||
|
<div>
|
||||||
|
{% translate 'Show on summaries' %}
|
||||||
|
<div
|
||||||
|
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% elif transaction.mute %}
|
{% elif transaction.mute %}
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
|
||||||
|
hx-target="closest .transaction" hx-swap="outerHTML"><i
|
||||||
|
class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
|
||||||
|
hx-target="closest .transaction" hx-swap="outerHTML"><i
|
||||||
|
class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a></li>
|
<li><a class="dropdown-item" href="#"
|
||||||
<li><hr class="dropdown-divider"></li>
|
hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a></li>
|
class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a>
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
|
</li>
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
|
<li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<hr class="dropdown-divider">
|
||||||
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i
|
||||||
|
class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a>
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i
|
||||||
|
class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i
|
||||||
|
class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
|
||||||
|
<li>
|
||||||
|
<hr class="dropdown-divider">
|
||||||
|
</li>
|
||||||
|
<li><a class="dropdown-item" href="#"
|
||||||
|
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i
|
||||||
|
class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="btn btn-secondary btn-sm transaction-action"
|
<a class="btn btn-secondary btn-sm transaction-action"
|
||||||
@@ -192,6 +246,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<th scope="col" class="col-auto"></th>
|
<th scope="col" class="col-auto"></th>
|
||||||
<th scope="col" class="col-auto">{% translate 'Code' %}</th>
|
<th scope="col" class="col-auto">{% translate 'Code' %}</th>
|
||||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||||
|
<th scope="col" class="col">{% translate 'Archived' %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="col-auto">{{ currency.code }}</td>
|
<td class="col-auto">{{ currency.code }}</td>
|
||||||
<td class="col">{{ currency.name }}</td>
|
<td class="col">{{ currency.name }}</td>
|
||||||
|
<td class="col">{% if currency.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<div class="container p-3">
|
<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">
|
<form hx-post="{% url 'export_form' %}" hx-ext="htmx-download" hx-swap="none" id="export-form" class="show-loading px-1" target="_blank">
|
||||||
{% crispy form %}
|
{% crispy form %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ init
|
|||||||
end
|
end
|
||||||
|
|
||||||
on htmx:afterSettle
|
on htmx:afterSettle
|
||||||
call initTooltips(event.detail.target)
|
call initTooltips(body)
|
||||||
end
|
end
|
||||||
|
|
||||||
on tooltips
|
on tooltips
|
||||||
|
|||||||
@@ -5,31 +5,56 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="tw:group tw:lg:w-16 tw:lg:hover:w-112 tw:transition-all tw:duration-100 tw:fixed tw:top-0 tw:start-0 tw:h-full tw:z-1020">
|
class="sidebar {% if request.session.sidebar_status == 'floating' %}tw:group sidebar-floating{% elif request.session.sidebar_status == 'fixed' %}sidebar-fixed{% else %}tw:group sidebar-floating{% endif %}"
|
||||||
|
id="sidebar-container">
|
||||||
<nav
|
<nav
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
hx-boost="true"
|
hx-boost="true"
|
||||||
data-bs-scroll="true"
|
data-bs-scroll="true"
|
||||||
class="offcanvas-lg offcanvas-start d-lg-flex flex-column position-fixed top-0 start-0 h-100 bg-body-tertiary shadow-sm tw:z-1020 tw:lg:w-16 tw:lg:group-hover:w-104 tw:transition-all tw:duration-100 tw:overflow-hidden">
|
class="offcanvas-lg offcanvas-start d-lg-flex flex-column position-fixed top-0 start-0 h-100 bg-body-tertiary shadow-sm tw:z-1020">
|
||||||
<div
|
|
||||||
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-8 tw:h-full tw:bg-transparent tw:pointer-events-auto tw:z-10"></div>
|
|
||||||
|
|
||||||
<a href="{% url 'index' %}" class="d-none d-lg-flex tw:justify-start p-3 text-decoration-none">
|
{# <div>#}
|
||||||
|
{# <a href="{% url 'index' %}" class="d-none d-lg-flex tw:justify-start p-3 text-decoration-none sidebar-title">#}
|
||||||
|
{# <img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>#}
|
||||||
|
{# <span class="fs-4 fw-bold ms-3">WYGIWYH</span>#}
|
||||||
|
{# </a>#}
|
||||||
|
{##}
|
||||||
|
{##}
|
||||||
|
{# </div>#}
|
||||||
|
|
||||||
|
<div class="d-none d-lg-flex tw:justify-between tw:items-center tw:border-b tw:border-gray-600 tw:lg:flex">
|
||||||
|
<a href="{% url 'index' %}" class="m-0 d-none d-lg-flex tw:justify-start p-3 text-decoration-none sidebar-title">
|
||||||
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
||||||
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span>
|
<span class="fs-4 fw-bold ms-3">WYGIWYH</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<button
|
||||||
|
id="sidebar-toggle-btn"
|
||||||
|
class="text-secondary-emphasis tw:rounded-full tw:w-12 tw:h-12 tw:flex tw:items-center tw:justify-center tw:transition-all tw:duration-300"
|
||||||
|
hx-get="{% url 'toggle_sidebar_status' %}"
|
||||||
|
_="on click
|
||||||
|
toggle .sidebar-floating on #sidebar-container
|
||||||
|
toggle .tw\:group on #sidebar-container
|
||||||
|
toggle .sidebar-fixed on #sidebar-container
|
||||||
|
end
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-thumbtack fa-sm"></i>
|
||||||
|
<i class="fa-solid fa-thumbtack-slash fa-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="offcanvas-header">
|
<div class="offcanvas-header">
|
||||||
<a href="{% url 'index' %}" class="offcanvas-title d-flex tw:justify-start text-decoration-none">
|
<a href="{% url 'index' %}" class="offcanvas-title d-flex tw:justify-start text-decoration-none">
|
||||||
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
|
||||||
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span>
|
<span class="fs-4 fw-bold ms-3">WYGIWYH</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="btn-close" data-bs-target="#sidebar" data-bs-dismiss="offcanvas"
|
<button type="button" class="btn-close" data-bs-target="#sidebar" data-bs-dismiss="offcanvas"
|
||||||
aria-label={% translate 'Close' %}></button>
|
aria-label={% translate 'Close' %}></button>
|
||||||
</div>
|
</div>
|
||||||
<hr class="m-0">
|
<hr class="m-0">
|
||||||
|
|
||||||
<ul class="list-unstyled p-3 d-flex flex-column gap-0 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
|
<ul class="list-unstyled p-3 d-flex flex-column gap-0 tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
|
||||||
style="animation-duration: 100ms">
|
style="animation-duration: 100ms">
|
||||||
|
|
||||||
<c-components.sidebar-menu-item
|
<c-components.sidebar-menu-item
|
||||||
@@ -127,10 +152,10 @@
|
|||||||
data-bs-target="#collapsible-panel"
|
data-bs-target="#collapsible-panel"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
aria-controls="collapsible-panel"
|
aria-controls="collapsible-panel"
|
||||||
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% 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||users_index' css_class="sidebar-active" %}">
|
class="sidebar-menu-item tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% 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||users_index' css_class="sidebar-active" %}">
|
||||||
<i class="fa-solid fa-toolbox fa-fw"></i>
|
<i class="fa-solid fa-toolbox fa-fw"></i>
|
||||||
<span
|
<span
|
||||||
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">
|
class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">
|
||||||
{% translate 'Management' %}
|
{% translate 'Management' %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,16 +163,15 @@
|
|||||||
|
|
||||||
<div class="mt-auto p-2 w-100">
|
<div class="mt-auto p-2 w-100">
|
||||||
<div id="collapsible-panel"
|
<div id="collapsible-panel"
|
||||||
class="p-0 collapse tw:absolute tw:bottom-0 tw:left-0 tw:w-full tw:z-30 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:max-h-dvh">
|
class="p-0 collapse tw:absolute tw:bottom-0 tw:left-0 tw:w-full tw:z-30 tw:max-h-dvh">
|
||||||
<div class="tw:h-dvh tw:backdrop-blur-3xl">
|
<div class="tw:h-dvh tw:backdrop-blur-3xl tw:flex tw:flex-col">
|
||||||
<!-- Header -->
|
|
||||||
<div
|
<div
|
||||||
class="tw:flex tw:justify-between tw:items-center tw:p-4 tw:border-b tw:border-gray-600 tw:lg:hidden tw:lg:group-hover:flex">
|
class="tw:justify-between tw:items-center tw:p-4 tw:border-b tw:border-gray-600 sidebar-submenu-header">
|
||||||
<h5 class="tw:text-lg tw:font-semibold tw:text-gray-800 tw:lg:invisible tw:lg:group-hover:visible">
|
<h5 class="tw:text-lg tw:font-semibold tw:text-gray-800 m-0">
|
||||||
{% trans 'Management' %}
|
{% trans 'Management' %}
|
||||||
</h5>
|
</h5>
|
||||||
|
|
||||||
<button type="button" class="btn-close tw:lg:hidden tw:lg:group-hover:inline" aria-label="Close"
|
<button type="button" class="btn-close" aria-label="Close"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
data-bs-target="#collapsible-panel"
|
data-bs-target="#collapsible-panel"
|
||||||
aria-expanded="true"
|
aria-expanded="true"
|
||||||
@@ -155,7 +179,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="list-unstyled p-3 d-flex flex-column gap-1 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
|
<ul class="list-unstyled p-3 d-flex flex-column gap-1 tw:lg:group-hover:animate-[disable-pointer-events] tw:flex-1"
|
||||||
style="animation-duration: 100ms">
|
style="animation-duration: 100ms">
|
||||||
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
|
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
|
||||||
<c-components.sidebar-menu-item
|
<c-components.sidebar-menu-item
|
||||||
@@ -261,7 +285,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="btn-group w-100" role="group">
|
<div class="btn-group w-100 sidebar-item" role="group">
|
||||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-toggle="tooltip"
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-toggle="tooltip"
|
||||||
data-bs-title="{% trans "Calculator" %}"
|
data-bs-title="{% trans "Calculator" %}"
|
||||||
_="on click trigger show on #calculator">
|
_="on click trigger show on #calculator">
|
||||||
@@ -273,17 +297,24 @@
|
|||||||
<div>
|
<div>
|
||||||
<hr class="my-1">
|
<hr class="my-1">
|
||||||
<div
|
<div
|
||||||
class="ps-4 pe-2 py-2 d-flex align-items-center text-body-secondary text-decoration-none justify-content-between tw:text-wrap tw:lg:text-nowrap">
|
class="ps-4 pe-2 py-2 d-flex align-items-center text-decoration-none justify-content-between">
|
||||||
<div>
|
|
||||||
<i class="fa-solid fa-circle-user"></i>
|
<div class="d-flex align-items-center" style="min-width: 0;">
|
||||||
<strong class="ms-2 tw:lg:invisible tw:lg:group-hover:visible">{{ user.email }}</strong>
|
<i class="fa-solid fa-circle-user text-body-secondary"></i>
|
||||||
|
|
||||||
|
<strong class="mx-2 text-body-secondary text-truncate sidebar-invisible">
|
||||||
|
{{ user.email }}
|
||||||
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw:lg:invisible tw:lg:group-hover:visible">
|
|
||||||
|
<div class="sidebar-invisible">
|
||||||
{% include 'includes/navbar/user_menu.html' %}
|
{% include 'includes/navbar/user_menu.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-16 tw:h-full tw:bg-transparent"></div>
|
class="tw:hidden tw:lg:group-hover:block tw:absolute tw:top-0 tw:left-104 tw:w-16 tw:h-full tw:bg-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
|
<div hx-get="{% url 'category_overview' %}" hx-trigger="updated from:window" class="show-loading" hx-swap="outerHTML"
|
||||||
hx-include="#picker-form, #picker-type, #view-type, #show-tags, #showing">
|
hx-include="#picker-form, #picker-type, #view-type, #show-tags, #showing, #show-entities">
|
||||||
<div class="h-100 text-center mb-4">
|
<div class="h-100 text-center mb-4">
|
||||||
<div class="btn-group gap-3" role="group" id="view-type" _="on change trigger updated">
|
<div class="btn-group gap-3" role="group" id="view-type" _="on change trigger updated">
|
||||||
<input type="radio" class="btn-check"
|
<input type="radio" class="btn-check"
|
||||||
@@ -25,8 +25,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 mb-1 d-flex flex-column flex-md-row justify-content-between">
|
<div class="mt-3 mb-1 d-flex flex-column flex-md-row justify-content-between">
|
||||||
<div class="form-check form-switch" id="show-tags">
|
<div class="d-flex gap-4">
|
||||||
{% if view_type == 'table' %}
|
{% if view_type == 'table' %}
|
||||||
|
<div class="form-check form-switch" id="show-tags">
|
||||||
<input type="hidden" name="show_tags" value="off">
|
<input type="hidden" name="show_tags" value="off">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="show-tags-switch" name="show_tags"
|
<input class="form-check-input" type="checkbox" role="switch" id="show-tags-switch" name="show_tags"
|
||||||
_="on change trigger updated" {% if show_tags %}checked{% endif %}>
|
_="on change trigger updated" {% if show_tags %}checked{% endif %}>
|
||||||
@@ -38,6 +39,20 @@
|
|||||||
content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}"
|
content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}"
|
||||||
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
||||||
{% endspaceless %}
|
{% endspaceless %}
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch" id="show-entities" {% if not show_tags %}style="display: none;"{% endif %}>
|
||||||
|
<input type="hidden" name="show_entities" value="off">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="show-entities-switch" name="show_entities"
|
||||||
|
_="on change trigger updated" {% if show_entities %}checked{% endif %}>
|
||||||
|
{% spaceless %}
|
||||||
|
<label class="form-check-label" for="show-entities-switch">
|
||||||
|
{% trans 'Entities' %}
|
||||||
|
</label>
|
||||||
|
<c-ui.help-icon
|
||||||
|
content="{% trans 'Transaction amounts associated with multiple tags and entities will be counted once for each tag' %}"
|
||||||
|
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
|
||||||
|
{% endspaceless %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm" role="group" id="showing" _="on change trigger updated">
|
<div class="btn-group btn-group-sm" role="group" id="showing" _="on change trigger updated">
|
||||||
@@ -250,6 +265,102 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<!-- Entity rows -->
|
||||||
|
{% if show_entities %}
|
||||||
|
{% for entity_id, entity in tag.entities.items %}
|
||||||
|
{% if entity.name or not entity.name and tag.entities.values|length > 1 %}
|
||||||
|
<tr class="table-row-nested-2">
|
||||||
|
<td class="ps-5">
|
||||||
|
<i class="fa-solid fa-user-group fa-fw me-2 text-muted"></i>{% if entity.name %}{{ entity.name }}{% else %}{% trans 'No entity' %}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% for currency in entity.currencies.values %}
|
||||||
|
{% if showing == 'current' and currency.income_current != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.income_current"
|
||||||
|
:prefix="currency.currency.prefix"
|
||||||
|
:suffix="currency.currency.suffix"
|
||||||
|
:decimal_places="currency.currency.decimal_places"
|
||||||
|
color="green"></c-amount.display>
|
||||||
|
{% elif showing == 'projected' and currency.income_projected != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.income_projected"
|
||||||
|
:prefix="currency.currency.prefix"
|
||||||
|
:suffix="currency.currency.suffix"
|
||||||
|
:decimal_places="currency.currency.decimal_places"
|
||||||
|
color="green"></c-amount.display>
|
||||||
|
{% elif showing == 'final' and 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 entity.currencies.values %}
|
||||||
|
{% if showing == 'current' and currency.expense_current != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.expense_current"
|
||||||
|
:prefix="currency.currency.prefix"
|
||||||
|
:suffix="currency.currency.suffix"
|
||||||
|
:decimal_places="currency.currency.decimal_places"
|
||||||
|
color="red"></c-amount.display>
|
||||||
|
{% elif showing == 'projected' and currency.expense_projected != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.expense_projected"
|
||||||
|
:prefix="currency.currency.prefix"
|
||||||
|
:suffix="currency.currency.suffix"
|
||||||
|
:decimal_places="currency.currency.decimal_places"
|
||||||
|
color="red"></c-amount.display>
|
||||||
|
{% elif showing == 'final' and 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 entity.currencies.values %}
|
||||||
|
{% if showing == 'current' and currency.total_current != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.total_current"
|
||||||
|
: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>
|
||||||
|
{% elif showing == 'projected' and currency.total_projected != 0 %}
|
||||||
|
<c-amount.display
|
||||||
|
:amount="currency.total_projected"
|
||||||
|
: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>
|
||||||
|
{% elif showing == 'final' and 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>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
{% include 'includes/mobile_navbar.html' %}
|
{% include 'includes/mobile_navbar.html' %}
|
||||||
{% include 'includes/sidebar.html' %}
|
{% include 'includes/sidebar.html' %}
|
||||||
|
|
||||||
<main class="tw:p-4 tw:lg:ml-16">
|
<main class="tw:p-4">
|
||||||
{% settings "DEMO" as demo_mode %}
|
{% settings "DEMO" as demo_mode %}
|
||||||
{% if demo_mode %}
|
{% if demo_mode %}
|
||||||
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
<div class="px-3 m-0" id="demo-mode-alert" hx-preserve>
|
||||||
|
|||||||
@@ -133,48 +133,32 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
||||||
<div class="row mb-1">
|
|
||||||
<div class="col-sm-6 col-12">
|
<div class="my-3">
|
||||||
{# Filter transactions button #}
|
{# Hidden select to hold the order value and preserve the original update trigger #}
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button"
|
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
|
||||||
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false"
|
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
||||||
aria-controls="collapse-filter">
|
<option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
||||||
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
|
<option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{# Ordering button#}
|
|
||||||
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0">
|
|
||||||
<div class="text-sm-end" _="on change trigger updated on window">
|
|
||||||
<label for="order">{% translate "Order by" %}</label>
|
|
||||||
<select
|
|
||||||
class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body"
|
|
||||||
name="order" id="order">
|
|
||||||
<option value="default"
|
|
||||||
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
|
||||||
<option value="older"
|
|
||||||
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
|
||||||
<option value="newer"
|
|
||||||
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
{# Main control bar with filter, search, and ordering #}
|
||||||
</div>
|
<div class="input-group">
|
||||||
{# Filter transactions form#}
|
|
||||||
<div class="collapse" id="collapse-filter" hx-preserve>
|
<button class="btn btn-secondary position-relative" type="button"
|
||||||
<div class="card card-body">
|
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
|
||||||
<form _="on change or submit or search trigger updated on window end
|
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
|
||||||
install init_tom_select
|
title="{% translate 'Filter transactions' %}">
|
||||||
install init_datepicker"
|
<i class="fa-solid fa-filter fa-fw"></i>
|
||||||
id="filter">
|
</button>
|
||||||
{% crispy filter.form %}
|
|
||||||
</form>
|
{# Search box #}
|
||||||
<button class="btn btn-outline-danger btn-sm"
|
<label for="quick-search">
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
</label>
|
||||||
</div>
|
<input type="search"
|
||||||
</div>
|
class="form-control"
|
||||||
<div id="search" class="my-3">
|
placeholder="{% translate 'Search' %}"
|
||||||
<label class="w-100">
|
hx-preserve
|
||||||
<input type="search" class="form-control" placeholder="{% translate 'Search' %}" hx-preserve
|
|
||||||
id="quick-search"
|
id="quick-search"
|
||||||
_="on input or search or htmx:afterSwap from window
|
_="on input or search or htmx:afterSwap from window
|
||||||
if my value is empty
|
if my value is empty
|
||||||
@@ -185,7 +169,65 @@
|
|||||||
show <.transactions-divider-title/> when my value is empty
|
show <.transactions-divider-title/> when my value is empty
|
||||||
show <.transaction/> in <#transactions-list/>
|
show <.transaction/> in <#transactions-list/>
|
||||||
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||||
</label>
|
|
||||||
|
{# Order by icon dropdown #}
|
||||||
|
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-no-icon" type="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
title="{% translate 'Order by' %}">
|
||||||
|
<i class="fa-solid fa-sort fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'default' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'default'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Default' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'older' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'older'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Oldest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'newer' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'newer'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Newest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Filter transactions form #}
|
||||||
|
<div class="collapse" id="collapse-filter" hx-preserve>
|
||||||
|
<div class="card card-body">
|
||||||
|
<div class="text-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form _="on change or submit or search trigger updated on window
|
||||||
|
install init_tom_select
|
||||||
|
install init_datepicker"
|
||||||
|
id="filter" class="mt-3">
|
||||||
|
{% crispy filter.form %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{# Transactions list#}
|
{# Transactions list#}
|
||||||
<div id="transactions"
|
<div id="transactions"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col" class="col-auto"></th>
|
<th scope="col" class="col-auto"></th>
|
||||||
<th scope="col" class="col-auto"></th>
|
<th scope="col" class="col-auto"></th>
|
||||||
|
<th scope="col" class="col-auto">{% translate 'Order' %}</th>
|
||||||
<th scope="col" class="col">{% translate 'Name' %}</th>
|
<th scope="col" class="col">{% translate 'Name' %}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -80,6 +81,9 @@
|
|||||||
<i class="fa-solid fa-toggle-off tw:text-red-400"></i>{% endif %}
|
<i class="fa-solid fa-toggle-off tw:text-red-400"></i>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="col text-center">
|
||||||
|
<div>{{ rule.order }}</div>
|
||||||
|
</td>
|
||||||
<td class="col">
|
<td class="col">
|
||||||
<div>{{ rule.name }}</div>
|
<div>{{ rule.name }}</div>
|
||||||
<div class="tw:text-gray-400">{{ rule.description }}</div>
|
<div class="tw:text-gray-400">{{ rule.description }}</div>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Edit transaction rule' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'transaction_rule_dry_run_created' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||||
|
hx-indicator="#dry-run-created-result, closest form" class="show-loading" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div id="dry-run-created-result" class="show-loading">
|
||||||
|
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Edit transaction rule' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'transaction_rule_dry_run_deleted' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||||
|
hx-indicator="#dry-run-deleted-result, closest form" class="show-loading" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div id="dry-run-deleted-result" class="show-loading">
|
||||||
|
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'extends/offcanvas.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
|
{% block title %}{% translate 'Edit transaction rule' %}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form hx-post="{% url 'transaction_rule_dry_run_updated' pk=rule.id %}" hx-target="#generic-offcanvas"
|
||||||
|
hx-indicator="#dry-run-updated-result, closest form" class="show-loading" novalidate>
|
||||||
|
{% crispy form %}
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div id="dry-run-updated-result" class="show-loading">
|
||||||
|
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
<div class="card tw:max-h-full tw:overflow-auto tw:overflow-x-auto">
|
||||||
|
<div class="card-header">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link active" id="visual-tab" data-bs-toggle="tab" data-bs-target="#visual-tab-pane"
|
||||||
|
type="button" role="tab" aria-controls="visual-tab-pane"
|
||||||
|
aria-selected="true">{% translate 'Visual' %}</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="logs-tab" data-bs-toggle="tab" data-bs-target="#logs-tab-pane" type="button"
|
||||||
|
role="tab" aria-controls="logs-tab-pane" aria-selected="false">{% translate 'Logs' %}</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content" id="myTabContent">
|
||||||
|
<div class="tab-pane fade show active" id="visual-tab-pane" role="tabpanel" aria-labelledby="home-tab"
|
||||||
|
tabindex="0">
|
||||||
|
{% if not results %}
|
||||||
|
{% translate 'Run a test to see...' %}
|
||||||
|
{% else %}
|
||||||
|
{% for result in results %}
|
||||||
|
|
||||||
|
{% if result.type == 'header' %}
|
||||||
|
<div class="my-3">
|
||||||
|
<h6 class="text-center mb-3">
|
||||||
|
<span class="badge text-bg-secondary">
|
||||||
|
{% if result.header_type == "edit_transaction" %}
|
||||||
|
{% translate 'Edit transaction' %}
|
||||||
|
{% elif result.header_type == "update_or_create_transaction" %}
|
||||||
|
{% translate 'Update or create transaction' %}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
|
||||||
|
{% if result.type == 'triggering_transaction' %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Start' %}</span></h6>
|
||||||
|
<c-transaction.item :transaction="result.transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.type == 'edit_transaction' %}
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
{% translate 'Set' %} <span
|
||||||
|
class="badge text-bg-secondary">{{ result.field }}</span> {% translate 'to' %}
|
||||||
|
<span class="badge text-bg-secondary">{{ result.new_value }}</span>
|
||||||
|
</div>
|
||||||
|
<c-transaction.item :transaction="result.transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.type == 'update_or_create_transaction' %}
|
||||||
|
<div>
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
{% translate 'Search' %}: {{ result.query }}
|
||||||
|
</div>
|
||||||
|
{% if result.start_transaction %}
|
||||||
|
<c-transaction.item :transaction="result.start_transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-danger" role="alert">
|
||||||
|
{% translate 'No transaction found, a new one will be created' %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-center h3 my-2"><i class="fa-solid fa-arrow-down"></i></div>
|
||||||
|
<c-transaction.item :transaction="result.end_transaction" :dummy="True"
|
||||||
|
:disable-selection="True"></c-transaction.item>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if result.type == 'error' %}
|
||||||
|
<div>
|
||||||
|
<div class="alert alert-{% if result.level == 'error' %}danger{% elif result.level == 'warning' %}warning{% else %}info{% endif %}" role="alert">
|
||||||
|
{{ result.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="logs-tab-pane" role="tabpanel" aria-labelledby="logs-tab" tabindex="0">
|
||||||
|
{% if not logs %}
|
||||||
|
{% translate 'Run a test to see...' %}
|
||||||
|
{% else %}
|
||||||
|
<pre>
|
||||||
|
{{ logs|linebreaks }}
|
||||||
|
</pre>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -30,21 +30,27 @@
|
|||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<div class="tw:text-xl mb-2">{% translate 'Then...' %}</div>
|
<div class="tw:text-xl mb-2">{% translate 'Then...' %}</div>
|
||||||
{% for action in transaction_rule.transaction_actions.all %}
|
{% for action in all_actions %}
|
||||||
|
{% if action.action_type == "edit_transaction" %}
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div>
|
<div>
|
||||||
|
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
|
||||||
|
<span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div>{% translate 'Set' %} <span
|
<div>
|
||||||
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div>
|
{% translate 'Set' %} <span
|
||||||
|
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}
|
||||||
|
</div>
|
||||||
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
|
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer text-end">
|
<div class="card-footer text-end">
|
||||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||||
role="button"
|
role="button"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{% translate "Edit" %}"
|
data-bs-title="{% translate 'Edit' %}"
|
||||||
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
|
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
|
||||||
hx-target="#generic-offcanvas">
|
hx-target="#generic-offcanvas">
|
||||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||||
@@ -52,23 +58,25 @@
|
|||||||
<a class="text-danger text-decoration-none p-1"
|
<a class="text-danger text-decoration-none p-1"
|
||||||
role="button"
|
role="button"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{% translate "Delete" %}"
|
data-bs-title="{% translate 'Delete' %}"
|
||||||
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
|
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
|
||||||
hx-trigger='confirmed'
|
hx-trigger='confirmed'
|
||||||
data-bypass-on-ctrl="true"
|
data-bypass-on-ctrl="true"
|
||||||
data-title="{% translate "Are you sure?" %}"
|
data-title="{% translate 'Are you sure?' %}"
|
||||||
data-text="{% translate "You won't be able to revert this!" %}"
|
data-text="{% translate "You won't be able to revert this!" %}"
|
||||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
data-confirm-text="{% translate 'Yes, delete it!' %}"
|
||||||
_="install prompt_swal">
|
_="install prompt_swal">
|
||||||
<i class="fa-solid fa-trash fa-fw"></i>
|
<i class="fa-solid fa-trash fa-fw"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% elif action.action_type == "update_or_create_transaction" %}
|
||||||
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
|
|
||||||
<div class="card mb-3">
|
<div class="card mb-3">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div>
|
<div>
|
||||||
|
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
|
||||||
|
<span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div>{% trans 'Edit to view' %}</div>
|
<div>{% trans 'Edit to view' %}</div>
|
||||||
@@ -77,7 +85,7 @@
|
|||||||
<a class="text-decoration-none tw:text-gray-400 p-1"
|
<a class="text-decoration-none tw:text-gray-400 p-1"
|
||||||
role="button"
|
role="button"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{% translate "Edit" %}"
|
data-bs-title="{% translate 'Edit' %}"
|
||||||
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
|
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
|
||||||
hx-target="#generic-offcanvas">
|
hx-target="#generic-offcanvas">
|
||||||
<i class="fa-solid fa-pencil fa-fw"></i>
|
<i class="fa-solid fa-pencil fa-fw"></i>
|
||||||
@@ -85,41 +93,66 @@
|
|||||||
<a class="text-danger text-decoration-none p-1"
|
<a class="text-danger text-decoration-none p-1"
|
||||||
role="button"
|
role="button"
|
||||||
data-bs-toggle="tooltip"
|
data-bs-toggle="tooltip"
|
||||||
data-bs-title="{% translate "Delete" %}"
|
data-bs-title="{% translate 'Delete' %}"
|
||||||
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
|
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
|
||||||
hx-trigger='confirmed'
|
hx-trigger='confirmed'
|
||||||
data-bypass-on-ctrl="true"
|
data-bypass-on-ctrl="true"
|
||||||
data-title="{% translate "Are you sure?" %}"
|
data-title="{% translate 'Are you sure?' %}"
|
||||||
data-text="{% translate "You won't be able to revert this!" %}"
|
data-text="{% translate "You won't be able to revert this!" %}"
|
||||||
data-confirm-text="{% translate "Yes, delete it!" %}"
|
data-confirm-text="{% translate 'Yes, delete it!' %}"
|
||||||
_="install prompt_swal">
|
_="install prompt_swal">
|
||||||
<i class="fa-solid fa-trash fa-fw"></i>
|
<i class="fa-solid fa-trash fa-fw"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %}
|
{% empty %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
{% translate 'This rule has no actions' %}
|
{% translate 'This rule has no actions' %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
<hr>
|
<hr>
|
||||||
<div class="dropdown">
|
<div class="d-grid d-lg-flex gap-2">
|
||||||
|
<div class="dropdown flex-fill">
|
||||||
|
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="fa-solid fa-flask-vial me-2"></i>{% translate 'Test' %}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% if transaction_rule.on_create %}
|
||||||
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
|
hx-get="{% url 'transaction_rule_dry_run_created' pk=transaction_rule.id %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'Create' %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if transaction_rule.on_update %}
|
||||||
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
|
hx-get="{% url 'transaction_rule_dry_run_updated' pk=transaction_rule.id %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'Update' %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if transaction_rule.on_delete %}
|
||||||
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
|
hx-get="{% url 'transaction_rule_dry_run_deleted' pk=transaction_rule.id %}"
|
||||||
|
hx-target="#generic-offcanvas">{% trans 'Delete' %}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown flex-fill">
|
||||||
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
|
||||||
aria-expanded="false">
|
aria-expanded="false">
|
||||||
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
|
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
|
||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end w-100">
|
<ul class="dropdown-menu">
|
||||||
<li><a class="dropdown-item" role="link"
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||||
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
|
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
|
||||||
<li><a class="dropdown-item" role="link"
|
<li><a class="dropdown-item" role="link" href="#"
|
||||||
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
|
||||||
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
|
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,31 +5,38 @@
|
|||||||
|
|
||||||
<div id="transactions-list">
|
<div id="transactions-list">
|
||||||
{% for x in transactions_by_date %}
|
{% for x in transactions_by_date %}
|
||||||
<div id="{{ x.grouper|slugify }}"
|
<div id="{{ x.grouper|slugify }}" class="transactions-divider"
|
||||||
_="on htmx:afterSettle from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
|
_="on htmx:afterSwap from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
|
||||||
<div class="mt-3 mb-1 w-100 tw:text-base border-bottom bg-body">
|
<div class="mt-3 mb-1 w-100 tw:text-base border-bottom bg-body transactions-divider-title">
|
||||||
<a class="text-decoration-none d-inline-block w-100"
|
<a class="text-decoration-none d-inline-block w-100"
|
||||||
role="button"
|
role="button"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
|
data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
|
||||||
id="c-{{ x.grouper|slugify }}-collapsible"
|
id="c-{{ x.grouper|slugify }}-collapsible"
|
||||||
aria-expanded="true"
|
aria-expanded="false"
|
||||||
aria-controls="c-{{ x.grouper|slugify }}-collapse">
|
aria-controls="c-{{ x.grouper|slugify }}-collapse">
|
||||||
{{ x.grouper }}
|
{{ x.grouper }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse" id="c-{{ x.grouper|slugify }}-collapse"
|
<div class="collapse transactions-divider-collapse" id="c-{{ x.grouper|slugify }}-collapse"
|
||||||
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
|
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
|
||||||
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
|
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
|
||||||
on htmx:afterSettle from #transactions
|
on htmx:afterSettle from #transactions or toggle
|
||||||
set state to sessionStorage.getItem(the closest parent @id)
|
set state to sessionStorage.getItem(the closest parent @id)
|
||||||
if state is 'true' or state is null
|
if state is 'true' or state is null
|
||||||
add .show to me
|
add .show to me
|
||||||
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
|
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
|
||||||
end">
|
else
|
||||||
|
remove .show from me
|
||||||
|
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to false
|
||||||
|
end
|
||||||
|
on show
|
||||||
|
add .show to me
|
||||||
|
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true">
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
{% for transaction in x.list %}
|
{% for transaction in x.list %}
|
||||||
<c-transaction.item :transaction="transaction"></c-transaction.item>
|
<c-transaction.item
|
||||||
|
:transaction="transaction"></c-transaction.item>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,45 +8,101 @@
|
|||||||
<div class="container px-md-3 py-3 column-gap-5">
|
<div class="container px-md-3 py-3 column-gap-5">
|
||||||
<div class="row gx-xl-4 gy-3">
|
<div class="row gx-xl-4 gy-3">
|
||||||
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
<div class="col-12 col-xl-8 order-2 order-xl-1">
|
||||||
<div class="row mb-1">
|
<div class="mb-3">
|
||||||
<div class="col-sm-6 col-12">
|
{# Hidden select to hold the order value and preserve the original update trigger #}
|
||||||
{# Filter transactions button #}
|
<select name="order" id="order" class="d-none" _="on change trigger updated on window">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button"
|
<option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
||||||
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false"
|
<option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
||||||
aria-controls="collapse-filter">
|
<option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
||||||
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{# Ordering button#}
|
|
||||||
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0">
|
|
||||||
<div class="text-sm-end" _="on change trigger updated on window">
|
|
||||||
<label for="order">{% translate "Order by" %}</label>
|
|
||||||
<select
|
|
||||||
class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body"
|
|
||||||
name="order" id="order">
|
|
||||||
<option value="default"
|
|
||||||
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
|
|
||||||
<option value="older"
|
|
||||||
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
|
|
||||||
<option value="newer"
|
|
||||||
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{# Main control bar with filter, search, and ordering #}
|
||||||
|
<div class="input-group">
|
||||||
|
|
||||||
|
<button class="btn btn-secondary position-relative" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
|
||||||
|
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
|
||||||
|
title="{% translate 'Filter transactions' %}">
|
||||||
|
<i class="fa-solid fa-filter fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# Search box #}
|
||||||
|
<label for="quick-search">
|
||||||
|
</label>
|
||||||
|
<input type="search"
|
||||||
|
class="form-control"
|
||||||
|
placeholder="{% translate 'Search' %}"
|
||||||
|
hx-preserve
|
||||||
|
id="quick-search"
|
||||||
|
_="on input or search or htmx:afterSwap from window
|
||||||
|
if my value is empty
|
||||||
|
trigger toggle on <.transactions-divider-collapse/>
|
||||||
|
else
|
||||||
|
trigger show on <.transactions-divider-collapse/>
|
||||||
|
end
|
||||||
|
show <.transactions-divider-title/> when my value is empty
|
||||||
|
show <.transaction/> in <#transactions-list/>
|
||||||
|
when its textContent.toLowerCase() contains my value.toLowerCase()">
|
||||||
|
|
||||||
|
{# Order by icon dropdown #}
|
||||||
|
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-no-icon" type="button"
|
||||||
|
data-bs-toggle="dropdown" aria-expanded="false"
|
||||||
|
title="{% translate 'Order by' %}">
|
||||||
|
<i class="fa-solid fa-sort fa-fw"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'default' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'default'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Default' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'older' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'older'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Oldest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button class="dropdown-item {% if order == 'newer' %}active{% endif %}" type="button"
|
||||||
|
_="on click remove .active from .dropdown-item in the closest <ul/>
|
||||||
|
then add .active to me
|
||||||
|
then set the value of #order to 'newer'
|
||||||
|
then trigger change on #order">
|
||||||
|
{% translate 'Newest first' %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{# Filter transactions form #}
|
{# Filter transactions form #}
|
||||||
<div class="collapse" id="collapse-filter">
|
<div class="collapse" id="collapse-filter" hx-preserve>
|
||||||
<div class="card card-body">
|
<div class="card card-body">
|
||||||
<form _="on change or submit or search trigger updated on window end
|
<div class="text-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form _="on change or submit or search trigger updated on window
|
||||||
install init_tom_select
|
install init_tom_select
|
||||||
install init_datepicker"
|
install init_datepicker"
|
||||||
id="filter">
|
id="filter" class="mt-3">
|
||||||
{% crispy filter.form %}
|
{% crispy filter.form %}
|
||||||
</form>
|
</form>
|
||||||
<button class="btn btn-outline-danger btn-sm"
|
|
||||||
|
<div class="text-end">
|
||||||
|
<button class="btn btn-outline-danger btn-sm tw:w-fit"
|
||||||
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="transactions"
|
<div id="transactions"
|
||||||
class="show-loading"
|
class="show-loading"
|
||||||
hx-get="{% url 'transactions_all_list' %}"
|
hx-get="{% url 'transactions_all_list' %}"
|
||||||
|
|||||||
@@ -1,2 +1,66 @@
|
|||||||
import htmx from "htmx.org";
|
import htmx from "htmx.org";
|
||||||
|
|
||||||
window.htmx = htmx;
|
window.htmx = htmx;
|
||||||
|
|
||||||
|
htmx.defineExtension('htmx-download', {
|
||||||
|
onEvent: function (name, evt) {
|
||||||
|
|
||||||
|
if (name === 'htmx:beforeRequest') {
|
||||||
|
// Set the responseType to 'arraybuffer' to handle binary data
|
||||||
|
evt.detail.xhr.responseType = 'arraybuffer';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'htmx:beforeSwap') {
|
||||||
|
const xhr = evt.detail.xhr;
|
||||||
|
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
// Parse headers
|
||||||
|
const headers = {};
|
||||||
|
const headerStr = xhr.getAllResponseHeaders();
|
||||||
|
const headerArr = headerStr.trim().split(/[\r\n]+/);
|
||||||
|
headerArr.forEach((line) => {
|
||||||
|
const parts = line.split(": ");
|
||||||
|
const header = parts.shift().toLowerCase();
|
||||||
|
const value = parts.join(": ");
|
||||||
|
headers[header] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract filename
|
||||||
|
let filename = 'downloaded_file.xlsx';
|
||||||
|
if (headers['content-disposition']) {
|
||||||
|
const filenameMatch = headers['content-disposition'].match(/filename\*?=(?:UTF-8'')?"?([^;\n"]+)/i);
|
||||||
|
if (filenameMatch && filenameMatch[1]) {
|
||||||
|
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type
|
||||||
|
const mimetype = headers['content-type'] || 'application/octet-stream';
|
||||||
|
|
||||||
|
// Create Blob
|
||||||
|
const blob = new Blob([xhr.response], {type: mimetype});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Trigger download
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.style.display = "none";
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
setTimeout(() => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
link.remove();
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.warn(`[htmx-download] Unexpected response status: ${xhr.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent htmx from swapping content
|
||||||
|
evt.detail.shouldSwap = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,3 +49,7 @@ $theme-colors: map.merge(
|
|||||||
.offcanvas-size-sm {
|
.offcanvas-size-sm {
|
||||||
--#{$prefix}offcanvas-width: min(95vw, 250px) !important;
|
--#{$prefix}offcanvas-width: min(95vw, 250px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-toggle.dropdown-toggle-no-icon::after {
|
||||||
|
display:none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,3 +85,7 @@ select[multiple] {
|
|||||||
[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
|
[data-bs-toggle="collapse"][aria-expanded="true"] .fa-chevron-down {
|
||||||
transform: rotate(-180deg);
|
transform: rotate(-180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div:where(.swal2-container) {
|
||||||
|
z-index: 1100 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,3 +9,133 @@
|
|||||||
.sidebar-item:not(.sidebar-active) {
|
.sidebar-item:not(.sidebar-active) {
|
||||||
@apply tw:text-gray-300 tw:hover:text-white;
|
@apply tw:text-gray-300 tw:hover:text-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.sidebar {
|
||||||
|
@apply tw:z-1020 tw:fixed tw:top-0 tw:start-0 tw:h-full tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating {
|
||||||
|
/* Establishes the hover group and sets the collapsed/hover widths for the container */
|
||||||
|
@apply tw:lg:w-16 tw:lg:hover:w-112;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating #sidebar {
|
||||||
|
/* Sets the collapsed/hover widths for the inner navigation element */
|
||||||
|
@apply tw:lg:w-16 tw:lg:group-hover:w-104 tw:transition-all tw:duration-100 tw:overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating + main {
|
||||||
|
/* Adjusts the main content margin to account for the collapsed sidebar */
|
||||||
|
@apply tw:lg:ml-16 tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-item span {
|
||||||
|
/* Hides the text labels and reveals them only on hover */
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-invisible {
|
||||||
|
/* Hides the text labels and reveals them only on hover */
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-menu-header {
|
||||||
|
/* Hides the menu headers and reveals them only on hover */
|
||||||
|
@apply tw:lg:hidden tw:lg:group-hover:inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating #sidebar-toggle-btn .fa-thumbtack-slash {
|
||||||
|
/* Hides the 'pin' icon in the floating state */
|
||||||
|
@apply tw:hidden!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating #sidebar-toggle-btn .fa-thumbtack {
|
||||||
|
/* Shows the 'expand' icon in the floating state */
|
||||||
|
@apply tw:inline-block!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-title span {
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-submenu-header {
|
||||||
|
@apply tw:flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-submenu-header {
|
||||||
|
@apply tw:lg:hidden tw:lg:group-hover:flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-submenu-header h5 {
|
||||||
|
@apply tw:lg:invisible tw:lg:group-hover:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-submenu-header button {
|
||||||
|
@apply tw:lg:hidden tw:lg:group-hover:inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .list-unstyled {
|
||||||
|
@apply tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-floating .sidebar-item {
|
||||||
|
@apply tw:text-wrap tw:lg:text-nowrap ;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- STATE 2: Fixed (Permanently Expanded) --- */
|
||||||
|
.sidebar-fixed {
|
||||||
|
/* Sets the fixed, expanded width for the container */
|
||||||
|
@apply tw:lg:w-[17%] tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed #sidebar {
|
||||||
|
/* Sets the fixed, expanded width for the inner navigation */
|
||||||
|
@apply tw:lg:w-[17%] tw:transition-all tw:duration-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed + main {
|
||||||
|
/* Adjusts the main content margin to account for the expanded sidebar */
|
||||||
|
@apply tw:lg:ml-[17%] tw:transition-all tw:duration-100;
|
||||||
|
|
||||||
|
/* Using 16vw to account for padding/margins */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-item {
|
||||||
|
@apply tw:text-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-item span {
|
||||||
|
/* Ensures text labels are always visible */
|
||||||
|
@apply tw:lg:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-menu-header {
|
||||||
|
/* Ensures menu headers are always visible */
|
||||||
|
@apply tw:lg:inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed #sidebar-toggle-btn .fa-thumbtack-slash {
|
||||||
|
/* Shows the 'pin' icon in the fixed state */
|
||||||
|
@apply tw:inline-block!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed #sidebar-toggle-btn .fa-thumbtack {
|
||||||
|
/* Hides the 'expand' icon in the fixed state */
|
||||||
|
@apply tw:hidden!;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-title span {
|
||||||
|
@apply tw:lg:visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .sidebar-submenu-header {
|
||||||
|
/* Ensures menu headers are always visible */
|
||||||
|
@apply tw:lg:flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-fixed .list-unstyled {
|
||||||
|
@apply tw:overflow-y-auto tw:overflow-x-hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user