Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1958.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed planned maintenance filter selection freezing the drop-down
2 changes: 1 addition & 1 deletion src/argus/htmx/plannedmaintenance/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
app_name = "htmx"
urlpatterns = [
path("create/", views.PlannedMaintenanceCreateView.as_view(), name="plannedmaintenance-create"),
path("search-filters/", views.SearchFiltersView.as_view(), name="search-filters"),
path("filter-widget/", views.FilterWidgetPartialView.as_view(), name="plannedmaintenance-filter-widget"),
path("filter-preview/", views.FilterPreviewView.as_view(), name="plannedmaintenance-filter-preview"),
re_path(r"^(?P<tab>upcoming|past)?/$", views.PlannedMaintenanceListView.as_view(), name="plannedmaintenance-list"),
path("<pk>/cancel/", views.PlannedMaintenanceCancelView.as_view(), name="plannedmaintenance-cancel"),
Expand Down
43 changes: 24 additions & 19 deletions src/argus/htmx/plannedmaintenance/views.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from django import forms
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.db.models import Case, IntegerField, When
from django.forms import modelform_factory
from django.http import HttpResponseRedirect, JsonResponse
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
Expand Down Expand Up @@ -84,15 +84,22 @@ class FilterWidgetMixin:

def get_filter_widget(self):
return SearchDropdownMultiSelect(
partial_get=reverse("htmx:search-filters"),
partial_get=reverse("htmx:plannedmaintenance-filter-widget"),
attrs={"placeholder": "Select filters..."},
extra={"search_placeholder": "Search by filter name or user..."},
extra={
"search_placeholder": "Search by filter name or user...",
"preload": True,
},
)

def get_form(self, form_class=None):
form = super().get_form(form_class)
if "filters" in form.fields:
form.fields["filters"].queryset = Filter.objects.select_related("user")
form.fields["filters"].queryset = (
Filter.objects.select_related("user")
.annotate(own=Case(When(user=self.request.user, then=0), default=1, output_field=IntegerField()))
.order_by("own", "name")
)
form.fields["filters"].label_from_instance = lambda obj: f"{obj.name} ({obj.user.username})"
if "end_time" in form.fields:
form.fields["end_time"].required = False
Expand Down Expand Up @@ -180,25 +187,23 @@ def form_valid(self, form):
return super().form_valid(form)


class SearchFiltersView(UserIsStaffMixin, View):
class FilterWidgetPartialView(UserIsStaffMixin, View):
http_method_names = ["get"]

def get(self, request):
query = request.GET.get("q", "")

filters = Filter.objects.select_related("user")
if query:
filters = filters.filter(
Q(name__icontains=query)
| Q(user__username__icontains=query)
| Q(user__first_name__icontains=query)
| Q(user__last_name__icontains=query)
)
filters = sorted(filters, key=lambda f: (f.user != request.user, f.name))[:20]
filter_ids = [fid for fid in request.GET.getlist("filters") if fid.isdigit()]

@hmpf hmpf Jun 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: validate with a form. (Allows for standardized error handling/logging in case somebody do want to use this for SQL injection or something.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems reasonable, I made a follow-up issue so that can be done in isolation.

selected_filters = Filter.objects.filter(pk__in=filter_ids).select_related("user")

@hmpf hmpf Jun 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous code showed the user's own filters, and sorted them on users. This does not. Why?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was probably forgotten in some iteration, I re-added the functionality in the new version and added a test for the future.


options = [{"id": f.pk, "text": f"{f.name} ({f.user.username})"} for f in filters]
class FiltersForm(forms.Form):
filters = forms.ModelMultipleChoiceField(
queryset=selected_filters,
widget=FilterWidgetMixin().get_filter_widget(),
required=False,
)

return JsonResponse({"results": options})
form = FiltersForm(request.GET)
form.fields["filters"].label_from_instance = lambda obj: f"{obj.name} ({obj.user.username})"
return HttpResponse(str(form["filters"]))


class FilterPreviewView(UserIsStaffMixin, View):
Expand Down
14 changes: 11 additions & 3 deletions src/argus/htmx/templates/htmx/forms/search_select_multiple.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,20 @@
}

if (preload) {
// Client-side filtering for preloaded options
// Client-side filtering, checked items always stay visible, unchecked results capped at 20
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase();
let shown = 0;
optionsContainer.querySelectorAll('label').forEach(label => {
const text = label.textContent.toLowerCase();
label.style.display = text.includes(query) ? '' : 'none';
const isChecked = label.querySelector('input:checked');
if (isChecked) {
label.style.display = '';
} else if (label.textContent.toLowerCase().includes(query) && shown < 20) {
label.style.display = '';
shown++;
} else {
label.style.display = 'none';
}
});
});
} else if (searchUrl) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@
// Filter preview functionality
(() => {
const filterPreview = document.getElementById('filter-preview');
const filtersSelect = document.getElementById('id_filters');
if (!filterPreview || !filtersSelect) return;
const filtersDropdown = document.getElementById('dropdown-id_filters');
if (!filterPreview || !filtersDropdown) return;

const previewUrl = '{% url "htmx:plannedmaintenance-filter-preview" %}';
let debounceTimer = null;

const loadPreview = () => {
const filterIds = Array.from(filtersSelect.selectedOptions).map(opt => opt.value);
const filterIds = [...filtersDropdown.querySelectorAll('input[name="filters"]:checked')]
.map(input => input.value);

const params = new URLSearchParams();
filterIds.forEach(id => params.append('filters', id));
Expand All @@ -46,14 +47,13 @@
});
};

