feat(transactions): add htmx filter presets

This commit is contained in:
Herculino Trotta
2026-08-16 01:40:11 -03:00
parent 9e135560fe
commit cd48edddab
10 changed files with 444 additions and 33 deletions
+3 -1
View File
@@ -14,7 +14,7 @@ from apps.monthly_overview.utils.daily_spending_allowance import (
calculate_daily_allowance_currency, calculate_daily_allowance_currency,
) )
from apps.transactions.filters import TransactionsFilter 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 ( from apps.transactions.utils.calculations import (
calculate_currency_totals, calculate_currency_totals,
calculate_percentage_distribution, calculate_percentage_distribution,
@@ -58,6 +58,8 @@ def monthly_overview(request, month: int, year: int):
"previous_month": previous_month, "previous_month": previous_month,
"previous_year": previous_year, "previous_year": previous_year,
"filter": f, "filter": f,
"filter_is_active": f.has_active_filters,
"filter_presets": FilterPreset.objects.filter(owner=request.user),
"order": order, "order": order,
"summary_tab": summary_tab, "summary_tab": summary_tab,
}, },
+23
View File
@@ -41,6 +41,12 @@ class MonthYearFilter(Filter):
class TransactionsFilter(django_filters.FilterSet): class TransactionsFilter(django_filters.FilterSet):
default_filter_values = {
"type": {"IN", "EX"},
"is_paid": {"1", "0"},
"mute_status": {"active", "muted"},
}
description = django_filters.CharFilter( description = django_filters.CharFilter(
label=_("Content"), label=_("Content"),
method=content_filter, method=content_filter,
@@ -217,6 +223,23 @@ class TransactionsFilter(django_filters.FilterSet):
] ]
self.form.fields["entities"].choices = custom_entity_choices + entity_choices 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 @staticmethod
def filter_category(queryset, name, value): def filter_category(queryset, name, value):
if not value: if not value:
@@ -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)
+20
View File
@@ -6,6 +6,26 @@ urlpatterns = [
path( path(
"transactions/list/", views.transaction_all_list, name="transactions_all_list" "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/<int:preset_id>/apply/",
views.filter_preset_apply,
name="filter_preset_apply",
),
path(
"transactions/filter-presets/<int:preset_id>/delete/",
views.filter_preset_delete,
name="filter_preset_delete",
),
path(
"transactions/filter/clear/",
views.transaction_filter_clear,
name="transaction_filter_clear",
),
path( path(
"transactions/trash/", "transactions/trash/",
views.transactions_trash_can_index, views.transactions_trash_can_index,
+88 -3
View File
@@ -11,7 +11,7 @@ from apps.transactions.forms import (
TransactionForm, TransactionForm,
TransferForm, TransferForm,
) )
from apps.transactions.models import Transaction, TransactionAttachment from apps.transactions.models import FilterPreset, Transaction, TransactionAttachment
from apps.transactions.utils.calculations import ( from apps.transactions.utils.calculations import (
calculate_account_totals, calculate_account_totals,
calculate_currency_totals, calculate_currency_totals,
@@ -23,7 +23,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Case, IntegerField, Q, Value, When 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.shortcuts import get_object_or_404, render
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -635,7 +635,92 @@ def transaction_all_index(request):
return render( return render(
request, request,
"transactions/pages/transactions.html", "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)},
) )
@@ -107,7 +107,7 @@
@click="filterOpen = !filterOpen" @click="filterOpen = !filterOpen"
:aria-expanded="filterOpen" id="filter-button" :aria-expanded="filterOpen" id="filter-button"
title="{% translate 'Filter transactions' %}" title="{% translate 'Filter transactions' %}"
_="on load or change from #filter _="on change from #filter-container
-- Check if any filter has a non-default value -- Check if any filter has a non-default value
set hasActiveFilter to false set hasActiveFilter to false
@@ -208,7 +208,7 @@
add .hidden to #filter-active-indicator add .hidden to #filter-active-indicator
end"> end">
<i class="fa-solid fa-filter fa-fw"></i> <i class="fa-solid fa-filter fa-fw"></i>
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full hidden z-10"></span> <span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full {% if not filter_is_active %}hidden {% endif %}z-10"></span>
</button> </button>
{# Search box #} {# Search box #}
@@ -228,6 +228,8 @@
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()">
{% include "transactions/fragments/filter_presets.html" %}
{# Order by icon dropdown #} {# Order by icon dropdown #}
<div> <div>
<button class="btn btn-secondary join-item" type="button" <button class="btn btn-secondary join-item" type="button"
@@ -270,21 +272,16 @@
{# Filter transactions form #} {# Filter transactions form #}
<div class="z-1" x-show="filterOpen" x-collapse x-cloak> <div class="z-1" x-show="filterOpen" x-collapse x-cloak>
<div class="card card-body bg-base-200 mt-2"> <div class="card card-body bg-base-200 mt-2">
<div class="text-right"> {% include "transactions/fragments/filter_actions.html" %}
<button class="btn btn-outline btn-error btn-sm 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 {% include "transactions/fragments/filter_form.html" %}
install init_tom_select
install init_datepicker"
id="filter" class="mt-3">
{% crispy filter.form %}
</form>
<div class="text-right"> <div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit" <a class="btn btn-outline btn-error btn-sm w-fit"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button> href="{{ request.path }}"
hx-get="{% url 'transaction_filter_clear' %}"
hx-target="#filter"
hx-swap="outerHTML">{% translate 'Clear' %}</a>
</div> </div>
</div> </div>
</div> </div>
@@ -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 %}
<div class="flex justify-end gap-2">
<form id="save-filter-preset"
hx-post="{% url 'filter_preset_create' %}"
hx-include="#filter"
hx-target="#filter-presets"
hx-swap="outerHTML">
<input id="filter-preset-name" type="hidden" name="name">
<button class="btn btn-outline btn-primary btn-sm" type="button"
data-title="{{ save_filter_preset_title }}"
data-input-label="{{ preset_name_label }}"
data-cancel-text="{{ cancel_label }}"
data-confirm-text="{{ save_label }}"
_="on click
call Swal.fire({title: @data-title,
input: 'text',
inputLabel: @data-input-label,
inputAttributes: {maxlength: '100'},
showCancelButton: true,
cancelButtonText: @data-cancel-text,
confirmButtonText: @data-confirm-text,
customClass: {
confirmButton: 'btn btn-primary me-3',
cancelButton: 'btn btn-error'
},
buttonsStyling: false})
if result.isConfirmed
set presetName to result.value.trim()
if presetName is not ''
set #filter-preset-name.value to presetName
trigger submit on #save-filter-preset
end
end">
<i class="fa-solid fa-bookmark fa-fw"></i>
{% translate 'Save preset' %}
</button>
</form>
<a class="btn btn-outline btn-error btn-sm"
href="{{ request.path }}"
hx-get="{% url 'transaction_filter_clear' %}"
hx-target="#filter"
hx-swap="outerHTML">
{% translate 'Clear' %}
</a>
</div>
@@ -0,0 +1,18 @@
{% load crispy_forms_tags %}
<form _="on change or submit or search
if event.type is 'submit'
halt the event
end
trigger updated on window
end
install init_tom_select
install init_datepicker"
id="filter" class="mt-3">
{% crispy filter.form %}
</form>
{% if swap_filter_indicator %}
<span id="filter-active-indicator"
class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full {% if not filter_is_active %}hidden {% endif %}z-10"
hx-swap-oob="outerHTML"></span>
{% endif %}
@@ -0,0 +1,37 @@
{% load i18n %}
<div id="filter-presets">
<button class="btn btn-secondary join-item" type="button"
data-bs-toggle="dropdown" aria-expanded="false"
title="{% translate 'Filter presets' %}">
<i class="fa-solid fa-bookmark fa-fw"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end menu min-w-52">
{% for preset in filter_presets %}
<li class="group flex-row items-center">
<button class="grow justify-start"
type="button"
hx-get="{% url 'filter_preset_apply' preset.id %}"
hx-target="#filter"
hx-swap="outerHTML">
{{ preset.name }}
</button>
<button class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 focus:opacity-100"
type="button"
aria-label="{% blocktranslate with name=preset.name %}Delete {{ name }}{% endblocktranslate %}"
hx-post="{% url 'filter_preset_delete' preset.id %}"
hx-trigger="confirmed"
hx-target="#filter-presets"
hx-swap="outerHTML"
data-title="{% translate 'Delete filter preset?' %}"
data-text="{% blocktranslate with name=preset.name %}Delete “{{ name }}”?{% endblocktranslate %}"
data-confirm-text="{% translate 'Delete' %}"
_="install prompt_swal">
<i class="fa-solid fa-xmark fa-fw"></i>
</button>
</li>
{% empty %}
<li class="menu-disabled"><span>{% translate 'No saved presets' %}</span></li>
{% endfor %}
</ul>
</div>
@@ -56,7 +56,7 @@
@click="filterOpen = !filterOpen" @click="filterOpen = !filterOpen"
:aria-expanded="filterOpen" id="filter-button" :aria-expanded="filterOpen" id="filter-button"
title="{% translate 'Filter transactions' %}" title="{% translate 'Filter transactions' %}"
_="on load or change from #filter _="on change from #filter-container
-- Check if any filter has a non-default value -- Check if any filter has a non-default value
set hasActiveFilter to false set hasActiveFilter to false
@@ -157,7 +157,7 @@
add .hidden to #filter-active-indicator add .hidden to #filter-active-indicator
end"> end">
<i class="fa-solid fa-filter fa-fw"></i> <i class="fa-solid fa-filter fa-fw"></i>
<span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full hidden z-10"></span> <span id="filter-active-indicator" class="absolute -top-1 -right-1 w-3 h-3 bg-error rounded-full {% if not filter_is_active %}hidden {% endif %}z-10"></span>
</button> </button>
{# Search box #} {# Search box #}
@@ -177,7 +177,8 @@
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()">
{# Order by icon dropdown #} {% include "transactions/fragments/filter_presets.html" %}
{# Order by icon dropdown #} {# Order by icon dropdown #}
<div> <div>
<button class="btn btn-secondary join-item" type="button" <button class="btn btn-secondary join-item" type="button"
@@ -220,21 +221,16 @@
{# Filter transactions form #} {# Filter transactions form #}
<div class="z-1" x-show="filterOpen" x-collapse x-cloak> <div class="z-1" x-show="filterOpen" x-collapse x-cloak>
<div class="card card-body bg-base-200 mt-2"> <div class="card card-body bg-base-200 mt-2">
<div class="text-right"> {% include "transactions/fragments/filter_actions.html" %}
<button class="btn btn-outline btn-error btn-sm 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 {% include "transactions/fragments/filter_form.html" %}
install init_tom_select
install init_datepicker"
id="filter" class="mt-3">
{% crispy filter.form %}
</form>
<div class="text-right"> <div class="text-right">
<button class="btn btn-outline btn-error btn-sm w-fit" <a class="btn btn-outline btn-error btn-sm w-fit"
_="on click call #filter.reset() then trigger change on #filter">{% translate 'Clear' %}</button> href="{{ request.path }}"
hx-get="{% url 'transaction_filter_clear' %}"
hx-target="#filter"
hx-swap="outerHTML">{% translate 'Clear' %}</a>
</div> </div>
</div> </div>
</div> </div>