Compare commits

..

108 Commits

Author SHA1 Message Date
Herculino Trotta
64b32316ca fix(accounts): unable to update accounts
due to wrong currency queryset
2025-09-08 09:19:17 -03:00
Herculino Trotta
efe020efb3 fix(rules): duplicating transactions when ran outside of test mode 2025-09-07 22:14:40 -03:00
Herculino Trotta
8c133f92ce fix(rules): add .exclude() to transactions() function 2025-09-07 21:30:03 -03:00
Herculino Trotta
f3c9d8faea feat(rules): add .exclude() to transactions() function 2025-09-07 21:24:53 -03:00
Herculino Trotta
8f5204a17b feat(rules): add .exclude() to transactions() function 2025-09-07 20:41:09 -03:00
Herculino Trotta
2235bdeabb changes 2025-09-02 23:17:04 -03:00
Herculino Trotta
d724300513 changes 2025-09-02 15:54:45 -03:00
Herculino Trotta
eacafa1def changes 2025-09-02 09:47:35 -03:00
Herculino Trotta
c738f5ee29 changes 2025-09-02 09:47:27 -03:00
Herculino Trotta
72904266bf feat(rules): add optional rules ordering 2025-08-31 09:06:48 -03:00
Herculino Trotta
670bee4325 feat(rules): add rule function to fetch transactions totals and balance 2025-08-30 15:36:07 -03:00
Herculino Trotta
3e2c1184ce Merge pull request #354
fix(yearly-overview): display total for archived accounts
2025-08-30 11:13:05 -03:00
Herculino Trotta
731f351eef fix(yearly-overview): display total for archived accounts 2025-08-30 11:12:47 -03:00
eitchtee
b7056e7aa1 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-30 02:09:21 +00:00
Herculino Trotta
accceed630 Merge pull request #353
feat(insights:category-overview): add "No entity" totals
2025-08-29 23:08:34 -03:00
Herculino Trotta
76346cb503 feat(insights:category-overview): add "No entity" totals 2025-08-29 23:08:16 -03:00
eitchtee
3df8952ea2 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-30 01:48:37 +00:00
Herculino Trotta
9bd067da96 Merge pull request #352
feat(currencies): allow archiving
2025-08-29 22:47:26 -03:00
Herculino Trotta
1abe9e9f62 feat(currencies): allow archiving 2025-08-29 22:47:00 -03:00
eitchtee
1a86b5dea4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-28 02:18:45 +00:00
Herculino Trotta
8f2f5a16c2 Merge pull request #349
fix(transactions:quick-transactions): error when saving due to wrong field definition
2025-08-27 23:17:09 -03:00
Herculino Trotta
4565dc770b fix(transactions:quick-transactions): error when saving due to wrong field definition 2025-08-27 23:16:06 -03:00
Herculino Trotta
23673def09 Merge pull request #346
fix(common:tasks): remove_old_jobs always failing
2025-08-24 10:41:48 -03:00
Herculino Trotta
dd2b9ead7e fix(common:tasks): remove_old_jobs always failing 2025-08-24 10:41:26 -03:00
Rhesa Daiva Bremana
2078e9f3e4 locale((Indonesian)): added translation using Weblate 2025-08-23 12:43:54 +00:00
eitchtee
e6bab57ab4 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-22 20:36:08 +00:00
Herculino Trotta
38d50a78f4 Merge pull request #344
fix(sidebar): sidebar status not saving properly
2025-08-22 17:34:42 -03:00
Herculino Trotta
0d947f9ba6 fix(sidebar): sidebar status not saving properly 2025-08-22 17:34:12 -03:00
eitchtee
99c85a56bb chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-22 20:16:36 +00:00
Herculino Trotta
ab1c074f27 Merge pull request #343
feat(sidebar): add button to keep it open
2025-08-22 17:15:55 -03:00
Herculino Trotta
abf3a148cc feat(sidebar): add button to keep it open 2025-08-22 17:15:32 -03:00
Herculino Trotta
2733c92da5 style(sidebar): truncate e-mail if it's too long 2025-08-22 13:14:47 -03:00
eitchtee
9bfbe54ed5 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-19 17:03:42 +00:00
Herculino Trotta
5b27dea07c Merge pull request #340
feat: turn filter, order and search into a single bar
2025-08-19 14:02:06 -03:00
Herculino Trotta
791e1000a3 feat(all-transactions): turn filter, order and search into a single bar 2025-08-19 14:01:35 -03:00
Herculino Trotta
7301d9f475 feat(monthly): turn filter, order and search into a single bar 2025-08-19 13:39:57 -03:00
sorcierwax
47a44e96f8 locale(French): update translation
Currently translated at 100.0% (685 of 685 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-18 08:17:42 +00:00
Herculino Trotta
7d247eb737 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (685 of 685 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-17 08:17:42 +00:00
Dimitri Decrock
373616e7bb locale(Dutch): update translation
Currently translated at 100.0% (685 of 685 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-17 08:17:42 +00:00
eitchtee
bf3c11d582 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-17 06:55:57 +00:00
Herculino Trotta
b4b1c10db9 Merge pull request #338
refactor(currencies): DEPRECATE SYNTH FINANCE
2025-08-17 03:54:28 -03:00
Herculino Trotta
5ca531f47d refactor(currencies): DEPRECATE SYNTH FINANCE 2025-08-17 03:54:10 -03:00
eitchtee
07673cb528 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-17 06:23:31 +00:00
Herculino Trotta
67c6d81897 Merge pull request #337
feat(currencies): add TwelveData and TwelveDataMarkets as providers
2025-08-17 03:22:11 -03:00
Herculino Trotta
3c85da46b0 feat(currencies): add TwelveData and TwelveDataMarkets as providers 2025-08-17 03:21:55 -03:00
eitchtee
d263936be7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-17 03:51:16 +00:00
Herculino Trotta
1524063d5a Merge pull request #336
feat(currencies): add Frankfurter as an Exchange Rate provider
2025-08-17 00:49:54 -03:00
Herculino Trotta
c3a403b8f0 feat(currencies): add Frankfurter as an Exchange Rate provider 2025-08-17 00:49:32 -03:00
eitchtee
1c1018adae chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-16 20:41:25 +00:00
Herculino Trotta
350d5adf25 Merge pull request #335
refactor: remove debug prints
2025-08-16 17:38:44 -03:00
Herculino Trotta
7e4defb9cc refactor: remove debug prints 2025-08-16 17:38:22 -03:00
Herculino Trotta
7121e4bc09 Merge pull request #334
fix(tooltips): sometimes not getting created on htmx swap
2025-08-16 17:37:53 -03:00
Herculino Trotta
4540e48fe5 fix(tooltips): sometimes not getting created on htmx swap 2025-08-16 17:37:27 -03:00
Herculino Trotta
d06b51421f Merge pull request #333
feat(insights:category-overview): display entities on table
2025-08-16 17:37:15 -03:00
Herculino Trotta
e096912e41 feat(insights:category-overview): display entities on table 2025-08-16 17:36:19 -03:00
Dimitri Decrock
f0ad6e16fe locale(Dutch): update translation
Currently translated at 100.0% (684 of 684 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-16 11:17:42 +00:00
Herculino Trotta
734a302fa7 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (684 of 684 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-16 04:17:41 +00:00
eitchtee
89b1b7bcb7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-16 01:55:05 +00:00
Herculino Trotta
37b40f89bb Merge pull request #332
feat: add today button to MonthYearPicker
2025-08-15 22:54:21 -03:00
Herculino Trotta
0c63552d1b feat: add today button to MonthYearPicker 2025-08-15 22:54:04 -03:00
Herculino Trotta
7db517e848 Merge pull request #331
feat(export): improve export flow by using HTMX
2025-08-15 22:40:00 -03:00
Herculino Trotta
7e3ed6cf94 feat(export): improve export flow by using HTMX 2025-08-15 22:39:18 -03:00
Herculino Trotta
e10a88c00e Merge pull request #330
fix(sidebar): management menu not scroll correctly
2025-08-15 12:53:01 -03:00
Herculino Trotta
b912a33b93 fix(sidebar): management menu not scroll correctly 2025-08-15 12:49:14 -03:00
eitchtee
d9fb3627cc chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-15 15:31:23 +00:00
Herculino Trotta
78ffa68ba4 Merge pull request #329
feat(transactions): filter for unset category/tag/entity
2025-08-15 12:30:14 -03:00
Herculino Trotta
37f4ead058 feat(transactions): filter for unset category/tag/entity
implements #327
2025-08-15 12:29:32 -03:00
Herculino Trotta
61630fca5b locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (680 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-12 22:17:41 +00:00
sorcierwax
910d4c84a3 locale(French): update translation
Currently translated at 99.1% (674 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 13:07:17 +00:00
sorcierwax
be1f29d8c1 locale(French): update translation
Currently translated at 99.1% (674 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 11:17:42 +00:00
sorcierwax
9784d840cc locale(French): update translation
Currently translated at 83.3% (567 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 09:47:01 +00:00
sorcierwax
db5ce13ff3 locale(French): update translation
Currently translated at 76.3% (519 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 09:25:38 +00:00
sorcierwax
a2b943d949 locale(French): update translation
Currently translated at 62.0% (422 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-08-12 08:45:52 +00:00
eitchtee
d8098b4486 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-10 15:35:43 +00:00
Herculino Trotta
f8cff6623f Merge pull request #324
feat(locale): add space-dot and space-comma number formatting options, where the thousand separator is a space
2025-08-10 12:34:59 -03:00
Herculino Trotta
65c61f76ff feat(locale): add space-dot and space-comma number formatting options, where the thousand separator is a space 2025-08-10 12:34:29 -03:00
Herculino Trotta
74899f63ab Merge pull request #323
fix(locale): get_format doesn't override number formatting if use_l10n is None
2025-08-10 12:24:37 -03:00
Herculino Trotta
66a5e6d613 fix(locale): get_format doesn't override number formatting if use_l10n is None 2025-08-10 12:23:41 -03:00
Dimitri Decrock
e0ab32ec03 locale(Dutch): update translation
Currently translated at 100.0% (680 of 680 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-09 10:17:41 +00:00
eitchtee
a912e4a511 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-09 06:57:32 +00:00
Herculino Trotta
57ba672c91 Merge pull request #321
feat(accounts): add option for untracking accounts on a per user basis
2025-08-09 03:55:59 -03:00
Herculino Trotta
20c6989ffb fix(insights:emergency-fund): hide untracked accounts 2025-08-09 03:45:16 -03:00
Herculino Trotta
c6cd525c49 fix(insights): display untracked accounts on sankey by account 2025-08-09 03:42:21 -03:00
Herculino Trotta
55c4b920ee feat(accounts): add option for untracking accounts on a per user basis 2025-08-09 03:35:39 -03:00
google-labs-jules[bot]
7f8261b9cc refactor: Style transaction items for untracked accounts
This commit extends the "Untrack Account" feature by applying a special style to transaction items that belong to an untracked account.

- The transaction item template is modified to apply a "dimmed" style to transactions from untracked accounts.
- The styling follows the precedence: Account (untracked) > Category (muted) > Transaction (hidden).
- The dropdown menu for transaction items now shows "Controlled by account" if the transaction's account is untracked.
2025-08-09 05:47:18 +00:00
Herculino Trotta
9102654eab Merge pull request #320
style(swal): move swal prompt over tooltips
2025-08-08 19:01:54 -03:00
Herculino Trotta
1ff49a8a04 style(swal): move swal prompt over tooltips 2025-08-08 19:01:35 -03:00
Herculino Trotta
846dd1fd73 Merge remote-tracking branch 'origin/main' 2025-08-08 16:49:54 -03:00
Herculino Trotta
9eed3b6692 style(transactions): remove menu vertical positioning on smaller screens 2025-08-08 16:49:37 -03:00
Dimitri Decrock
b7c53a3c2d locale(Dutch): update translation
Currently translated at 99.7% (673 of 675 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-08 06:17:41 +00:00
Herculino Trotta
b378c8f6f7 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (675 of 675 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-08 04:17:41 +00:00
Herculino Trotta
ccc4deb1d8 Merge branch 'main' of https://github.com/eitchtee/WYGIWYH 2025-08-07 23:48:45 -03:00
Herculino Trotta
d3ecf55375 Merge remote-tracking branch 'weblate/main' 2025-08-07 23:43:25 -03:00
eitchtee
580f3e7345 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:41:32 +00:00
Herculino Trotta
0e5843094b Merge pull request #319
dev
2025-08-07 23:39:41 -03:00
Herculino Trotta
ed65945d19 feat(automatic-exchange-rates): rename automatic field 2025-08-07 23:39:21 -03:00
Herculino Trotta
18d8837c64 locale(Portuguese (Brazil)): update translation
Currently translated at 99.2% (671 of 676 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-08-08 02:37:06 +00:00
eitchtee
067d819077 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:37:02 +00:00
Herculino Trotta
bbaae4746a Merge pull request #318
feat(transactions:recurring): try to create transactions on update
2025-08-07 23:36:12 -03:00
Herculino Trotta
d2e5c1d6b3 feat(transactions:recurring): try to create transactions on update 2025-08-07 23:35:57 -03:00
eitchtee
ffef61d514 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-08 02:27:52 +00:00
Herculino Trotta
9020f6f972 Merge pull request #317
feat(automatic-exchange-rates): add "Single exchange rate" where only one exchange rate is added and updated to avoid db clutter
2025-08-07 23:26:10 -03:00
Herculino Trotta
540235c1b0 feat(automatic-exchange-rates): add "Single exchange rate" where only one exchange rate is added and updated to avoid db clutter 2025-08-07 23:25:51 -03:00
eitchtee
9070bc5705 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-07 20:04:56 +00:00
Herculino Trotta
ba5a6c9772 Merge pull request #316 from eitchtee/dev
feat(transactions): add menu itens for quickly changing transaction date
2025-08-07 17:04:11 -03:00
Herculino Trotta
5f24d05540 Merge pull request #315
style(login): make login button take full width as open id login
2025-08-07 12:03:02 -03:00
Dimitri Decrock
15d990007e locale(Dutch): update translation
Currently translated at 100.0% (670 of 670 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-08-07 06:17:41 +00:00
Herculino Trotta
3d5bc9cd3f Merge pull request #314
feat(tasks:check_for_updates): add env variable to disable checking
2025-08-06 16:59:15 -03:00
91 changed files with 10201 additions and 3828 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -138,6 +138,7 @@ class RecurringTransactionSerializer(serializers.ModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
instance = super().update(instance, validated_data) instance = super().update(instance, validated_data)
instance.update_unpaid_transactions() instance.update_unpaid_transactions()
instance.generate_upcoming_transactions()
return instance return instance

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
} }
@@ -203,21 +198,63 @@ class ExchangeRateFetcher:
if provider.rates_inverted: if provider.rates_inverted:
# If rates are inverted, we need to swap currencies # If rates are inverted, we need to swap currencies
ExchangeRate.objects.create( if service.singleton:
from_currency=to_currency, # Try to get the last automatically created exchange rate
to_currency=from_currency, exchange_rate = (
rate=rate, ExchangeRate.objects.filter(
date=timezone.now(), automatic=True,
) from_currency=to_currency,
to_currency=from_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((to_currency.id, from_currency.id)) processed_pairs.add((to_currency.id, from_currency.id))
else: else:
# If rates are not inverted, we can use them as is # If rates are not inverted, we can use them as is
ExchangeRate.objects.create( if service.singleton:
from_currency=from_currency, # Try to get the last automatically created exchange rate
to_currency=to_currency, exchange_rate = (
rate=rate, ExchangeRate.objects.filter(
date=timezone.now(), automatic=True,
) from_currency=from_currency,
to_currency=to_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((from_currency.id, to_currency.id)) processed_pairs.add((from_currency.id, to_currency.id))
service.last_fetch = timezone.now() service.last_fetch = timezone.now()

View File

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

View File

@@ -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",
@@ -114,6 +116,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
"fetch_interval", "fetch_interval",
"target_currencies", "target_currencies",
"target_accounts", "target_accounts",
"singleton",
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -126,6 +129,7 @@ class ExchangeRateServiceForm(forms.ModelForm):
"name", "name",
"service_type", "service_type",
Switch("is_active"), Switch("is_active"),
Switch("singleton"),
"api_key", "api_key",
Row( Row(
Column("interval_type", css_class="form-group col-md-6"), Column("interval_type", css_class="form-group col-md-6"),

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-08-08 02:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0014_alter_currency_options'),
]
operations = [
migrations.AddField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Automatic'),
),
migrations.AddField(
model_name='exchangerateservice',
name='singleton',
field=models.BooleanField(default=False, help_text='Create one exchange rate and keep updating it. Avoids database clutter.', verbose_name='Single exchange rate'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-08 02:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('currencies', '0015_exchangerate_automatic_exchangerateservice_singleton'),
]
operations = [
migrations.AlterField(
model_name='exchangerate',
name='automatic',
field=models.BooleanField(default=False, verbose_name='Auto'),
),
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -70,6 +75,8 @@ class ExchangeRate(models.Model):
) )
date = models.DateTimeField(verbose_name=_("Date and Time")) date = models.DateTimeField(verbose_name=_("Date and Time"))
automatic = models.BooleanField(verbose_name=_("Auto"), default=False)
class Meta: class Meta:
verbose_name = _("Exchange Rate") verbose_name = _("Exchange Rate")
verbose_name_plural = _("Exchange Rates") verbose_name_plural = _("Exchange Rates")
@@ -92,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")
@@ -148,6 +156,14 @@ class ExchangeRateService(models.Model):
blank=True, blank=True,
) )
singleton = models.BooleanField(
verbose_name=_("Single exchange rate"),
default=False,
help_text=_(
"Create one exchange rate and keep updating it. Avoids database clutter."
),
)
class Meta: class Meta:
verbose_name = _("Exchange Rate Service") verbose_name = _("Exchange Rate Service")
verbose_name_plural = _("Exchange Rate Services") verbose_name_plural = _("Exchange Rate Services")

View File

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

View File

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

View File

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

View File

@@ -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,10 +250,14 @@ 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 = (
is_paid=True, account__is_archived=False, account__is_asset=False Transaction.objects.filter(
).order_by( is_paid=True, account__is_archived=False, account__is_asset=False
"account__currency__name", )
.exclude(account__in=request.user.untracked_accounts.all())
.order_by(
"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"))
) )

View File

@@ -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 = (
reference_date__year=year, Transaction.objects.filter(
reference_date__month=month, reference_date__year=year,
).exclude(Q(Q(category__mute=True) & ~Q(category=None)) | Q(mute=True)) reference_date__month=month,
)
.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)

View File

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

View File

@@ -20,17 +20,18 @@ 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
@@ -39,10 +40,12 @@ 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

View File

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

View File

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

View File

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

View File

@@ -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(
@@ -1085,5 +1170,6 @@ class RecurringTransactionForm(forms.ModelForm):
instance.create_upcoming_transactions() instance.create_upcoming_transactions()
else: else:
instance.update_unpaid_transactions() instance.update_unpaid_transactions()
instance.generate_upcoming_transactions()
return instance return instance

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ from . import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("setup/", views.setup, name="setup"),
path("login/", views.UserLoginView.as_view(), name="login"), path("login/", views.UserLoginView.as_view(), name="login"),
# path("login/fallback/", views.UserLoginView.as_view(), name="fallback_login"), # path("login/fallback/", views.UserLoginView.as_view(), name="fallback_login"),
path("logout/", views.logout_view, name="logout"), path("logout/", views.logout_view, name="logout"),
@@ -18,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,

View File

@@ -21,8 +21,6 @@ from apps.users.forms import (
) )
from apps.users.models import UserSettings from apps.users.models import UserSettings
from apps.common.decorators.demo import disabled_on_demo from apps.common.decorators.demo import disabled_on_demo
from apps.currencies.models import Currency
from apps.accounts.models import Account
def logout_view(request): def logout_view(request):
@@ -50,28 +48,6 @@ def index(request):
return redirect(reverse("monthly_index")) return redirect(reverse("monthly_index"))
@login_required
def setup(request):
has_currency = Currency.objects.exists()
has_account = Account.objects.exists()
# return render(
# request,
# "users/setup/setup.html",
# {"has_currency": has_currency, "has_account": has_account},
# )
if not has_currency or not has_account:
return render(
request,
"users/setup/setup.html",
{"has_currency": has_currency, "has_account": has_account},
)
else:
return HttpResponse(
status=200,
headers={"HX-Reswap": "delete"},
)
class UserLoginView(LoginView): class UserLoginView(LoginView):
form_class = LoginForm form_class = LoginForm
template_name = "users/login.html" template_name = "users/login.html"
@@ -140,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"])

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -5,47 +5,51 @@
{% block title %}{% translate 'Pick a month' %}{% endblock %} {% block title %}{% translate 'Pick a month' %}{% endblock %}
{% block body %} {% block body %}
{% regroup month_year_data by year as years_list %} {% regroup month_year_data by year as years_list %}
<ul class="nav nav-pills nav-fill" id="yearTabs" role="tablist"> <ul class="nav nav-pills nav-fill" id="yearTabs" role="tablist">
{% for x in years_list %} {% for x in years_list %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link{% if x.grouper == current_year %} active{% endif %}" <button class="nav-link{% if x.grouper == current_year %} active{% endif %}"
id="{{ x.grouper }}" id="{{ x.grouper }}"
data-bs-toggle="tab" data-bs-toggle="tab"
data-bs-target="#{{ x.grouper }}-pane" data-bs-target="#{{ x.grouper }}-pane"
type="button" type="button"
role="tab" role="tab"
aria-controls="{{ x.grouper }}-pane" aria-controls="{{ x.grouper }}-pane"
aria-selected="{% if x.grouper == current_year %}true{% else %}false{% endif %}"> aria-selected="{% if x.grouper == current_year %}true{% else %}false{% endif %}">
{{ x.grouper }} {{ x.grouper }}
</button> </button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="yearTabsContent" hx-boost="true">
{% for x in years_list %}
<div class="tab-pane fade{% if x.grouper == current_year %} show active{% endif %} mt-2"
id="{{ x.grouper }}-pane"
role="tabpanel"
aria-labelledby="{{ x.grouper }}"
tabindex="0">
<ul class="list-group list-group-flush" id="month-year-list">
{% for month_data in x.list %}
<li class="list-group-item tw:hover:bg-zinc-900
{% if month_data.month == current_month and month_data.year == current_year %} disabled bg-primary{% endif %}"
{% if month_data.month == current_month and month_data.year == current_year %}aria-disabled="true"{% endif %}>
<div class="d-flex justify-content-between">
<a class="text-decoration-none stretched-link {% if month_data.month == current_month and month_data.year == current_year %} text-black{% endif %}"
href={{ month_data.url }}>
{{ month_data.month|month_name }}</a>
<span class="badge text-bg-secondary">{{ month_data.transaction_count }}</span>
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="tab-content" id="yearTabsContent" hx-boost="true">
{% for x in years_list %}
<div class="tab-pane fade{% if x.grouper == current_year %} show active{% endif %} mt-2"
id="{{ x.grouper }}-pane"
role="tabpanel"
aria-labelledby="{{ x.grouper }}"
tabindex="0">
<ul class="list-group list-group-flush" id="month-year-list">
{% for month_data in x.list %}
<li class="list-group-item tw:hover:bg-zinc-900
{% if month_data.month == current_month and month_data.year == current_year %} disabled bg-primary{% endif %}"
{% if month_data.month == current_month and month_data.year == current_year %}aria-disabled="true"{% endif %}>
<div class="d-flex justify-content-between">
<a class="text-decoration-none stretched-link {% if month_data.month == current_month and month_data.year == current_year %} text-black{% endif %}"
href={{ month_data.url }}>
{{ month_data.month|month_name }}</a>
<span class="badge text-bg-secondary">{{ month_data.transaction_count }}</span>
</div>
</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</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> </div>
{% endfor %}
</div>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> </div>
{% endif %}
{% endwith %} {% 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>
{% endif %}
{# 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> </div>
{% endif %}
{% endwith %} {% 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>
{% endif %}
</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,97 +117,136 @@
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#}
{% with exchanged=transaction.exchanged_amount %} {% if not dummy %}
{% if exchanged %} {% with exchanged=transaction.exchanged_amount %}
<div class="exchanged-amount mb-2 mb-lg-0"> {% if exchanged %}
<c-amount.display <div class="exchanged-amount mb-2 mb-lg-0">
:amount="exchanged.amount" <c-amount.display
:prefix="exchanged.prefix" :amount="exchanged.amount"
:suffix="exchanged.suffix" :prefix="exchanged.prefix"
:decimal_places="exchanged.decimal_places" :suffix="exchanged.suffix"
color="grey"></c-amount.display> :decimal_places="exchanged.decimal_places"
</div> color="grey"></c-amount.display>
{% endif %} </div>
{% endwith %} {% endif %}
{% 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>
<div> {% if not dummy %}
{# Item actions#} <div>
<div {# Item actions#}
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"> <div
<div class="card-body p-1 shadow-lg d-flex flex-column flex-md-row gap-1"> 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">
{% if not transaction.deleted %} <div class="card-body p-1 shadow-lg d-flex flex-row gap-1">
<a class="btn btn-secondary btn-sm transaction-action" {% if not transaction.deleted %}
role="button" <a class="btn btn-secondary btn-sm transaction-action"
data-bs-toggle="tooltip" role="button"
data-bs-title="{% translate "Edit" %}" data-bs-toggle="tooltip"
hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}" data-bs-title="{% translate "Edit" %}"
hx-target="#generic-offcanvas" hx-swap="innerHTML"> hx-get="{% url 'transaction_edit' transaction_id=transaction.id %}"
<i class="fa-solid fa-pencil fa-fw"></i></a> hx-target="#generic-offcanvas" hx-swap="innerHTML">
<a class="btn btn-secondary btn-sm transaction-action" <i class="fa-solid fa-pencil fa-fw"></i></a>
role="button" <a class="btn btn-secondary btn-sm transaction-action"
data-bs-toggle="tooltip" role="button"
data-bs-title="{% translate "Delete" %}" data-bs-toggle="tooltip"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}" data-bs-title="{% translate "Delete" %}"
hx-trigger='confirmed' hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
data-bypass-on-ctrl="true" hx-trigger='confirmed'
data-title="{% translate "Are you sure?" %}" data-bypass-on-ctrl="true"
data-text="{% translate "You won't be able to revert this!" %}" data-title="{% translate "Are you sure?" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-text="{% translate "You won't be able to revert this!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i> data-confirm-text="{% translate "Yes, delete it!" %}"
</a> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false"> </a>
<i class="fa-solid fa-ellipsis fa-fw"></i> <button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown"
</button> aria-expanded="false">
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start"> <i class="fa-solid fa-ellipsis fa-fw"></i>
{% if transaction.category.mute %} </button>
<li> <ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
<a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true"> {% if transaction.account.is_untracked_by %}
<i class="fa-solid fa-eye fa-fw me-2"></i> <li>
<div> <a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
{% translate 'Show on summaries' %} <i class="fa-solid fa-eye fa-fw me-2"></i>
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div> <div>
</div> {% translate 'Show on summaries' %}
</a> <div
</li> class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
{% elif transaction.mute %} </div>
<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> </a>
{% else %} </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> {% elif transaction.category.mute %}
{% endif %} <li>
<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> <a class="dropdown-item disabled d-flex align-items-center" aria-disabled="true">
<li><hr class="dropdown-divider"></li> <i class="fa-solid fa-eye fa-fw me-2"></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> <div>
<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> {% translate 'Show on summaries' %}
<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> <div
<li><hr class="dropdown-divider"></li> class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
<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> </div>
</ul> </a>
{% else %} </li>
<a class="btn btn-secondary btn-sm transaction-action" {% elif transaction.mute %}
role="button" <li><a class="dropdown-item" href="#"
data-bs-toggle="tooltip" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
data-bs-title="{% translate "Restore" %}" hx-target="closest .transaction" hx-swap="outerHTML"><i
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
class="fa-solid fa-trash-arrow-up"></i></a> {% else %}
<a class="btn btn-secondary btn-sm transaction-action" <li><a class="dropdown-item" href="#"
role="button" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
data-bs-toggle="tooltip" hx-target="closest .transaction" hx-swap="outerHTML"><i
data-bs-title="{% translate "Delete" %}" class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}" {% endif %}
hx-trigger='confirmed' <li><a class="dropdown-item" href="#"
data-bypass-on-ctrl="true" hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i
data-title="{% translate "Are you sure?" %}" class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a>
data-text="{% translate "You won't be able to revert this!" %}" </li>
data-confirm-text="{% translate "Yes, delete it!" %}" <li>
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i> <hr class="dropdown-divider">
</a> </li>
{% endif %} <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>
{% else %}
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Restore" %}"
hx-get="{% url 'transaction_undelete' transaction_id=transaction.id %}"><i
class="fa-solid fa-trash-arrow-up"></i></a>
<a class="btn btn-secondary btn-sm transaction-action"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_delete' transaction_id=transaction.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a>
{% endif %}
</div>
</div> </div>
</div> </div>
</div> {% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -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>#}
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/> {# <a href="{% url 'index' %}" class="d-none d-lg-flex tw:justify-start p-3 text-decoration-none sidebar-title">#}
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span> {# <img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>#}
</a> {# <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"/>
<span class="fs-4 fw-bold ms-3">WYGIWYH</span>
</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>

View File

@@ -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,19 +25,34 @@
</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' %}
<input type="hidden" name="show_tags" value="off"> <div class="form-check form-switch" id="show-tags">
<input class="form-check-input" type="checkbox" role="switch" id="show-tags-switch" name="show_tags" <input type="hidden" name="show_tags" value="off">
_="on change trigger updated" {% if show_tags %}checked{% endif %}> <input class="form-check-input" type="checkbox" role="switch" id="show-tags-switch" name="show_tags"
{% spaceless %} _="on change trigger updated" {% if show_tags %}checked{% endif %}>
<label class="form-check-label" for="show-tags-switch"> {% spaceless %}
{% trans 'Tags' %} <label class="form-check-label" for="show-tags-switch">
</label> {% trans 'Tags' %}
<c-ui.help-icon </label>
content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}" <c-ui.help-icon
icon="fa-solid fa-circle-exclamation"></c-ui.help-icon> content="{% trans 'Transaction amounts associated with multiple tags will be counted once for each tag' %}"
{% endspaceless %} icon="fa-solid fa-circle-exclamation"></c-ui.help-icon>
{% 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 %}

View File

@@ -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>
@@ -46,8 +46,6 @@
</div> </div>
{% endif %} {% endif %}
<div class="container" hx-get="{% url 'setup' %}" hx-trigger="load, updated from:window"></div>
<div id="content"> <div id="content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>

View File

@@ -44,12 +44,12 @@
</div> </div>
</div> </div>
{# Action buttons#} {# Action buttons#}
{# <div class="col-12 col-xl-8">#} {# <div class="col-12 col-xl-8">#}
{# <c-ui.quick-transactions-buttons#} {# <c-ui.quick-transactions-buttons#}
{# :year="year"#} {# :year="year"#}
{# :month="month"#} {# :month="month"#}
{# ></c-ui.quick-transactions-buttons>#} {# ></c-ui.quick-transactions-buttons>#}
{# </div>#} {# </div>#}
</div> </div>
{# Monthly summary#} {# Monthly summary#}
<div class="row gx-xl-4 gy-3"> <div class="row gx-xl-4 gy-3">
@@ -133,59 +133,101 @@
</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>
</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> </button>
</div>
{# Ordering button#} {# Search box #}
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0"> <label for="quick-search">
<div class="text-sm-end" _="on change trigger updated on window"> </label>
<label for="order">{% translate "Order by" %}</label> <input type="search"
<select class="form-control"
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" placeholder="{% translate 'Search' %}"
name="order" id="order"> hx-preserve
<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>
</div>
</div>
</div>
{# Filter transactions form#}
<div class="collapse" id="collapse-filter" hx-preserve>
<div class="card card-body">
<form _="on change or submit or search trigger updated on window end
install init_tom_select
install init_datepicker"
id="filter">
{% crispy filter.form %}
</form>
<button class="btn btn-outline-danger btn-sm"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
</div>
<div id="search" class="my-3">
<label class="w-100">
<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
trigger toggle on <.transactions-divider-collapse/> trigger toggle on <.transactions-divider-collapse/>
else else
trigger show on <.transactions-divider-collapse/> trigger show on <.transactions-divider-collapse/>
end end
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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,95 +30,128 @@
<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 %}
<div class="card mb-3"> {% if action.action_type == "edit_transaction" %}
<div class="card-header"> <div class="card mb-3">
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div> <div class="card-header">
<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 class="card-body">
<div>
{% translate 'Set' %} <span
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}
</div>
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw:text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Edit' %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Delete' %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate 'Are you sure?' %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div> </div>
<div class="card-body"> {% elif action.action_type == "update_or_create_transaction" %}
<div>{% translate 'Set' %} <span <div class="card mb-3">
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div> <div class="card-header">
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</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 class="card-body">
<div>{% trans 'Edit to view' %}</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw:text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Edit' %}"
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate 'Delete' %}"
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate 'Are you sure?' %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div> </div>
<div class="card-footer text-end"> {% endif %}
<a class="text-decoration-none tw:text-gray-400 p-1" {% empty %}
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
{% endfor %}
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
<div class="card mb-3">
<div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div>
</div>
<div class="card-body">
<div>{% trans 'Edit to view' %}</div>
</div>
<div class="card-footer text-end">
<a class="text-decoration-none tw:text-gray-400 p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}"
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i>
</a>
<a class="text-danger text-decoration-none p-1"
role="button"
data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}"
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i>
</a>
</div>
</div>
{% endfor %}
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %}
<div class="card"> <div class="card">
<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">
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown" <div class="dropdown flex-fill">
aria-expanded="false"> <button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %} aria-expanded="false">
</button> <i class="fa-solid fa-flask-vial me-2"></i>{% translate 'Test' %}
<ul class="dropdown-menu dropdown-menu-end w-100"> </button>
<li><a class="dropdown-item" role="link" <ul class="dropdown-menu">
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}" {% if transaction_rule.on_create %}
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li> <li><a class="dropdown-item" role="link" href="#"
<li><a class="dropdown-item" role="link" hx-get="{% url 'transaction_rule_dry_run_created' pk=transaction_rule.id %}"
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}" hx-target="#generic-offcanvas">{% trans 'Create' %}</a></li>
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li> {% endif %}
</ul> {% 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"
aria-expanded="false">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,121 +5,128 @@
<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>
</div> </div>
{% empty %} {% empty %}
<c-msg.empty <c-msg.empty
title="{% translate "No transactions found" %}" title="{% translate "No transactions found" %}"
subtitle="{% translate "Try adding one" %}"></c-msg.empty> subtitle="{% translate "Try adding one" %}"></c-msg.empty>
{% endfor %} {% endfor %}
{% if page_obj.has_other_pages %} {% if page_obj.has_other_pages %}
<div class="mt-auto"> <div class="mt-auto">
<input value="{{ page_obj.number }}" name="page" type="hidden" id="page"> <input value="{{ page_obj.number }}" name="page" type="hidden" id="page">
<nav aria-label="{% translate 'Page navigation' %}"> <nav aria-label="{% translate 'Page navigation' %}">
<ul class="pagination justify-content-center mt-5"> <ul class="pagination justify-content-center mt-5">
<li class="page-item"> <li class="page-item">
<a class="page-link tw:cursor-pointer {% if not page_obj.has_previous %}disabled{% endif %}" <a class="page-link tw:cursor-pointer {% if not page_obj.has_previous %}disabled{% endif %}"
hx-get="{% if page_obj.has_previous %}{% url 'transactions_all_list' %}{% endif %}" hx-get="{% if page_obj.has_previous %}{% url 'transactions_all_list' %}{% endif %}"
hx-vals='{"page": 1}' hx-vals='{"page": 1}'
hx-include="#filter, #order" hx-include="#filter, #order"
hx-target="#transactions-list" hx-target="#transactions-list"
aria-label="Primeira página" aria-label="Primeira página"
hx-swap="show:top"> hx-swap="show:top">
<span aria-hidden="true">&laquo;</span> <span aria-hidden="true">&laquo;</span>
</a> </a>
</li> </li>
{% for page_number in page_obj.paginator.page_range %} {% for page_number in page_obj.paginator.page_range %}
{% comment %} {% comment %}
This conditional allows us to display up to 3 pages before and after the current page This conditional allows us to display up to 3 pages before and after the current page
If you decide to remove this conditional, all the pages will be displayed If you decide to remove this conditional, all the pages will be displayed
You can change the 3 to any number you want e.g You can change the 3 to any number you want e.g
To display only 5 pagination items, change the 3 to 2 (2 before and 2 after the current page) To display only 5 pagination items, change the 3 to 2 (2 before and 2 after the current page)
{% endcomment %} {% endcomment %}
{% if page_number <= page_obj.number|add:3 and page_number >= page_obj.number|add:-3 %} {% if page_number <= page_obj.number|add:3 and page_number >= page_obj.number|add:-3 %}
{% if page_obj.number == page_number %} {% if page_obj.number == page_number %}
<li class="page-item active"> <li class="page-item active">
<a class="page-link tw:cursor-pointer"> <a class="page-link tw:cursor-pointer">
{{ page_number }} {{ page_number }}
</a> </a>
</li> </li>
{% else %} {% else %}
<li class="page-item"> <li class="page-item">
<a class="page-link tw:cursor-pointer" <a class="page-link tw:cursor-pointer"
hx-get="{% url 'transactions_all_list' %}" hx-get="{% url 'transactions_all_list' %}"
hx-vals='{"page": {{ page_number }}}' hx-vals='{"page": {{ page_number }}}'
hx-include="#filter, #order" hx-include="#filter, #order"
hx-target="#transactions-list" hx-target="#transactions-list"
hx-swap="show:top"> hx-swap="show:top">
{{ page_number }} {{ page_number }}
</a> </a>
</li> </li>
{% endif %}
{% endif %} {% endif %}
{% endfor %} {% endif %}
{% if page_obj.number|add:3 < page_obj.paginator.num_pages %} {% endfor %}
<li class="page-item"> {% if page_obj.number|add:3 < page_obj.paginator.num_pages %}
<a class="page-link disabled" <li class="page-item">
aria-label="..."> <a class="page-link disabled"
<span aria-hidden="true">...</span> aria-label="...">
</a> <span aria-hidden="true">...</span>
</li> </a>
<li class="page-item"> </li>
<a class="page-link tw:cursor-pointer" <li class="page-item">
hx-get="{% url 'transactions_all_list' %}" hx-target="#transactions-list" <a class="page-link tw:cursor-pointer"
hx-vals='{"page": {{ page_obj.paginator.num_pages }}}' hx-get="{% url 'transactions_all_list' %}" hx-target="#transactions-list"
hx-include="#filter, #order" hx-vals='{"page": {{ page_obj.paginator.num_pages }}}'
hx-swap="show:top" hx-include="#filter, #order"
aria-label="Última página"> hx-swap="show:top"
<span aria-hidden="true">{{ page_obj.paginator.num_pages }}</span> aria-label="Última página">
</a> <span aria-hidden="true">{{ page_obj.paginator.num_pages }}</span>
</li> </a>
{% endif %} </li>
<li class="page-item"> {% endif %}
<a class="page-link {% if not page_obj.has_next %}disabled{% endif %} tw:cursor-pointer" <li class="page-item">
hx-get="{% if page_obj.has_next %}{% url 'transactions_all_list' %}{% endif %}" <a class="page-link {% if not page_obj.has_next %}disabled{% endif %} tw:cursor-pointer"
hx-vals='{"page": {{ page_obj.paginator.num_pages }}}' hx-get="{% if page_obj.has_next %}{% url 'transactions_all_list' %}{% endif %}"
hx-include="#filter, #order" hx-vals='{"page": {{ page_obj.paginator.num_pages }}}'
hx-swap="show:top" hx-include="#filter, #order"
hx-target="#transactions-list" hx-swap="show:top"
aria-label="Next"> hx-target="#transactions-list"
<span aria-hidden="true">&raquo;</span> aria-label="Next">
</a> <span aria-hidden="true">&raquo;</span>
</li> </a>
</ul> </li>
</nav> </ul>
</div> </nav>
</div>
{% endif %} {% endif %}
{# Floating bar#} {# Floating bar#}
<c-ui.transactions-action-bar></c-ui.transactions-action-bar> <c-ui.transactions-action-bar></c-ui.transactions-action-bar>
</div> </div>

View File

@@ -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' %} </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> </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>
{# Ordering button#}
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0"> {# Filter transactions form #}
<div class="text-sm-end" _="on change trigger updated on window"> <div class="collapse" id="collapse-filter" hx-preserve>
<label for="order">{% translate "Order by" %}</label> <div class="card card-body">
<select <div class="text-end">
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" <button class="btn btn-outline-danger btn-sm tw:w-fit"
name="order" id="order"> _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
<option value="default" </div>
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
<option value="older" <form _="on change or submit or search trigger updated on window
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option> install init_tom_select
<option value="newer" install init_datepicker"
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option> id="filter" class="mt-3">
</select> {% 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>
</div> </div>
{# Filter transactions form#}
<div class="collapse" id="collapse-filter">
<div class="card card-body">
<form _="on change or submit or search trigger updated on window end
install init_tom_select
install init_datepicker"
id="filter">
{% crispy filter.form %}
</form>
<button class="btn btn-outline-danger btn-sm"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</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' %}"

View File

@@ -1,68 +0,0 @@
{% load i18n %}
<div class="card shadow-sm rounded-3">
<div class="card-header border-bottom-0 pt-4 px-4">
<h5 class="card-title fw-bold"><i class="fas fa-rocket me-2"></i>Let's Get You Set Up</h5>
</div>
<div class="card-body p-4">
<!-- Explanation Text -->
<div class="alert alert-info border-0" role="alert">
<div class="d-flex align-items-center">
<i class="fas fa-info-circle fa-lg me-3"></i>
<div>
Welcome to <strong>WYGIWYH</strong>! To get started, you need to configure a currency and create your first account. This will establish the foundation for managing your finances.
</div>
</div>
</div>
<!-- Task Lists -->
<div class="mt-4 border rounded-3 overflow-hidden">
<!-- Required Section -->
<div class="p-3 text-bg-secondary text-muted text-uppercase fw-bold small">Required Steps</div>
<ul class="list-group list-group-flush">
<!-- Task 1: Create Currency -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fa-regular fa-circle{% if has_currency %}-check{% endif %} fa-fw text-primary me-3"></i>
<span class="fw-medium {% if has_currency %}tw:line-through{% endif %}">{% trans 'Add' %} {% trans 'Currency' %}</span>
</div>
<a href="#" class="btn btn-primary btn-sm"
role="button"
hx-get="{% url 'currency_add' %}"
hx-target="#generic-offcanvas">{% trans 'Add' %} <i class="fas fa-arrow-right ms-1"></i></a>
</li>
<!-- Task 2: Create Account -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fa-regular fa-circle{% if has_account %}-check{% endif %} fa-fw text-primary me-3"></i>
<span class="fw-medium {% if has_account %}tw:line-through{% endif %}">{% trans 'Add' %} {% trans 'Account' %}</span>
</div>
<a class="btn btn-primary btn-sm"
role="button"
hx-get="{% url 'account_add' %}"
hx-target="#generic-offcanvas">{% trans 'Add' %} <i class="fas fa-arrow-right ms-1"></i></a>
</li>
</ul>
<!-- Optional Section -->
<div class="p-3 text-bg-secondary text-muted text-uppercase fw-bold small border-top">Optional Steps</div>
<ul class="list-group list-group-flush">
<!-- Task 3: Import Data -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fas fa-upload fa-fw text-secondary me-3"></i>
<span class="fw-medium">Import data from another app</span>
</div>
<a href="#" class="btn btn-outline-secondary btn-sm">Import</a>
</li>
<!-- Task 4: Invite Team -->
<li class="list-group-item d-flex justify-content-between align-items-center p-3">
<div class="d-flex align-items-center">
<i class="fas fa-user-plus fa-fw text-secondary me-3"></i>
<span class="fw-medium">Invite team members</span>
</div>
<a href="#" class="btn btn-outline-secondary btn-sm">Invite</a>
</li>
</ul>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,139 @@
@custom-variant hover (&:hover); @custom-variant hover (&:hover);
.sidebar-active { .sidebar-active {
@apply tw:bg-gray-700 tw:text-white; @apply tw:bg-gray-700 tw:text-white;
} }
.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;
}
}