// Listen for changes on the select element (Choices.js triggers change events)
filtersSelect.addEventListener('change', () => {
filtersDropdown.querySelector('.dropdown-content').addEventListener('change', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadPreview, 500);
});

// Load initial preview if filters are already selected
if (filtersSelect.selectedOptions.length > 0) {
if (filtersDropdown.querySelector('input[name="filters"]:checked')) {
loadPreview();
}
})();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ <h2 class="card-title">
</label>
{{ form.filters }}
<div class="label">
<span class="label-text-alt">Select filters that define incidents covered by this maintenance. Filters should include source system IDs, source types, or tags to be effective. Type at least 2 characters to search.</span>
<span class="label-text-alt">Select filters that define incidents covered by this maintenance. Filters should include source system IDs, source types, or tags to be effective.</span>
</div>
{% if form.filters.errors %}
<div class="label">
Expand Down
68 changes: 26 additions & 42 deletions tests/htmx/plannedmaintenance/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,55 +271,39 @@ def test_cancel_view_redirects_on_success(self):


@tag("integration")
class SearchFiltersViewTests(TestCase):
class PlannedMaintenanceFilterDropdownTests(TestCase):
def setUp(self):
self.user = PersonUserFactory()
self.staff_user = AdminUserFactory(username="felaali", first_name="Ferrari", last_name="Testarossa")
self.other_staff_user = AdminUserFactory(username="lambo", first_name="Lamborghini", last_name="Countach")

self.staff_filter = FilterFactory(user=self.staff_user, name="My Filter")
self.other_staff_filter = FilterFactory(user=self.other_staff_user, name="Other Filter")

def test_search_filters_requires_login(self):
response = self.client.get(reverse("htmx:search-filters"))
self.assertEqual(response.status_code, 302)

def test_search_filters_when_not_staff_then_forbidden(self):
self.client.force_login(self.user)
response = self.client.get(reverse("htmx:search-filters"))
self.assertEqual(response.status_code, 403)

def test_search_filters_it_should_return_json(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("htmx:search-filters"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/json")

def test_search_filters_returns_all_filters_without_query(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("htmx:search-filters"))
data = response.json()
self.assertEqual(len(data["results"]), 2)
self.staff_user = AdminUserFactory()

def test_search_filters_filters_by_name(self):
def test_given_create_form_filter_queryset_should_include_all_filters(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("htmx:search-filters"), {"q": "My"})
data = response.json()
self.assertEqual(len(data["results"]), 1)
self.assertEqual(data["results"][0]["id"], self.staff_filter.pk)
f1 = FilterFactory(user=self.staff_user)
f2 = FilterFactory(user=AdminUserFactory())
response = self.client.get(reverse("htmx:plannedmaintenance-create"))
qs = response.context["form"].fields["filters"].queryset
self.assertIn(f1, qs)
self.assertIn(f2, qs)

def test_search_filters_filters_by_username(self):
def test_given_create_form_queryset_users_own_filters_come_first(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("htmx:search-filters"), {"q": self.other_staff_user.username})
data = response.json()
self.assertEqual(len(data["results"]), 1)
self.assertEqual(data["results"][0]["id"], self.other_staff_filter.pk)
other_user_1 = AdminUserFactory()
other_user_2 = AdminUserFactory()

FilterFactory(user=other_user_1, name="B other")
FilterFactory(user=self.staff_user, name="M own")
FilterFactory(user=self.staff_user, name="A own")
FilterFactory(user=other_user_2, name="Z other")
FilterFactory(user=other_user_2, name="C other")
response = self.client.get(reverse("htmx:plannedmaintenance-create"))
names = [f.name for f in response.context["form"].fields["filters"].queryset]
self.assertEqual(names, ["A own", "M own", "B other", "C other", "Z other"])

def test_search_filters_sorts_current_user_filters_first(self):
def test_given_selected_filter_ids_partial_view_it_should_render_them_as_checked(self):
self.client.force_login(self.staff_user)
response = self.client.get(reverse("htmx:search-filters"))
data = response.json()
self.assertEqual(data["results"][0]["id"], self.staff_filter.pk)
f = FilterFactory(user=self.staff_user)
response = self.client.get(reverse("htmx:plannedmaintenance-filter-widget"), {"filters": [f.pk]})
self.assertContains(response, f'value="{f.pk}"')
self.assertContains(response, "checked")


@tag("integration")
Expand Down
Loading