Compare commits

..

87 Commits

Author SHA1 Message Date
Herculino Trotta 5d7dd622f5 feat: add internal_port env var 2025-11-09 15:42:42 -03:00
eitchtee f2abeff31a chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-14 04:57:39 +00:00
Herculino Trotta 666eaff167 Merge pull request #377
fix(rules:dry-run): rename offcanvas
2025-09-14 01:56:48 -03:00
Herculino Trotta d72454f854 fix(rules:dry-run): rename offcanvas 2025-09-14 01:56:31 -03:00
Herculino Trotta 333aa81923 Merge pull request #376
fix(rules:dry-run): current_user getting overwritten and delete on synchronous call
2025-09-14 01:37:23 -03:00
eitchtee 41b8cfd1e7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-14 04:37:05 +00:00
Herculino Trotta 1fa7985b01 fix(rules:dry-run): current_user getting overwritten and delete on synchronous call 2025-09-14 01:37:03 -03:00
Herculino Trotta 38392a6322 Merge pull request #375
feat(transactions): Try to convert amount to the expected Decimal if it is a str, int or float
2025-09-14 01:36:19 -03:00
Herculino Trotta 637c62319b feat(transactions): Try to convert amount to the expected Decimal if it is a str, int or float 2025-09-14 01:23:49 -03:00
Herculino Trotta f91fe67629 Merge pull request #374
feat(rules): expose if the transaction is recurring/installment
2025-09-14 01:18:28 -03:00
Herculino Trotta 9eb1818a20 feat(rules): expose if the transaction is recurring/installment 2025-09-14 01:18:08 -03:00
Herculino Trotta 50ac679e33 Merge pull request #373
fix(rules:dry-run): Edit/Update transaction not showing message when transaction can't be found
2025-09-14 00:41:28 -03:00
Herculino Trotta 2a463c63b8 fix(rules:dry-run): Edit/Update transaction not showing message when transaction can't be found 2025-09-14 00:41:04 -03:00
eitchtee dce65f2faf chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-13 06:24:57 +00:00
Herculino Trotta a053cb3947 Merge pull request #372
feat(ui:sidebar): smoother transition when clicking on menu items
2025-09-13 03:21:13 -03:00
Herculino Trotta 2d43072120 feat(ui:sidebar): smoother transition when clicking on menu items 2025-09-13 03:20:55 -03:00
Herculino Trotta 70bdee065e Merge pull request #371
feat(ui:sidebar): add a chevron to the management menu to indicate it opens another "page"
2025-09-13 03:20:01 -03:00
Herculino Trotta 95db27a32f feat(ui:sidebar): add a chevron to the management menu to indicate it opens another "page" 2025-09-13 03:19:36 -03:00
Herculino Trotta d6d4e6a102 Merge pull request #370
feat(ui:sidebar): keep management menu open if the user is on a management page
2025-09-13 03:19:06 -03:00
Herculino Trotta bc0f30fead feat(ui:sidebar): keep management menu open if the user is on a management page 2025-09-13 03:18:45 -03:00
Herculino Trotta a9a86fc491 Merge pull request #368 from eitchtee/weblate
Translations update from Weblate
2025-09-12 09:15:44 -03:00
Phillip Maizza c3b5f2bf39 locale(Italian): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 22:17:42 +00:00
Herculino Trotta 19128e5aed Merge pull request #367 from eitchtee/weblate
Translations update from Weblate
2025-09-11 18:49:31 -03:00
Phillip Maizza 9b5c6d3413 locale(Italian): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 21:17:42 +00:00
Phillip Maizza 73c873a2ad locale(Italian): update translation
Currently translated at 79.8% (554 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 19:17:42 +00:00
Phillip Maizza 9d2be22a77 locale(Italian): update translation
Currently translated at 28.8% (200 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/it/
2025-09-11 18:17:42 +00:00
Phillip Maizza 6a3d31f37d locale((Italian)): added translation using Weblate 2025-09-11 17:46:47 +00:00
Herculino Trotta 3be3a3c14b Merge pull request #366 from eitchtee/weblate
Translations update from Weblate
2025-09-09 23:02:50 -03:00
Dimitri Decrock a5b0f4efb7 locale(Dutch): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-09-09 18:17:42 +00:00
Herculino Trotta 6da50db417 Merge pull request #365 from samuelthng/patch-1
fix(app): pwa title colour
2025-09-09 00:10:15 -03:00
Samuel a6c1daf902 fix(app): PWA Title Colour 2025-09-09 08:12:16 +08:00
eitchtee 6a271fb3d7 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-08 12:20:45 +00:00
Herculino Trotta 2cf9a9dd0f Merge pull request #364
fix(accounts): unable to update accounts
2025-09-08 09:19:49 -03:00
Herculino Trotta 64b32316ca fix(accounts): unable to update accounts
due to wrong currency queryset
2025-09-08 09:19:17 -03:00
sorcierwax 0deaabe719 locale(French): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-09-08 06:17:42 +00:00
Herculino Trotta b14342af2e Merge pull request #362
fix(rules): duplicating transactions when ran outside of test mode
2025-09-07 22:15:11 -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 2c14ce6366 Merge pull request #361
fix(rules): add .exclude() to transactions() function
2025-09-07 21:30:32 -03:00
Herculino Trotta 8c133f92ce fix(rules): add .exclude() to transactions() function 2025-09-07 21:30:03 -03:00
Herculino Trotta 2dd887b0d9 Merge pull request #360
feat(rules): add .exclude() to transactions() function
2025-09-07 21:25:18 -03:00
Herculino Trotta f3c9d8faea feat(rules): add .exclude() to transactions() function 2025-09-07 21:24:53 -03:00
Herculino Trotta 8be7758dc0 Merge pull request #359
feat(rules): add .exclude() to transactions() function
2025-09-07 20:41:36 -03:00
Herculino Trotta 8f5204a17b feat(rules): add .exclude() to transactions() function 2025-09-07 20:41:09 -03:00
Herculino Trotta 05dd782df5 locale(Portuguese (Brazil)): update translation
Currently translated at 100.0% (694 of 694 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/pt_BR/
2025-09-07 14:17:42 +00:00
eitchtee 187fe43283 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-09-07 13:08:56 +00:00
Herculino Trotta cae73376db Merge pull request #358
feat(rules): many improvements
2025-09-07 10:07:19 -03:00
Herculino Trotta 7225454a6e Merge pull request #357
fix(ui): unable to CTRL + A amount fields
2025-09-05 23:05:49 -03:00
Herculino Trotta 70c8c1e07c fix(ui): unable to CTRL + A amount fields 2025-09-05 23:04:12 -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
sorcierwax c392a2c988 locale(French): update translation
Currently translated at 100.0% (686 of 686 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/fr/
2025-09-01 06:17:42 +00:00
Dimitri Decrock 17ea859fd2 locale(Dutch): update translation
Currently translated at 100.0% (686 of 686 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/nl/
2025-09-01 06:17:42 +00:00
eitchtee 8aae6f928f chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2025-08-31 12:07:49 +00:00
Herculino Trotta 7c43b06b9f Merge pull request #356
feat(rules): add optional rules ordering
2025-08-31 09:07:07 -03:00
Herculino Trotta 72904266bf feat(rules): add optional rules ordering 2025-08-31 09:06:48 -03:00
Herculino Trotta e16e279911 Merge pull request #355
feat(rules): add rule function to fetch transactions totals and balance
2025-08-30 15:45:45 -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
70 changed files with 13780 additions and 4509 deletions
+3
View File
@@ -160,3 +160,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/ .idea/
postgres_data/
.prod.env
+1
View File
@@ -126,6 +126,7 @@ To create the first user, open the container's console using Unraid's UI, by cli
| variable | type | default | explanation | | variable | type | default | explanation |
|-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |-------------------------------|-------------|-----------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| INTERNAL_PORT | int | 8000 | The port on which the app listens on. Defaults to 8000 if not set. |
| DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details | | DJANGO_ALLOWED_HOSTS | string | localhost 127.0.0.1 | A list of space separated domains and IPs representing the host/domain names that WYGIWYH site can serve. [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#allowed-hosts) for more details |
| HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection | | HTTPS_ENABLED | true\|false | false | Whether to use secure cookies. If this is set to true, the cookie will be marked as “secure”, which means browsers may ensure that the cookie is only sent under an HTTPS connection |
| URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details | | URL | string | http://localhost http://127.0.0.1 | A list of space separated domains and IPs (with the protocol) representing the trusted origins for unsafe requests (e.g. POST). [Click here](https://docs.djangoproject.com/en/5.1/ref/settings/#csrf-trusted-origins ) for more details |
+1 -1
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",
+14
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"
+4 -1
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
+3
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
+1 -1
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,
) )
+1 -1
View File
@@ -36,7 +36,7 @@ class ArbitraryDecimalDisplayNumberInput(forms.TextInput):
{ {
"x-data": "", "x-data": "",
"x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')", "x-mask:dynamic": f"$money($input, '{get_format('DECIMAL_SEPARATOR')}', '{get_format('THOUSAND_SEPARATOR')}', '30')",
"x-on:keyup": "$el.dispatchEvent(new Event('input'))", "x-on:keyup": "if (!['Control', 'Shift', 'Alt', 'Meta'].includes($event.key) && !(($event.ctrlKey || $event.metaKey) && $event.key.toLowerCase() === 'a')) $el.dispatchEvent(new Event('input'))",
} }
) )
+2
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",
@@ -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'),
),
]
+5
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
-6
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)
+22 -12
View File
@@ -12,7 +12,10 @@ from apps.currencies.utils.convert import convert
def get_categories_totals( def get_categories_totals(
transactions_queryset, ignore_empty=False, show_entities=False transactions_queryset, ignore_empty=False, show_entities=False
): ):
# First get the category totals as before # 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",
@@ -76,7 +79,10 @@ def get_categories_totals(
.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",
@@ -131,10 +137,12 @@ def get_categories_totals(
), ),
) )
# 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(
@@ -185,7 +193,7 @@ def get_categories_totals(
"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(
@@ -224,7 +232,7 @@ def get_categories_totals(
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
@@ -281,7 +289,7 @@ def get_categories_totals(
"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(
@@ -322,6 +330,7 @@ def get_categories_totals(
currency_id currency_id
] = tag_currency_data ] = tag_currency_data
# Step 6: If requested, aggregate and process entity-level data.
if show_entities: if show_entities:
entity_metrics = transactions_queryset.values( entity_metrics = transactions_queryset.values(
"category", "category",
@@ -389,14 +398,15 @@ def get_categories_totals(
tag_id = entity_metric["tags"] tag_id = entity_metric["tags"]
entity_id = entity_metric["entities"] entity_id = entity_metric["entities"]
if not entity_id:
continue
if category_id in result: if category_id in result:
tag_key = tag_id if tag_id is not None else "untagged" tag_key = tag_id if tag_id is not None else "untagged"
if tag_key in result[category_id]["tags"]: if tag_key in result[category_id]["tags"]:
entity_key = entity_id entity_key = entity_id if entity_id is not None else "no_entity"
entity_name = entity_metric["entities__name"] entity_name = (
entity_metric["entities__name"]
if entity_id is not None
else None
)
if "entities" not in result[category_id]["tags"][tag_key]: if "entities" not in result[category_id]["tags"][tag_key]:
result[category_id]["tags"][tag_key]["entities"] = {} result[category_id]["tags"][tag_key]["entities"] = {}
+2
View File
@@ -102,4 +102,6 @@ def get_transactions(
account__in=request.user.untracked_accounts.all() account__in=request.user.untracked_accounts.all()
) )
transactions = transactions.exclude(account__currency__is_archived=True)
return transactions return transactions
@@ -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()
+137 -3
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
)
@@ -0,0 +1,39 @@
# Generated by Django 5.2 on 2025-08-30 18:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("rules", "0014_alter_transactionrule_owner_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="transactionruleaction",
options={
"ordering": ["order"],
"verbose_name": "Edit transaction action",
"verbose_name_plural": "Edit transaction actions",
},
),
migrations.AlterModelOptions(
name="updateorcreatetransactionruleaction",
options={
"ordering": ["order"],
"verbose_name": "Update or create transaction action",
"verbose_name_plural": "Update or create transaction actions",
},
),
migrations.AddField(
model_name="transactionruleaction",
name="order",
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
),
migrations.AddField(
model_name="updateorcreatetransactionruleaction",
name="order",
field=models.PositiveIntegerField(default=0, verbose_name="Order"),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-08-31 18:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0015_alter_transactionruleaction_options_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='sequenced',
field=models.BooleanField(default=False, verbose_name='Sequenced'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.5 on 2025-08-31 19:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0016_transactionrule_sequenced'),
]
operations = [
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_mute',
field=models.TextField(blank=True, verbose_name='Search Mute'),
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='search_mute_operator',
field=models.CharField(choices=[('exact', 'is exactly'), ('contains', 'contains'), ('startswith', 'starts with'), ('endswith', 'ends with'), ('eq', 'equals'), ('gt', 'greater than'), ('lt', 'less than'), ('gte', 'greater than or equal'), ('lte', 'less than or equal')], default='exact', max_length=10, verbose_name='Mute Operator'),
),
migrations.AddField(
model_name='updateorcreatetransactionruleaction',
name='set_mute',
field=models.TextField(blank=True, verbose_name='Mute'),
),
migrations.AlterField(
model_name='transactionruleaction',
name='field',
field=models.CharField(choices=[('account', 'Account'), ('type', 'Type'), ('is_paid', 'Paid'), ('date', 'Date'), ('reference_date', 'Reference Date'), ('mute', 'Mute'), ('amount', 'Amount'), ('description', 'Description'), ('notes', 'Notes'), ('category', 'Category'), ('tags', 'Tags'), ('entities', 'Entities'), ('internal_nome', 'Internal Note'), ('internal_id', 'Internal ID')], max_length=50, verbose_name='Field'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.5 on 2025-09-02 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rules', '0017_updateorcreatetransactionruleaction_search_mute_and_more'),
]
operations = [
migrations.AddField(
model_name='transactionrule',
name='order',
field=models.PositiveIntegerField(default=0, verbose_name='Order'),
),
]
+40
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)
+7 -26
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,
) )
+666 -322
View File
File diff suppressed because it is too large Load Diff
+15
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
+101
View File
@@ -0,0 +1,101 @@
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,
"installment_id": sender.installment_id if sender.installment_plan else None,
"installment_total": (
sender.installment_plan.number_of_installments
if sender.installment_plan is not None
else None
),
"installment": sender.installment_plan is not None,
"recurring_transaction": sender.recurring_transaction is not None,
}
+178 -2
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},
)
+107 -22
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(
+66 -7
View File
@@ -1,4 +1,6 @@
import decimal
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 +35,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 +52,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 +381,10 @@ 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):
if isinstance(self.amount, (str, int, float)):
self.amount = decimal.Decimal(str(self.amount))
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 +394,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 +456,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):
+1 -1
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")
+14 -6
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,
+5
View File
@@ -17,6 +17,11 @@ urlpatterns = [
views.toggle_sound_playing, views.toggle_sound_playing,
name="toggle_sound_playing", name="toggle_sound_playing",
), ),
path(
"user/toggle-sidebar/",
views.toggle_sidebar_status,
name="toggle_sidebar_status",
),
path( path(
"user/settings/", "user/settings/",
views.update_settings, views.update_settings,
+18
View File
@@ -116,6 +116,24 @@ def update_settings(request):
return render(request, "users/fragments/user_settings.html", {"form": form}) return render(request, "users/fragments/user_settings.html", {"form": form})
@only_htmx
@htmx_login_required
def toggle_sidebar_status(request):
if not request.session.get("sidebar_status"):
request.session["sidebar_status"] = "floating"
if request.session["sidebar_status"] == "floating":
request.session["sidebar_status"] = "fixed"
elif request.session["sidebar_status"] == "fixed":
request.session["sidebar_status"] = "floating"
else:
request.session["sidebar_status"] = "fixed"
return HttpResponse(
status=204,
)
@htmx_login_required @htmx_login_required
@is_superuser @is_superuser
@require_http_methods(["GET"]) @require_http_methods(["GET"])
+1 -1
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:
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
File diff suppressed because it is too large Load Diff
+3
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>
@@ -1,6 +1,6 @@
<li> <li>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="text-muted small fw-bold text-uppercase tw:lg:hidden tw:lg:group-hover:inline me-2">{{ title }}</span> <span class="sidebar-menu-header text-muted small fw-bold text-uppercase me-2">{{ title }}</span>
<hr class="flex-grow-1"/> <hr class="flex-grow-1"/>
</div> </div>
</li> </li>
@@ -1,7 +1,7 @@
{% load active_link %} {% load active_link %}
<li> <li>
<a href="{% url url %}" <a href="{% url url %}"
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}" class="tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
{% if tooltip %} {% if tooltip %}
data-bs-placement="right" data-bs-placement="right"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
@@ -9,6 +9,6 @@
{% endif %}> {% endif %}>
<i class="{{ icon }} fa-fw"></i> <i class="{{ icon }} fa-fw"></i>
<span <span
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span> class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
</a> </a>
</li> </li>
@@ -2,7 +2,7 @@
<li> <li>
<a href="{{ url }}" <a href="{{ url }}"
hx-boost="false" hx-boost="false"
class="tw:text-wrap tw:lg:text-nowrap tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}" class="tw:lg:text-sm d-flex align-items-center text-decoration-none p-2 rounded-3 sidebar-item {% active_link views=active css_class="sidebar-active" %}"
{% if tooltip %} {% if tooltip %}
data-bs-placement="right" data-bs-placement="right"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
@@ -11,6 +11,6 @@
<i class="{{ icon }} fa-fw"></i> <i class="{{ icon }} fa-fw"></i>
<span <span
class="ms-3 fw-medium tw:lg:invisible tw:lg:group-hover:visible tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span> class="ms-3 fw-medium tw:lg:group-hover:truncate tw:lg:group-focus:truncate tw:lg:group-hover:text-ellipsis tw:lg:group-focus:text-ellipsis">{{ title }}</span>
</a> </a>
</li> </li>
+70 -25
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.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}"> <div
class="col-lg col-12 {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
{# Date#} {# Date#}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div> <div class="col-auto pe-1"><i class="fa-solid fa-calendar fa-fw me-1 fa-xs"></i></div>
@@ -58,14 +61,20 @@
</div> </div>
<div class="tw:text-gray-400 tw:text-sm"> <div class="tw:text-gray-400 tw:text-sm">
{# Entities #} {# Entities #}
{% with transaction.entities.all as entities %} {% comment %} First, check for the highest priority: a valid 'overriden_entities' list. {% endcomment %}
{% if entities %} {% if overriden_entities %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div> <div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ entities|join:", " }}</div> <div class="col ps-0">{{ overriden_entities|join:", " }}</div>
</div>
{% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
{% elif transaction.id and transaction.entities.all %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-user-group fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.entities.all|join:", " }}</div>
</div> </div>
{% endif %} {% endif %}
{% endwith %}
{# Notes#} {# Notes#}
{% if transaction.notes %} {% if transaction.notes %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
@@ -81,17 +90,24 @@
</div> </div>
{% endif %} {% endif %}
{# Tags#} {# Tags#}
{% with transaction.tags.all as tags %} {% comment %} First, check for the highest priority: a valid 'overriden_tags' list. {% endcomment %}
{% if tags %} {% if overriden_tags %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400"> <div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div> <div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ tags|join:", " }}</div> <div class="col ps-0">{{ overriden_tags|join:", " }}</div>
</div>
{% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
{% elif transaction.id and transaction.tags.all %}
<div class="row mb-2 mb-lg-1 tw:text-gray-400">
<div class="col-auto pe-1"><i class="fa-solid fa-hashtag fa-fw me-1 fa-xs"></i></div>
<div class="col ps-0">{{ transaction.tags.all|join:", " }}</div>
</div> </div>
{% endif %} {% endif %}
{% endwith %}
</div> </div>
</div> </div>
<div class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}"> <div
class="col-lg-auto col-12 text-lg-end align-self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}tw:brightness-80{% endif %}">
<div class="main-amount mb-2 mb-lg-0"> <div class="main-amount mb-2 mb-lg-0">
<c-amount.display <c-amount.display
:amount="transaction.amount" :amount="transaction.amount"
@@ -101,6 +117,7 @@
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display> color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
</div> </div>
{# Exchange Rate#} {# Exchange Rate#}
{% if not dummy %}
{% with exchanged=transaction.exchanged_amount %} {% with exchanged=transaction.exchanged_amount %}
{% if exchanged %} {% if exchanged %}
<div class="exchanged-amount mb-2 mb-lg-0"> <div class="exchanged-amount mb-2 mb-lg-0">
@@ -113,10 +130,12 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endif %}
<div> <div>
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }} {% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
</div> </div>
</div> </div>
{% if not dummy %}
<div> <div>
{# Item actions#} {# Item actions#}
<div <div
@@ -142,7 +161,8 @@
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i> _="install prompt_swal"><i class="fa-solid fa-trash fa-fw text-danger"></i>
</a> </a>
<button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-secondary btn-sm transaction-action" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-ellipsis fa-fw"></i> <i class="fa-solid fa-ellipsis fa-fw"></i>
</button> </button>
<ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start"> <ul class="dropdown-menu dropdown-menu-end dropdown-menu-md-start">
@@ -152,7 +172,8 @@
<i class="fa-solid fa-eye fa-fw me-2"></i> <i class="fa-solid fa-eye fa-fw me-2"></i>
<div> <div>
{% translate 'Show on summaries' %} {% translate 'Show on summaries' %}
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div> <div
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by account' %}</div>
</div> </div>
</a> </a>
</li> </li>
@@ -162,22 +183,45 @@
<i class="fa-solid fa-eye fa-fw me-2"></i> <i class="fa-solid fa-eye fa-fw me-2"></i>
<div> <div>
{% translate 'Show on summaries' %} {% translate 'Show on summaries' %}
<div class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div> <div
class="d-block text-body-secondary tw:text-xs tw:font-medium">{% translate 'Controlled by category' %}</div>
</div> </div>
</a> </a>
</li> </li>
{% elif transaction.mute %} {% elif transaction.mute %}
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li> <li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
hx-target="closest .transaction" hx-swap="outerHTML"><i
class="fa-solid fa-eye fa-fw me-2"></i>{% translate 'Show on summaries' %}</a></li>
{% else %} {% else %}
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}" hx-target="closest .transaction" hx-swap="outerHTML"><i class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li> <li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_mute' transaction_id=transaction.id %}"
hx-target="closest .transaction" hx-swap="outerHTML"><i
class="fa-solid fa-eye-slash fa-fw me-2"></i>{% translate 'Hide from summaries' %}</a></li>
{% endif %} {% endif %}
<li><a class="dropdown-item" href="#" hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a></li> <li><a class="dropdown-item" href="#"
<li><hr class="dropdown-divider"></li> hx-get="{% url 'quick_transaction_add_as_quick_transaction' transaction_id=transaction.id %}"><i
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a></li> class="fa-solid fa-person-running fa-fw me-2"></i>{% translate 'Add as quick transaction' %}</a>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li> </li>
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li> <li>
<li><hr class="dropdown-divider"></li> <hr class="dropdown-divider">
<li><a class="dropdown-item" href="#" hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li> </li>
<li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='previous' %}"><i
class="fa-solid fa-calendar-minus fa-fw me-2"></i>{% translate 'Move to previous month' %}</a>
</li>
<li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_change_month' transaction_id=transaction.id change_type='next' %}"><i
class="fa-solid fa-calendar-plus fa-fw me-2"></i>{% translate 'Move to next month' %}</a></li>
<li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_move_to_today' transaction_id=transaction.id %}"><i
class="fa-solid fa-calendar-day fa-fw me-2"></i>{% translate 'Move to today' %}</a></li>
<li>
<hr class="dropdown-divider">
</li>
<li><a class="dropdown-item" href="#"
hx-get="{% url 'transaction_clone' transaction_id=transaction.id %}"><i
class="fa-solid fa-clone fa-fw me-2"></i>{% translate 'Duplicate' %}</a></li>
</ul> </ul>
{% else %} {% else %}
<a class="btn btn-secondary btn-sm transaction-action" <a class="btn btn-secondary btn-sm transaction-action"
@@ -202,6 +246,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -24,6 +24,7 @@
<th scope="col" class="col-auto"></th> <th scope="col" class="col-auto"></th>
<th scope="col" class="col-auto">{% translate 'Code' %}</th> <th scope="col" class="col-auto">{% translate 'Code' %}</th>
<th scope="col" class="col">{% translate 'Name' %}</th> <th scope="col" class="col">{% translate 'Name' %}</th>
<th scope="col" class="col">{% translate 'Archived' %}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -53,6 +54,7 @@
</td> </td>
<td class="col-auto">{{ currency.code }}</td> <td class="col-auto">{{ currency.code }}</td>
<td class="col">{{ currency.name }}</td> <td class="col">{{ currency.name }}</td>
<td class="col">{% if currency.is_archived %}<i class="fa-solid fa-solid fa-check text-success"></i>{% endif %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -12,6 +12,4 @@
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}"> <link rel="icon" type="image/png" sizes="32x32" href="{% static 'img/favicon/favicon-32x32.png' %}">
<link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}"> <link rel="icon" type="image/png" sizes="96x96" href="{% static 'img/favicon/favicon-96x96.png' %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}"> <link rel="icon" type="image/png" sizes="16x16" href="{% static 'img/favicon/favicon-16x16.png' %}">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}"> <meta name="msapplication-TileImage" content="{% static 'img/favicon/ms-icon-144x144.png' %}">
<meta name="theme-color" content="#ffffff">
+45 -22
View File
@@ -5,31 +5,48 @@
{% 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"
hx-swap="transition: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 class="d-none d-lg-flex tw:justify-between tw:items-center tw:border-b tw:border-gray-600 tw:lg:flex">
<a href="{% url 'index' %}" class="m-0 d-none d-lg-flex tw:justify-start p-3 text-decoration-none sidebar-title">
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/> <img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span> <span class="fs-4 fw-bold ms-3">WYGIWYH</span>
</a> </a>
<button
id="sidebar-toggle-btn"
class="text-secondary-emphasis tw:rounded-full tw:w-12 tw:h-12 tw:flex tw:items-center tw:justify-center tw:transition-all tw:duration-300"
hx-get="{% url 'toggle_sidebar_status' %}"
_="on click
toggle .sidebar-floating on #sidebar-container
toggle .tw\:group on #sidebar-container
toggle .sidebar-fixed on #sidebar-container
end
"
>
<i class="fa-solid fa-thumbtack fa-sm"></i>
<i class="fa-solid fa-thumbtack-slash fa-sm"></i>
</button>
</div>
<div class="offcanvas-header"> <div class="offcanvas-header">
<a href="{% url 'index' %}" class="offcanvas-title d-flex tw:justify-start text-decoration-none"> <a href="{% url 'index' %}" class="offcanvas-title d-flex tw:justify-start text-decoration-none">
<img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/> <img src="{% static 'img/logo-icon.svg' %}" alt="WYGIWYH Logo" height="30" width="30" title="WYGIWYH"/>
<span class="fs-4 fw-bold ms-3 tw:lg:invisible tw:lg:group-hover:visible">WYGIWYH</span> <span class="fs-4 fw-bold ms-3">WYGIWYH</span>
</a> </a>
<button type="button" class="btn-close" data-bs-target="#sidebar" data-bs-dismiss="offcanvas" <button type="button" class="btn-close" data-bs-target="#sidebar" data-bs-dismiss="offcanvas"
aria-label={% translate 'Close' %}></button> aria-label={% translate 'Close' %}></button>
</div> </div>
<hr class="m-0"> <hr class="m-0">
<ul class="list-unstyled p-3 d-flex flex-column gap-0 tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]" <ul class="list-unstyled p-3 d-flex flex-column gap-0 tw:text-nowrap tw:lg:group-hover:animate-[disable-pointer-events]"
style="animation-duration: 100ms"> style="animation-duration: 100ms">
<c-components.sidebar-menu-item <c-components.sidebar-menu-item
@@ -127,27 +144,26 @@
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: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: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">
{% translate 'Management' %} {% translate 'Management' %}
</span> </span>
<i class="fa-solid fa-chevron-right fa-fw ms-auto pe-2"></i>
</div> </div>
</ul> </ul>
<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: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 {% 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="show" %}">
<div class="tw:h-dvh tw:backdrop-blur-3xl tw:flex tw:flex-col"> <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 +171,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] tw:flex-1" <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
@@ -273,17 +289,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>
@@ -268,9 +268,10 @@
<!-- Entity rows --> <!-- Entity rows -->
{% if show_entities %} {% if show_entities %}
{% for entity_id, entity in tag.entities.items %} {% 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"> <tr class="table-row-nested-2">
<td class="ps-5"> <td class="ps-5">
<i class="fa-solid fa-user-group fa-fw me-2 text-muted"></i>{{ entity.name }} <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>
<td> <td>
{% for currency in entity.currencies.values %} {% for currency in entity.currencies.values %}
@@ -357,6 +358,7 @@
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
{% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endif %} {% endif %}
+1 -1
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>
@@ -133,48 +133,32 @@
</div> </div>
<div class="col-12 col-xl-8 order-2 order-xl-1"> <div class="col-12 col-xl-8 order-2 order-xl-1">
<div class="row mb-1">
<div class="col-sm-6 col-12"> <div class="my-3">
{# Filter transactions button #} {# Hidden select to hold the order value and preserve the original update trigger #}
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" <select name="order" id="order" class="d-none" _="on change trigger updated on window">
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false" <option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
aria-controls="collapse-filter"> <option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %} <option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
</button>
</div>
{# Ordering button#}
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0">
<div class="text-sm-end" _="on change trigger updated on window">
<label for="order">{% translate "Order by" %}</label>
<select
class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body"
name="order" id="order">
<option value="default"
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
<option value="older"
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
<option value="newer"
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
</select> </select>
</div>
</div> {# Main control bar with filter, search, and ordering #}
</div> <div class="input-group">
{# Filter transactions form#}
<div class="collapse" id="collapse-filter" hx-preserve> <button class="btn btn-secondary position-relative" type="button"
<div class="card card-body"> data-bs-toggle="collapse" data-bs-target="#collapse-filter"
<form _="on change or submit or search trigger updated on window end aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
install init_tom_select title="{% translate 'Filter transactions' %}">
install init_datepicker" <i class="fa-solid fa-filter fa-fw"></i>
id="filter"> </button>
{% crispy filter.form %}
</form> {# Search box #}
<button class="btn btn-outline-danger btn-sm" <label for="quick-search">
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button> </label>
</div> <input type="search"
</div> class="form-control"
<div id="search" class="my-3"> placeholder="{% translate 'Search' %}"
<label class="w-100"> hx-preserve
<input type="search" class="form-control" placeholder="{% translate 'Search' %}" hx-preserve
id="quick-search" id="quick-search"
_="on input or search or htmx:afterSwap from window _="on input or search or htmx:afterSwap from window
if my value is empty if my value is empty
@@ -185,7 +169,65 @@
show <.transactions-divider-title/> when my value is empty show <.transactions-divider-title/> when my value is empty
show <.transaction/> in <#transactions-list/> show <.transaction/> in <#transactions-list/>
when its textContent.toLowerCase() contains my value.toLowerCase()"> when its textContent.toLowerCase() contains my value.toLowerCase()">
</label>
{# Order by icon dropdown #}
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-no-icon" type="button"
data-bs-toggle="dropdown" aria-expanded="false"
title="{% translate 'Order by' %}">
<i class="fa-solid fa-sort fa-fw"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item {% if order == 'default' %}active{% endif %}" type="button"
_="on click remove .active from .dropdown-item in the closest <ul/>
then add .active to me
then set the value of #order to 'default'
then trigger change on #order">
{% translate 'Default' %}
</button>
</li>
<li>
<button class="dropdown-item {% if order == 'older' %}active{% endif %}" type="button"
_="on click remove .active from .dropdown-item in the closest <ul/>
then add .active to me
then set the value of #order to 'older'
then trigger change on #order">
{% translate 'Oldest first' %}
</button>
</li>
<li>
<button class="dropdown-item {% if order == 'newer' %}active{% endif %}" type="button"
_="on click remove .active from .dropdown-item in the closest <ul/>
then add .active to me
then set the value of #order to 'newer'
then trigger change on #order">
{% translate 'Newest first' %}
</button>
</li>
</ul>
</div>
{# Filter transactions form #}
<div class="collapse" id="collapse-filter" hx-preserve>
<div class="card card-body">
<div class="text-end">
<button class="btn btn-outline-danger btn-sm tw:w-fit"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
<form _="on change or submit or search trigger updated on window
install init_tom_select
install init_datepicker"
id="filter" class="mt-3">
{% crispy filter.form %}
</form>
<div class="text-end">
<button class="btn btn-outline-danger btn-sm tw:w-fit"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
</div>
</div>
</div> </div>
{# Transactions list#} {# Transactions list#}
<div id="transactions" <div id="transactions"
+4
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>
@@ -0,0 +1,16 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% trans 'Test' %} - {% trans 'Create' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_rule_dry_run_created' pk=rule.id %}" hx-target="#generic-offcanvas"
hx-indicator="#dry-run-created-result, closest form" class="show-loading" novalidate>
{% crispy form %}
</form>
<hr>
<div id="dry-run-created-result" class="show-loading">
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% trans 'Test' %} - {% trans 'Delete' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_rule_dry_run_deleted' pk=rule.id %}" hx-target="#generic-offcanvas"
hx-indicator="#dry-run-deleted-result, closest form" class="show-loading" novalidate>
{% crispy form %}
</form>
<hr>
<div id="dry-run-deleted-result" class="show-loading">
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
</div>
{% endblock %}
@@ -0,0 +1,16 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% trans 'Test' %} - {% trans 'Update' %}{% endblock %}
{% block body %}
<form hx-post="{% url 'transaction_rule_dry_run_updated' pk=rule.id %}" hx-target="#generic-offcanvas"
hx-indicator="#dry-run-updated-result, closest form" class="show-loading" novalidate>
{% crispy form %}
</form>
<hr>
<div id="dry-run-updated-result" class="show-loading">
{% include 'rules/fragments/transaction_rule/dry_run/visual.html' with logs=logs results=results %}
</div>
{% endblock %}
@@ -0,0 +1,101 @@
{% load i18n %}
<div class="card tw:max-h-full tw:overflow-auto tw:overflow-x-auto">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<button class="nav-link active" id="visual-tab" data-bs-toggle="tab" data-bs-target="#visual-tab-pane"
type="button" role="tab" aria-controls="visual-tab-pane"
aria-selected="true">{% translate 'Visual' %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="logs-tab" data-bs-toggle="tab" data-bs-target="#logs-tab-pane" type="button"
role="tab" aria-controls="logs-tab-pane" aria-selected="false">{% translate 'Logs' %}</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="visual-tab-pane" role="tabpanel" aria-labelledby="home-tab"
tabindex="0">
{% if not results %}
{% translate 'Run a test to see...' %}
{% else %}
{% for result in results %}
{% if result.type == 'header' %}
<div class="my-3">
<h6 class="text-center mb-3">
<span class="badge text-bg-secondary">
{% if result.header_type == "edit_transaction" %}
{% translate 'Edit transaction' %}
{% elif result.header_type == "update_or_create_transaction" %}
{% translate 'Update or create transaction' %}
{% endif %}
</span>
</h6>
</div>
{% endif %}
{% if result.type == 'triggering_transaction' %}
<div class="mt-4">
<h6 class="text-center mb-3"><span class="badge text-bg-secondary">{% translate 'Start' %}</span></h6>
<c-transaction.item :transaction="result.transaction" :dummy="True"
:disable-selection="True"></c-transaction.item>
</div>
{% endif %}
{% if result.type == 'edit_transaction' %}
<div>
<div>
{% translate 'Set' %} <span
class="badge text-bg-secondary">{{ result.field }}</span> {% translate 'to' %}
<span class="badge text-bg-secondary">{{ result.new_value }}</span>
</div>
<c-transaction.item :transaction="result.transaction" :dummy="True"
:disable-selection="True"></c-transaction.item>
</div>
{% endif %}
{% if result.type == 'update_or_create_transaction' %}
<div>
<div class="alert alert-info" role="alert">
{% translate 'Search' %}: {{ result.query }}
</div>
{% if result.start_transaction %}
<c-transaction.item :transaction="result.start_transaction" :dummy="True"
:disable-selection="True"></c-transaction.item>
{% else %}
<div class="alert alert-danger" role="alert">
{% translate 'No transaction found, a new one will be created' %}
</div>
{% endif %}
<div class="text-center h3 my-2"><i class="fa-solid fa-arrow-down"></i></div>
<c-transaction.item :transaction="result.end_transaction" :dummy="True"
:disable-selection="True"></c-transaction.item>
</div>
{% endif %}
{% if result.type == 'error' %}
<div>
<div class="alert alert-{% if result.level == 'error' %}danger{% elif result.level == 'warning' %}warning{% else %}info{% endif %}" role="alert">
{{ result.error }}
</div>
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
<div class="tab-pane fade" id="logs-tab-pane" role="tabpanel" aria-labelledby="logs-tab" tabindex="0">
{% if not logs %}
{% translate 'Run a test to see...' %}
{% else %}
<pre>
{{ logs|linebreaks }}
</pre>
{% endif %}
</div>
</div>
</div>
</div>
@@ -30,21 +30,27 @@
<div class="my-3"> <div class="my-3">
<div class="tw:text-xl mb-2">{% translate 'Then...' %}</div> <div class="tw:text-xl mb-2">{% translate 'Then...' %}</div>
{% for action in transaction_rule.transaction_actions.all %} {% for action in all_actions %}
{% if action.action_type == "edit_transaction" %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span></div> <div>
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
<span class="badge text-bg-primary">{% trans 'Edit transaction' %}</span>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div>{% translate 'Set' %} <span <div>
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}</div> {% translate 'Set' %} <span
class="badge text-bg-secondary">{{ action.get_field_display }}</span> {% translate 'to' %}
</div>
<div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div> <div class="text-bg-secondary rounded-3 mt-3 p-2">{{ action.value }}</div>
</div> </div>
<div class="card-footer text-end"> <div class="card-footer text-end">
<a class="text-decoration-none tw:text-gray-400 p-1" <a class="text-decoration-none tw:text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate 'Edit' %}"
hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}" hx-get="{% url 'transaction_rule_action_edit' transaction_rule_action_id=action.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i> <i class="fa-solid fa-pencil fa-fw"></i>
@@ -52,23 +58,25 @@
<a class="text-danger text-decoration-none p-1" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate 'Delete' %}"
hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}" hx-delete="{% url 'transaction_rule_action_delete' transaction_rule_action_id=action.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate 'Are you sure?' %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i> <i class="fa-solid fa-trash fa-fw"></i>
</a> </a>
</div> </div>
</div> </div>
{% endfor %} {% elif action.action_type == "update_or_create_transaction" %}
{% for action in transaction_rule.update_or_create_transaction_actions.all %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"> <div class="card-header">
<div><span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span></div> <div>
{% if action.order != 0 %}<span class="badge text-bg-secondary">{{ action.order }}</span>{% endif %}
<span class="badge text-bg-primary">{% trans 'Update or create transaction' %}</span>
</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div>{% trans 'Edit to view' %}</div> <div>{% trans 'Edit to view' %}</div>
@@ -77,7 +85,7 @@
<a class="text-decoration-none tw:text-gray-400 p-1" <a class="text-decoration-none tw:text-gray-400 p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Edit" %}" data-bs-title="{% translate 'Edit' %}"
hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}" hx-get="{% url 'update_or_create_transaction_rule_action_edit' pk=action.id %}"
hx-target="#generic-offcanvas"> hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i> <i class="fa-solid fa-pencil fa-fw"></i>
@@ -85,41 +93,66 @@
<a class="text-danger text-decoration-none p-1" <a class="text-danger text-decoration-none p-1"
role="button" role="button"
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-title="{% translate "Delete" %}" data-bs-title="{% translate 'Delete' %}"
hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}" hx-delete="{% url 'update_or_create_transaction_rule_action_delete' pk=action.id %}"
hx-trigger='confirmed' hx-trigger='confirmed'
data-bypass-on-ctrl="true" data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}" data-title="{% translate 'Are you sure?' %}"
data-text="{% translate "You won't be able to revert this!" %}" data-text="{% translate "You won't be able to revert this!" %}"
data-confirm-text="{% translate "Yes, delete it!" %}" data-confirm-text="{% translate 'Yes, delete it!' %}"
_="install prompt_swal"> _="install prompt_swal">
<i class="fa-solid fa-trash fa-fw"></i> <i class="fa-solid fa-trash fa-fw"></i>
</a> </a>
</div> </div>
</div> </div>
{% endfor %} {% endif %}
{% if not transaction_rule.update_or_create_transaction_actions.all and not transaction_rule.transaction_actions.all %} {% empty %}
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
{% translate 'This rule has no actions' %} {% translate 'This rule has no actions' %}
</div> </div>
</div> </div>
{% endif %} {% endfor %}
<hr> <hr>
<div class="dropdown"> <div class="d-grid d-lg-flex gap-2">
<div class="dropdown flex-fill">
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fa-solid fa-flask-vial me-2"></i>{% translate 'Test' %}
</button>
<ul class="dropdown-menu">
{% if transaction_rule.on_create %}
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_dry_run_created' pk=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Create' %}</a></li>
{% endif %}
{% if transaction_rule.on_update %}
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_dry_run_updated' pk=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Update' %}</a></li>
{% endif %}
{% if transaction_rule.on_delete %}
<li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_dry_run_deleted' pk=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Delete' %}</a></li>
{% endif %}
</ul>
</div>
<div class="dropdown flex-fill">
<button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown" <button class="btn btn-outline-primary text-decoration-none w-100" type="button" data-bs-toggle="dropdown"
aria-expanded="false"> aria-expanded="false">
<i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %} <i class="fa-solid fa-circle-plus me-2"></i>{% translate 'Add new' %}
</button> </button>
<ul class="dropdown-menu dropdown-menu-end w-100"> <ul class="dropdown-menu">
<li><a class="dropdown-item" role="link" <li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}" hx-get="{% url 'transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li> hx-target="#generic-offcanvas">{% trans 'Edit Transaction' %}</a></li>
<li><a class="dropdown-item" role="link" <li><a class="dropdown-item" role="link" href="#"
hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}" hx-get="{% url 'update_or_create_transaction_rule_action_add' transaction_rule_id=transaction_rule.id %}"
hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li> hx-target="#generic-offcanvas">{% trans 'Update or Create Transaction' %}</a></li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}
@@ -5,31 +5,38 @@
<div id="transactions-list"> <div id="transactions-list">
{% for x in transactions_by_date %} {% for x in transactions_by_date %}
<div id="{{ x.grouper|slugify }}" <div id="{{ x.grouper|slugify }}" class="transactions-divider"
_="on htmx:afterSettle from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')"> _="on htmx:afterSwap from #transactions if sessionStorage.getItem(my id) is null then sessionStorage.setItem(my id, 'true')">
<div class="mt-3 mb-1 w-100 tw:text-base border-bottom bg-body"> <div class="mt-3 mb-1 w-100 tw:text-base border-bottom bg-body transactions-divider-title">
<a class="text-decoration-none d-inline-block w-100" <a class="text-decoration-none d-inline-block w-100"
role="button" role="button"
data-bs-toggle="collapse" data-bs-toggle="collapse"
data-bs-target="#c-{{ x.grouper|slugify }}-collapse" data-bs-target="#c-{{ x.grouper|slugify }}-collapse"
id="c-{{ x.grouper|slugify }}-collapsible" id="c-{{ x.grouper|slugify }}-collapsible"
aria-expanded="true" aria-expanded="false"
aria-controls="c-{{ x.grouper|slugify }}-collapse"> aria-controls="c-{{ x.grouper|slugify }}-collapse">
{{ x.grouper }} {{ x.grouper }}
</a> </a>
</div> </div>
<div class="collapse" id="c-{{ x.grouper|slugify }}-collapse" <div class="collapse transactions-divider-collapse" id="c-{{ x.grouper|slugify }}-collapse"
_="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true') _="on shown.bs.collapse sessionStorage.setItem(the closest parent @id, 'true')
on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false') on hidden.bs.collapse sessionStorage.setItem(the closest parent @id, 'false')
on htmx:afterSettle from #transactions on htmx:afterSettle from #transactions or toggle
set state to sessionStorage.getItem(the closest parent @id) set state to sessionStorage.getItem(the closest parent @id)
if state is 'true' or state is null if state is 'true' or state is null
add .show to me add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true
end"> else
remove .show from me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to false
end
on show
add .show to me
set @aria-expanded of #c-{{ x.grouper|slugify }}-collapsible to true">
<div class="d-flex flex-column"> <div class="d-flex flex-column">
{% for transaction in x.list %} {% for transaction in x.list %}
<c-transaction.item :transaction="transaction"></c-transaction.item> <c-transaction.item
:transaction="transaction"></c-transaction.item>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -8,45 +8,101 @@
<div class="container px-md-3 py-3 column-gap-5"> <div class="container px-md-3 py-3 column-gap-5">
<div class="row gx-xl-4 gy-3"> <div class="row gx-xl-4 gy-3">
<div class="col-12 col-xl-8 order-2 order-xl-1"> <div class="col-12 col-xl-8 order-2 order-xl-1">
<div class="row mb-1"> <div class="mb-3">
<div class="col-sm-6 col-12"> {# Hidden select to hold the order value and preserve the original update trigger #}
{# Filter transactions button #} <select name="order" id="order" class="d-none" _="on change trigger updated on window">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle" type="button" <option value="default" {% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
data-bs-toggle="collapse" data-bs-target="#collapse-filter" aria-expanded="false" <option value="older" {% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
aria-controls="collapse-filter"> <option value="newer" {% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
<i class="fa-solid fa-filter fa-fw me-2"></i>{% translate 'Filter transactions' %}
</button>
</div>
{# Ordering button#}
<div class="col-sm-6 col-12 tw:content-center my-3 my-sm-0">
<div class="text-sm-end" _="on change trigger updated on window">
<label for="order">{% translate "Order by" %}</label>
<select
class="tw:border-0 tw:focus-visible:outline-0 w-full pe-2 tw:leading-normal text-bg-tertiary tw:font-medium rounded bg-body text-body"
name="order" id="order">
<option value="default"
{% if order == 'default' %}selected{% endif %}>{% translate 'Default' %}</option>
<option value="older"
{% if order == 'older' %}selected{% endif %}>{% translate 'Oldest first' %}</option>
<option value="newer"
{% if order == 'newer' %}selected{% endif %}>{% translate 'Newest first' %}</option>
</select> </select>
{# Main control bar with filter, search, and ordering #}
<div class="input-group">
<button class="btn btn-secondary position-relative" type="button"
data-bs-toggle="collapse" data-bs-target="#collapse-filter"
aria-expanded="false" aria-controls="collapse-filter" id="filter-button" hx-preserve
title="{% translate 'Filter transactions' %}">
<i class="fa-solid fa-filter fa-fw"></i>
</button>
{# Search box #}
<label for="quick-search">
</label>
<input type="search"
class="form-control"
placeholder="{% translate 'Search' %}"
hx-preserve
id="quick-search"
_="on input or search or htmx:afterSwap from window
if my value is empty
trigger toggle on <.transactions-divider-collapse/>
else
trigger show on <.transactions-divider-collapse/>
end
show <.transactions-divider-title/> when my value is empty
show <.transaction/> in <#transactions-list/>
when its textContent.toLowerCase() contains my value.toLowerCase()">
{# Order by icon dropdown #}
<button class="btn btn-secondary dropdown-toggle dropdown-toggle-no-icon" type="button"
data-bs-toggle="dropdown" aria-expanded="false"
title="{% translate 'Order by' %}">
<i class="fa-solid fa-sort fa-fw"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item {% if order == 'default' %}active{% endif %}" type="button"
_="on click remove .active from .dropdown-item in the closest <ul/>
then add .active to me
then set the value of #order to 'default'
then trigger change on #order">
{% translate 'Default' %}
</button>
</li>
<li>
<button class="dropdown-item {% if order == 'older' %}active{% endif %}" type="button"
_="on click remove .active from .dropdown-item in the closest <ul/>
then add .active to me
then set the value of #order to 'older'
then trigger change on #order">
{% translate 'Oldest first' %}
</button>
</li>
<li>
<button class="dropdown-item {% if order == 'newer' %}active{% endif %}" type="button"
_="on click remove .active from .dropdown-item in the closest <ul/>
then add .active to me
then set the value of #order to 'newer'
then trigger change on #order">
{% translate 'Newest first' %}
</button>
</li>
</ul>
</div> </div>
</div>
</div>
{# Filter transactions form #} {# Filter transactions form #}
<div class="collapse" id="collapse-filter"> <div class="collapse" id="collapse-filter" hx-preserve>
<div class="card card-body"> <div class="card card-body">
<form _="on change or submit or search trigger updated on window end <div class="text-end">
<button class="btn btn-outline-danger btn-sm tw:w-fit"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div>
<form _="on change or submit or search trigger updated on window
install init_tom_select install init_tom_select
install init_datepicker" install init_datepicker"
id="filter"> id="filter" class="mt-3">
{% crispy filter.form %} {% crispy filter.form %}
</form> </form>
<button class="btn btn-outline-danger btn-sm"
<div class="text-end">
<button class="btn btn-outline-danger btn-sm tw:w-fit"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button> _="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button>
</div> </div>
</div> </div>
</div>
</div>
<div id="transactions" <div id="transactions"
class="show-loading" class="show-loading"
hx-get="{% url 'transactions_all_list' %}" hx-get="{% url 'transactions_all_list' %}"
+1 -1
View File
@@ -15,7 +15,7 @@ services:
- ./frontend/:/usr/src/frontend:z - ./frontend/:/usr/src/frontend:z
- wygiwyh_temp:/usr/src/app/temp/ - wygiwyh_temp:/usr/src/app/temp/
ports: ports:
- "${OUTBOUND_PORT}:8000" - "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file: env_file:
- .env - .env
depends_on: depends_on:
+1 -1
View File
@@ -4,7 +4,7 @@ services:
container_name: ${SERVER_NAME} container_name: ${SERVER_NAME}
command: /start-single command: /start-single
ports: ports:
- "${OUTBOUND_PORT}:8000" - "${OUTBOUND_PORT:-8000}:${INTERNAL_PORT:-8000}"
env_file: env_file:
- .env - .env
depends_on: depends_on:
+4 -1
View File
@@ -4,6 +4,9 @@ set -o errexit
set -o pipefail set -o pipefail
set -o nounset set -o nounset
# Set INTERNAL_PORT with default value of 8000
INTERNAL_PORT=${INTERNAL_PORT:-8000}
rm -f /tmp/migrations_complete rm -f /tmp/migrations_complete
python manage.py migrate python manage.py migrate
@@ -13,4 +16,4 @@ touch /tmp/migrations_complete
python manage.py setup_users python manage.py setup_users
exec python manage.py runserver 0.0.0.0:8000 exec python manage.py runserver 0.0.0.0:$INTERNAL_PORT
+4 -1
View File
@@ -4,6 +4,9 @@ set -o errexit
set -o pipefail set -o pipefail
set -o nounset set -o nounset
# Set INTERNAL_PORT with default value of 8000
INTERNAL_PORT=${INTERNAL_PORT:-8000}
# Remove flag file if it exists from previous run # Remove flag file if it exists from previous run
rm -f /tmp/migrations_complete rm -f /tmp/migrations_complete
@@ -15,4 +18,4 @@ touch /tmp/migrations_complete
python manage.py setup_users python manage.py setup_users
exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:8000 --timeout 600 exec gunicorn WYGIWYH.wsgi:application --bind 0.0.0.0:$INTERNAL_PORT --timeout 600
+4
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;
}
+130
View File
@@ -9,3 +9,133 @@
.sidebar-item:not(.sidebar-active) { .sidebar-item:not(.sidebar-active) {
@apply tw:text-gray-300 tw:hover:text-white; @apply tw:text-gray-300 tw:hover:text-white;
} }
@layer components {
.sidebar {
@apply tw:z-1020 tw:fixed tw:top-0 tw:start-0 tw:h-full tw:transition-all tw:duration-100;
}
.sidebar-floating {
/* Establishes the hover group and sets the collapsed/hover widths for the container */
@apply tw:lg:w-16 tw:lg:hover:w-112;
}
.sidebar-floating #sidebar {
/* Sets the collapsed/hover widths for the inner navigation element */
@apply tw:lg:w-16 tw:lg:group-hover:w-104 tw:transition-all tw:duration-100 tw:overflow-hidden;
}
.sidebar-floating + main {
/* Adjusts the main content margin to account for the collapsed sidebar */
@apply tw:lg:ml-16 tw:transition-all tw:duration-100;
}
.sidebar-floating .sidebar-item span {
/* Hides the text labels and reveals them only on hover */
@apply tw:lg:invisible tw:lg:group-hover:visible;
}
.sidebar-floating .sidebar-invisible {
/* Hides the text labels and reveals them only on hover */
@apply tw:lg:invisible tw:lg:group-hover:visible;
}
.sidebar-floating .sidebar-menu-header {
/* Hides the menu headers and reveals them only on hover */
@apply tw:lg:hidden tw:lg:group-hover:inline;
}
.sidebar-floating #sidebar-toggle-btn .fa-thumbtack-slash {
/* Hides the 'pin' icon in the floating state */
@apply tw:hidden!;
}
.sidebar-floating #sidebar-toggle-btn .fa-thumbtack {
/* Shows the 'expand' icon in the floating state */
@apply tw:inline-block!;
}
.sidebar-floating .sidebar-title span {
@apply tw:lg:invisible tw:lg:group-hover:visible
}
.sidebar-submenu-header {
@apply tw:flex;
}
.sidebar-floating .sidebar-submenu-header {
@apply tw:lg:hidden tw:lg:group-hover:flex;
}
.sidebar-floating .sidebar-submenu-header h5 {
@apply tw:lg:invisible tw:lg:group-hover:visible;
}
.sidebar-floating .sidebar-submenu-header button {
@apply tw:lg:hidden tw:lg:group-hover:inline;
}
.sidebar-floating .list-unstyled {
@apply tw:group-hover:lg:overflow-y-auto tw:lg:overflow-y-hidden tw:overflow-y-auto tw:overflow-x-hidden;
}
.sidebar-floating .sidebar-item {
@apply tw:text-wrap tw:lg:text-nowrap ;
}
/* --- STATE 2: Fixed (Permanently Expanded) --- */
.sidebar-fixed {
/* Sets the fixed, expanded width for the container */
@apply tw:lg:w-[17%] tw:transition-all tw:duration-100;
}
.sidebar-fixed #sidebar {
/* Sets the fixed, expanded width for the inner navigation */
@apply tw:lg:w-[17%] tw:transition-all tw:duration-100;
}
.sidebar-fixed + main {
/* Adjusts the main content margin to account for the expanded sidebar */
@apply tw:lg:ml-[17%] tw:transition-all tw:duration-100;
/* Using 16vw to account for padding/margins */
}
.sidebar-fixed .sidebar-item {
@apply tw:text-wrap;
}
.sidebar-fixed .sidebar-item span {
/* Ensures text labels are always visible */
@apply tw:lg:visible;
}
.sidebar-fixed .sidebar-menu-header {
/* Ensures menu headers are always visible */
@apply tw:lg:inline;
}
.sidebar-fixed #sidebar-toggle-btn .fa-thumbtack-slash {
/* Shows the 'pin' icon in the fixed state */
@apply tw:inline-block!;
}
.sidebar-fixed #sidebar-toggle-btn .fa-thumbtack {
/* Hides the 'expand' icon in the fixed state */
@apply tw:hidden!;
}
.sidebar-fixed .sidebar-title span {
@apply tw:lg:visible;
}
.sidebar-fixed .sidebar-submenu-header {
/* Ensures menu headers are always visible */
@apply tw:lg:flex;
}
.sidebar-fixed .list-unstyled {
@apply tw:overflow-y-auto tw:overflow-x-hidden;
}
}