diff --git a/changelog.d/1045.added.md b/changelog.d/1045.added.md new file mode 100644 index 000000000..0127454a9 --- /dev/null +++ b/changelog.d/1045.added.md @@ -0,0 +1 @@ +Implemented functionality that allows users to create new incident filters, and to select from existing ones via HTMX UI. diff --git a/src/argus/htmx/incident/filter.py b/src/argus/htmx/incident/filter.py index 1a26ce46f..7ef7f80a5 100644 --- a/src/argus/htmx/incident/filter.py +++ b/src/argus/htmx/incident/filter.py @@ -1,22 +1,44 @@ from django import forms from django.urls import reverse +from django.views.generic import ListView from argus.filter import get_filter_backend from argus.incident.models import SourceSystem from argus.incident.constants import Level from argus.htmx.widgets import BadgeDropdownMultiSelect - +from argus.notificationprofile.models import Filter filter_backend = get_filter_backend() QuerySetFilter = filter_backend.QuerySetFilter +class FilterMixin: + model = Filter + + def get_queryset(self): + qs = super().get_queryset() + return qs.filter(user_id=self.request.user.id) + + def get_template_names(self): + orig_app_label = self.model._meta.app_label + orig_model_name = self.model._meta.model_name + self.model._meta.app_label = "htmx/incident" + self.model._meta.model_name = "filter" + templates = super().get_template_names() + self.model._meta.app_label = orig_app_label + self.model._meta.model_name = orig_model_name + return templates + + def get_success_url(self): + return reverse("htmx:filter-list") + + class IncidentFilterForm(forms.Form): open = forms.BooleanField(required=False) closed = forms.BooleanField(required=False) acked = forms.BooleanField(required=False) unacked = forms.BooleanField(required=False) - source = forms.MultipleChoiceField( + sourceSystemIds = forms.MultipleChoiceField( widget=BadgeDropdownMultiSelect( attrs={"placeholder": "select sources..."}, partial_get=None, @@ -37,17 +59,17 @@ class IncidentFilterForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # mollify tests - self.fields["source"].widget.partial_get = reverse("htmx:incident-filter") + self.fields["sourceSystemIds"].widget.partial_get = reverse("htmx:incident-filter") def _tristate(self, onkey, offkey): on = self.cleaned_data.get(onkey, None) off = self.cleaned_data.get(offkey, None) if on == off: - return None + return None, None if on and not off: - return True + return True, False if off and not on: - return False + return False, True def to_filterblob(self): if not self.is_valid(): @@ -55,17 +77,17 @@ def to_filterblob(self): filterblob = {} - open = self._tristate("open", "closed") - if open is not None: - filterblob["open"] = open + open, closed = self._tristate("open", "closed") + filterblob["open"] = open + filterblob["closed"] = closed - acked = self._tristate("acked", "unacked") - if acked is not None: - filterblob["acked"] = acked + acked, unacked = self._tristate("acked", "unacked") + filterblob["acked"] = acked + filterblob["unacked"] = unacked - source = self.cleaned_data.get("source", []) - if source: - filterblob["sourceSystemIds"] = source + sourceSystemIds = self.cleaned_data.get("sourceSystemIds", []) + if sourceSystemIds: + filterblob["sourceSystemIds"] = sourceSystemIds maxlevel = self.cleaned_data.get("maxlevel", 0) if maxlevel: @@ -74,11 +96,40 @@ def to_filterblob(self): return filterblob +class NamedFilterForm(forms.ModelForm): + class Meta: + model = Filter + fields = ["name", "filter"] + + +class FilterListView(FilterMixin, ListView): + pass + + def incident_list_filter(request, qs): - # TODO: initialize with chosen Filter.filter if any - form = IncidentFilterForm(request.GET or None) + filter_pk, filter_obj = request.session.get("selected_filter", None), None + if filter_pk: + filter_obj = Filter.objects.get(pk=filter_pk) + if filter_obj: + form = IncidentFilterForm(filter_obj.filter) + else: + if request.method == "POST": + form = IncidentFilterForm(request.POST) + else: + form = IncidentFilterForm(request.GET or None) if form.is_valid(): filterblob = form.to_filterblob() qs = QuerySetFilter.filtered_incidents(filterblob, qs) return form, qs + + +def create_named_filter(request, filter_name: str, filterblob: dict): + form = NamedFilterForm({"name": filter_name, "filter": filterblob}) + filter_obj = None + + if form.is_valid(): + filter_obj = Filter.objects.create( + user=request.user, name=form.cleaned_data["name"], filter=form.cleaned_data["filter"] + ) + return form, filter_obj diff --git a/src/argus/htmx/incident/urls.py b/src/argus/htmx/incident/urls.py index 8421f9619..9fc9564e5 100644 --- a/src/argus/htmx/incident/urls.py +++ b/src/argus/htmx/incident/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from . import views +from . import views, filter app_name = "htmx" @@ -9,4 +9,7 @@ path("/", views.incident_detail, name="incident-detail"), path("update//", views.incident_update, name="incident-update"), path("filter/", views.filter_form, name="incident-filter"), + path("filter-list/", filter.FilterListView.as_view(), name="filter-list"), + path("select-filter/", views.filter_select, name="select-filter"), + path("filter-create/", views.create_filter, name="filter-create"), ] diff --git a/src/argus/htmx/incident/views.py b/src/argus/htmx/incident/views.py index b1ee81f05..ac4c792e1 100644 --- a/src/argus/htmx/incident/views.py +++ b/src/argus/htmx/incident/views.py @@ -5,15 +5,17 @@ from django import forms from django.contrib.auth import get_user_model +from django.contrib import messages from django.shortcuts import render, get_object_or_404 from django.views.decorators.http import require_POST, require_GET from django.core.paginator import Paginator from django.http import HttpResponse, HttpResponseBadRequest -from django_htmx.http import HttpResponseClientRefresh +from django_htmx.http import HttpResponseClientRefresh, reswap, retarget from argus.auth.utils import get_or_update_preference from argus.incident.models import Incident +from argus.notificationprofile.models import Filter from argus.util.datetime_utils import make_aware from ..request import HtmxHttpRequest @@ -91,12 +93,47 @@ def incident_update(request: HtmxHttpRequest, action: str): @require_GET def filter_form(request: HtmxHttpRequest): + request.session["selected_filter"] = None incident_list_filter = get_filter_function() filter_form, _ = incident_list_filter(request, None) context = {"filter_form": filter_form} return render(request, "htmx/incident/_incident_filterbox.html", context=context) +@require_POST +def create_filter(request: HtmxHttpRequest): + from argus.htmx.incident.filter import create_named_filter + + filter_name = request.POST.get("filter_name", None) + incident_list_filter = get_filter_function() + filter_form, _ = incident_list_filter(request, None) + if filter_name and filter_form.is_valid(): + filterblob = filter_form.to_filterblob() + _, filter_obj = create_named_filter(request, filter_name, filterblob) + if filter_obj: + request.session["selected_filter"] = str(filter_obj.id) + return HttpResponseClientRefresh() + messages.error(request, "Failed to create filter") + return HttpResponseBadRequest() + + +@require_GET +def filter_select(request: HtmxHttpRequest): + filter_id = request.GET.get("filter", None) + if filter_id and get_object_or_404(Filter, id=filter_id): + request.session["selected_filter"] = filter_id + incident_list_filter = get_filter_function() + filter_form, _ = incident_list_filter(request, None) + context = {"filter_form": filter_form} + return render(request, "htmx/incident/_incident_filterbox.html", context=context) + else: + request.session["selected_filter"] = None + if request.htmx.trigger: + return reswap(HttpResponse(), "none") + else: + return retarget(HttpResponse(), "#incident-filter-select") + + @require_GET def incident_list(request: HtmxHttpRequest) -> HttpResponse: columns = get_incident_table_columns() diff --git a/src/argus/htmx/static/styles.css b/src/argus/htmx/static/styles.css index 01b2b6e78..e8c0cc19a 100644 --- a/src/argus/htmx/static/styles.css +++ b/src/argus/htmx/static/styles.css @@ -3109,6 +3109,10 @@ details.collapse summary::-webkit-details-marker { } } +.select-bordered { + border-color: var(--fallback-bc,oklch(var(--bc)/0.2)); +} + .select:focus { box-shadow: none; border-color: var(--fallback-bc,oklch(var(--bc)/0.2)); @@ -3693,6 +3697,20 @@ details.collapse summary::-webkit-details-marker { --filler-offset: 0.4rem; } +.select-sm { + height: 2rem; + min-height: 2rem; + padding-left: 0.75rem; + padding-right: 2rem; + font-size: 0.875rem; + line-height: 2rem; +} + +[dir="rtl"] .select-sm { + padding-left: 2rem; + padding-right: 0.75rem; +} + .select-xs { height: 1.5rem; min-height: 1.5rem; @@ -4414,6 +4432,12 @@ details.collapse summary::-webkit-details-marker { overflow-y: scroll; } +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .text-nowrap { text-wrap: nowrap; } @@ -4430,6 +4454,14 @@ details.collapse summary::-webkit-details-marker { border-radius: 0.5rem; } +.\!rounded-ee-\[inherit\] { + border-end-end-radius: inherit !important; +} + +.\!rounded-se-\[inherit\] { + border-start-end-radius: inherit !important; +} + .border { border-width: 2px; } @@ -4528,6 +4560,22 @@ details.collapse summary::-webkit-details-marker { padding-bottom: 0.5rem; } +.pb-0\.5 { + padding-bottom: 0.125rem; +} + +.pb-1 { + padding-bottom: 0.25rem; +} + +.pt-0\.5 { + padding-top: 0.125rem; +} + +.pt-1 { + padding-top: 0.25rem; +} + .text-center { text-align: center; } diff --git a/src/argus/htmx/templates/htmx/_base_form_modal.html b/src/argus/htmx/templates/htmx/_base_form_modal.html index d4cec5812..6773ece40 100644 --- a/src/argus/htmx/templates/htmx/_base_form_modal.html +++ b/src/argus/htmx/templates/htmx/_base_form_modal.html @@ -22,9 +22,7 @@

{{ header }}