diff --git a/app/apps/monthly_overview/views.py b/app/apps/monthly_overview/views.py index c4a4c81..4d7f49c 100644 --- a/app/apps/monthly_overview/views.py +++ b/app/apps/monthly_overview/views.py @@ -14,7 +14,7 @@ from apps.monthly_overview.utils.daily_spending_allowance import ( calculate_daily_allowance_currency, ) from apps.transactions.filters import TransactionsFilter -from apps.transactions.models import Transaction +from apps.transactions.models import FilterPreset, Transaction from apps.transactions.utils.calculations import ( calculate_currency_totals, calculate_percentage_distribution, @@ -58,6 +58,8 @@ def monthly_overview(request, month: int, year: int): "previous_month": previous_month, "previous_year": previous_year, "filter": f, + "filter_is_active": f.has_active_filters, + "filter_presets": FilterPreset.objects.filter(owner=request.user), "order": order, "summary_tab": summary_tab, }, diff --git a/app/apps/transactions/filters.py b/app/apps/transactions/filters.py index d21f3d0..f25a5dd 100644 --- a/app/apps/transactions/filters.py +++ b/app/apps/transactions/filters.py @@ -41,6 +41,12 @@ class MonthYearFilter(Filter): class TransactionsFilter(django_filters.FilterSet): + default_filter_values = { + "type": {"IN", "EX"}, + "is_paid": {"1", "0"}, + "mute_status": {"active", "muted"}, + } + description = django_filters.CharFilter( label=_("Content"), method=content_filter, @@ -217,6 +223,23 @@ class TransactionsFilter(django_filters.FilterSet): ] self.form.fields["entities"].choices = custom_entity_choices + entity_choices + @property + def has_active_filters(self): + for name in self.base_filters: + if hasattr(self.form.data, "getlist"): + values = self.form.data.getlist(name) + else: + value = self.form.data.get(name) + values = value if isinstance(value, (list, tuple)) else [value] + + values = [str(value) for value in values if value not in (None, "")] + if not values: + continue + if set(values) == self.default_filter_values.get(name): + continue + return True + return False + @staticmethod def filter_category(queryset, name, value): if not value: diff --git a/app/apps/transactions/migrations/0050_filterpreset.py b/app/apps/transactions/migrations/0050_filterpreset.py new file mode 100644 index 0000000..cc8c396 --- /dev/null +++ b/app/apps/transactions/migrations/0050_filterpreset.py @@ -0,0 +1,38 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("transactions", "0049_transactionattachment"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FilterPreset", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("parameters", models.JSONField(default=dict)), + ( + "owner", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="filter_presets", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={"ordering": ["name", "id"]}, + ), + ] diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 8399e3a..f462a35 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -30,12 +30,27 @@ from django.utils.translation import gettext_lazy as _ logger = logging.getLogger() - transaction_created = Signal() transaction_updated = Signal() transaction_deleted = Signal() +class FilterPreset(models.Model): + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="filter_presets", + ) + name = models.CharField(max_length=100) + parameters = models.JSONField(default=dict) + + class Meta: + ordering = ["name", "id"] + + def __str__(self): + return self.name + + def transaction_attachment_path(instance, filename): extension = Path(filename).suffix return f"transaction_attachments/{instance.transaction_id}/{instance.id}{extension}" @@ -535,6 +550,7 @@ class Transaction(OwnedObject): return new_obj + class TransactionAttachment(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) transaction = models.ForeignKey( @@ -590,6 +606,7 @@ def delete_transaction_attachment_file(sender, instance, **kwargs): if storage.exists(instance.file.name): storage.delete(instance.file.name) + class InstallmentPlan(models.Model): class Recurrence(models.TextChoices): YEARLY = "yearly", _("Yearly") diff --git a/app/apps/transactions/tests/test_filter_presets.py b/app/apps/transactions/tests/test_filter_presets.py new file mode 100644 index 0000000..5742d85 --- /dev/null +++ b/app/apps/transactions/tests/test_filter_presets.py @@ -0,0 +1,183 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse + +from apps.transactions.models import FilterPreset + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, +) +class FilterPresetViewTests(TestCase): + def setUp(self): + user_model = get_user_model() + self.user = user_model.objects.create_user( + email="preset-owner@example.com", password="testpass123" + ) + self.other_user = user_model.objects.create_user( + email="other-user@example.com", password="testpass123" + ) + self.client.force_login(self.user) + self.preset = FilterPreset.objects.create( + owner=self.user, + name="Unpaid", + parameters={"is_paid": ["0"], "type": ["IN", "EX"]}, + ) + + def test_create_stores_only_transaction_filter_fields(self): + response = self.client.post( + reverse("filter_preset_create"), + { + "name": "Account X Unpaid", + "account": ["Account X"], + "is_paid": ["0"], + "order": "newer", + }, + HTTP_HX_REQUEST="true", + ) + + preset = FilterPreset.objects.get( + owner=self.user, name="Account X Unpaid" + ) + self.assertEqual( + preset.parameters, + {"account": ["Account X"], "is_paid": ["0"]}, + ) + self.assertContains(response, "Account X Unpaid") + + def test_create_rejects_a_blank_name(self): + response = self.client.post( + reverse("filter_preset_create"), + {"name": " ", "is_paid": ["0"]}, + HTTP_HX_REQUEST="true", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(FilterPreset.objects.filter(owner=self.user).count(), 1) + + def test_apply_returns_the_saved_filter_form_without_changing_the_url(self): + response = self.client.get( + reverse("filter_preset_apply", args=[self.preset.pk]), + HTTP_HX_REQUEST="true", + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="filter"') + self.assertEqual(response.context["filter"].data.getlist("is_paid"), ["0"]) + self.assertEqual( + response.context["filter"].data.getlist("type"), ["IN", "EX"] + ) + self.assertNotIn("HX-Push-Url", response.headers) + self.assertNotIn("HX-Replace-Url", response.headers) + self.assertIs(response.context.get("filter_is_active"), True) + self.assertContains(response, 'hx-swap-oob="outerHTML"') + self.assertEqual( + response.headers["HX-Trigger-After-Settle"], + "updated", + ) + + def test_clear_returns_the_default_filter_form_without_changing_the_url(self): + response = self.client.get( + "/transactions/filter/clear/", + HTTP_HX_REQUEST="true", + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'id="filter"') + self.assertEqual( + response.context["filter"].data.getlist("type"), ["IN", "EX"] + ) + self.assertEqual( + response.context["filter"].data.getlist("is_paid"), ["1", "0"] + ) + self.assertNotIn("HX-Push-Url", response.headers) + self.assertNotIn("HX-Replace-Url", response.headers) + self.assertIs(response.context.get("filter_is_active"), False) + self.assertContains(response, 'hx-swap-oob="outerHTML"') + self.assertEqual( + response.headers["HX-Trigger-After-Settle"], + "updated", + ) + + def test_other_users_cannot_apply_or_delete_a_preset(self): + self.client.force_login(self.other_user) + + apply_response = self.client.get( + reverse("filter_preset_apply", args=[self.preset.pk]), + HTTP_HX_REQUEST="true", + ) + delete_response = self.client.post( + reverse("filter_preset_delete", args=[self.preset.pk]), + HTTP_HX_REQUEST="true", + ) + + self.assertEqual(apply_response.status_code, 404) + self.assertEqual(delete_response.status_code, 404) + self.assertTrue(FilterPreset.objects.filter(pk=self.preset.pk).exists()) + + def test_delete_removes_the_current_users_preset(self): + response = self.client.post( + reverse("filter_preset_delete", args=[self.preset.pk]), + HTTP_HX_REQUEST="true", + ) + + self.assertEqual(response.status_code, 200) + self.assertFalse(FilterPreset.objects.filter(pk=self.preset.pk).exists()) + self.assertNotContains(response, self.preset.name) + + def test_all_transactions_page_only_offers_the_current_users_presets(self): + FilterPreset.objects.create( + owner=self.other_user, + name="Other User Preset", + parameters={"type": ["IN"]}, + ) + + response = self.client.get(reverse("transactions_all_index")) + + self.assertContains(response, self.preset.name) + self.assertNotContains(response, "Other User Preset") + self.assertContains(response, 'id="filter-presets"') + self.assertNotContains(response, 'href="./?') + self.assertContains( + response, + reverse("filter_preset_apply", args=[self.preset.pk]), + ) + + def test_all_transactions_page_marks_a_filtered_query_active(self): + response = self.client.get( + reverse("transactions_all_index"), + {"type": ["IN", "EX"], "is_paid": ["0"]}, + ) + + self.assertIs(response.context["filter_is_active"], True) + + def test_all_transactions_page_does_not_mark_default_query_active(self): + response = self.client.get( + reverse("transactions_all_index"), + { + "type": ["IN", "EX"], + "is_paid": ["1", "0"], + "mute_status": ["active", "muted"], + }, + ) + + self.assertIs(response.context["filter_is_active"], False) + + def test_monthly_page_renders_preset_controls(self): + other_preset = FilterPreset.objects.create( + owner=self.other_user, + name="Other Monthly Preset", + parameters={"type": ["IN"]}, + ) + response = self.client.get(reverse("monthly_overview", args=[8, 2026])) + + self.assertContains(response, 'id="filter-presets"') + self.assertNotContains(response, 'href="./?') + self.assertContains(response, reverse("filter_preset_create")) + self.assertNotContains(response, other_preset.name) diff --git a/app/apps/transactions/urls.py b/app/apps/transactions/urls.py index 0ef9c1d..bd9444c 100644 --- a/app/apps/transactions/urls.py +++ b/app/apps/transactions/urls.py @@ -6,6 +6,26 @@ urlpatterns = [ path( "transactions/list/", views.transaction_all_list, name="transactions_all_list" ), + path( + "transactions/filter-presets/create/", + views.filter_preset_create, + name="filter_preset_create", + ), + path( + "transactions/filter-presets//apply/", + views.filter_preset_apply, + name="filter_preset_apply", + ), + path( + "transactions/filter-presets//delete/", + views.filter_preset_delete, + name="filter_preset_delete", + ), + path( + "transactions/filter/clear/", + views.transaction_filter_clear, + name="transaction_filter_clear", + ), path( "transactions/trash/", views.transactions_trash_can_index, diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index 5ff95b4..dd1e0ec 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -11,7 +11,7 @@ from apps.transactions.forms import ( TransactionForm, TransferForm, ) -from apps.transactions.models import Transaction, TransactionAttachment +from apps.transactions.models import FilterPreset, Transaction, TransactionAttachment from apps.transactions.utils.calculations import ( calculate_account_totals, calculate_currency_totals, @@ -23,7 +23,7 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator from django.db.models import Case, IntegerField, Q, Value, When -from django.http import FileResponse, Http404, HttpResponse, JsonResponse +from django.http import FileResponse, Http404, HttpResponse, JsonResponse, QueryDict from django.shortcuts import get_object_or_404, render from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -635,7 +635,92 @@ def transaction_all_index(request): return render( request, "transactions/pages/transactions.html", - {"filter": f, "order": order, "summary_tab": summary_tab}, + { + "filter": f, + "filter_is_active": f.has_active_filters, + "filter_presets": FilterPreset.objects.filter(owner=request.user), + "order": order, + "summary_tab": summary_tab, + }, + ) + + +@only_htmx +@login_required +@require_http_methods(["POST"]) +def filter_preset_create(request): + name = request.POST.get("name", "").strip() + if not name or len(name) > 100: + return HttpResponse(status=400) + + parameters = { + key: request.POST.getlist(key) + for key in TransactionsFilter.base_filters + if key in request.POST + } + FilterPreset.objects.create( + owner=request.user, + name=name, + parameters=parameters, + ) + return render( + request, + "transactions/fragments/filter_presets.html", + {"filter_presets": FilterPreset.objects.filter(owner=request.user)}, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def filter_preset_apply(request, preset_id): + preset = get_object_or_404(FilterPreset, pk=preset_id, owner=request.user) + data = QueryDict(mutable=True) + for key, values in preset.parameters.items(): + if key in TransactionsFilter.base_filters: + data.setlist(key, values) + + transaction_filter = TransactionsFilter(data) + response = render( + request, + "transactions/fragments/filter_form.html", + { + "filter": transaction_filter, + "filter_is_active": transaction_filter.has_active_filters, + "swap_filter_indicator": True, + }, + ) + response.headers["HX-Trigger-After-Settle"] = "updated" + return response + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def transaction_filter_clear(request): + transaction_filter = TransactionsFilter(QueryDict()) + response = render( + request, + "transactions/fragments/filter_form.html", + { + "filter": transaction_filter, + "filter_is_active": False, + "swap_filter_indicator": True, + }, + ) + response.headers["HX-Trigger-After-Settle"] = "updated" + return response + + +@only_htmx +@login_required +@require_http_methods(["POST"]) +def filter_preset_delete(request, preset_id): + get_object_or_404(FilterPreset, pk=preset_id, owner=request.user).delete() + return render( + request, + "transactions/fragments/filter_presets.html", + {"filter_presets": FilterPreset.objects.filter(owner=request.user)}, ) diff --git a/app/templates/monthly_overview/pages/overview.html b/app/templates/monthly_overview/pages/overview.html index 497617c..77d782d 100644 --- a/app/templates/monthly_overview/pages/overview.html +++ b/app/templates/monthly_overview/pages/overview.html @@ -107,7 +107,7 @@ @click="filterOpen = !filterOpen" :aria-expanded="filterOpen" id="filter-button" title="{% translate 'Filter transactions' %}" - _="on load or change from #filter + _="on change from #filter-container -- Check if any filter has a non-default value set hasActiveFilter to false @@ -208,7 +208,7 @@ add .hidden to #filter-active-indicator end"> - + {# Search box #} @@ -228,6 +228,8 @@ show <.transaction/> in <#transactions-list/> when its textContent.toLowerCase() contains my value.toLowerCase()"> + {% include "transactions/fragments/filter_presets.html" %} + {# Order by icon dropdown #}
-
+ {% include "transactions/fragments/filter_actions.html" %} -
- {% crispy filter.form %} -
+ {% include "transactions/fragments/filter_form.html" %}
- + {% translate 'Clear' %}
diff --git a/app/templates/transactions/fragments/filter_actions.html b/app/templates/transactions/fragments/filter_actions.html new file mode 100644 index 0000000..b8463a9 --- /dev/null +++ b/app/templates/transactions/fragments/filter_actions.html @@ -0,0 +1,50 @@ +{% load i18n %} +{% translate "Save filter preset" as save_filter_preset_title %} +{% translate "Preset name" as preset_name_label %} +{% translate "Cancel" as cancel_label %} +{% translate "Save" as save_label %} + +
+
+ + +
+ + {% translate 'Clear' %} + +
diff --git a/app/templates/transactions/fragments/filter_form.html b/app/templates/transactions/fragments/filter_form.html new file mode 100644 index 0000000..be1bc17 --- /dev/null +++ b/app/templates/transactions/fragments/filter_form.html @@ -0,0 +1,18 @@ +{% load crispy_forms_tags %} + +
+ {% crispy filter.form %} +
+{% if swap_filter_indicator %} + +{% endif %} diff --git a/app/templates/transactions/fragments/filter_presets.html b/app/templates/transactions/fragments/filter_presets.html new file mode 100644 index 0000000..685be40 --- /dev/null +++ b/app/templates/transactions/fragments/filter_presets.html @@ -0,0 +1,37 @@ +{% load i18n %} + +
+ + +
diff --git a/app/templates/transactions/pages/transactions.html b/app/templates/transactions/pages/transactions.html index 2d2500e..282a8f8 100644 --- a/app/templates/transactions/pages/transactions.html +++ b/app/templates/transactions/pages/transactions.html @@ -56,7 +56,7 @@ @click="filterOpen = !filterOpen" :aria-expanded="filterOpen" id="filter-button" title="{% translate 'Filter transactions' %}" - _="on load or change from #filter + _="on change from #filter-container -- Check if any filter has a non-default value set hasActiveFilter to false @@ -157,7 +157,7 @@ add .hidden to #filter-active-indicator end"> - + {# Search box #} @@ -177,7 +177,8 @@ show <.transaction/> in <#transactions-list/> when its textContent.toLowerCase() contains my value.toLowerCase()"> - {# Order by icon dropdown #} + {% include "transactions/fragments/filter_presets.html" %} + {# Order by icon dropdown #}
-
+ {% include "transactions/fragments/filter_actions.html" %} -
- {% crispy filter.form %} -
+ {% include "transactions/fragments/filter_form.html" %}
- + {% translate 'Clear' %}