Skip to content

Commit

Permalink
Make it possible to create and select incident filters (#1122)
Browse files Browse the repository at this point in the history
* Add basic logic to render filter select component
* Make it possible to select existing filter (both filter params and the incidents table are updated accordingly to a selected filter)
* Persist selected filter in session
* Unselect filter when user manually updates filter params
* Initialize filter params with chosen filter if selected
* Rename sources field (in order to match expected FilterKey and the rest of the filter related classes in Argus)
* Add logic to create filter
* Make tristates logic compatible with Filter object (so that filter is not created and selected with incorrect tristate selector values)
* Align filter selector and filter control button
* Align contents of filter-incidents-tab
  • Loading branch information
podliashanyk authored Jan 22, 2025
1 parent 249b681 commit 605b38e
Show file tree
Hide file tree
Showing 13 changed files with 220 additions and 26 deletions.
1 change: 1 addition & 0 deletions changelog.d/1045.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implemented functionality that allows users to create new incident filters, and to select from existing ones via HTMX UI.
85 changes: 68 additions & 17 deletions src/argus/htmx/incident/filter.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -37,35 +59,35 @@ 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():
return {}

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:
Expand All @@ -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
5 changes: 4 additions & 1 deletion src/argus/htmx/incident/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.urls import path

from . import views
from . import views, filter


app_name = "htmx"
Expand All @@ -9,4 +9,7 @@
path("<int:pk>/", views.incident_detail, name="incident-detail"),
path("update/<str:action>/", 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"),
]
39 changes: 38 additions & 1 deletion src/argus/htmx/incident/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
48 changes: 48 additions & 0 deletions src/argus/htmx/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 1 addition & 3 deletions src/argus/htmx/templates/htmx/_base_form_modal.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ <h3 class="card-title">{{ header }}</h3>
<div class="modal-action card-actions">
<form method="dialog" class="w-full">
<div class="divider divider-end">
<button type="submit"
form="{{ dialog_id }}-form"
class="btn {{ button_class|default:'btn-primary' }}">
<button type="submit" form="{{ dialog_id }}-form" class="btn btn-primary">
<span>{{ submit_text }}</span>
</button>
<button class="btn">{{ cancel_text }}</button>
Expand Down
4 changes: 4 additions & 0 deletions src/argus/htmx/templates/htmx/incident/_filter_controls.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<div class="join join-horizontal items-center">
{% include "htmx/incident/_filter_select.html" %}
{% include "htmx/incident/_filter_create_modal.html" with dialog_id="create-filter-dialog" button_title="Create filter" button_class="btn-sm join-item !rounded-ee-[inherit] !rounded-se-[inherit]" header="Create new filter" explanation="Create new filter from currently selected filter parameters" cancel_text="Cancel" submit_text="Submit" %}
</div>
17 changes: 17 additions & 0 deletions src/argus/htmx/templates/htmx/incident/_filter_create_modal.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{% extends "htmx/_base_form_modal.html" %}
{% block form_control %}
hx-post="{% url 'htmx:filter-create' %}"
hx-include="#incident-filter-box fieldset, [name='filter_name']"
hx-target="#incident-filter-box"
hx-swap="outerHTML"
{% endblock form_control %}
{% block dialogform %}
<label class="indicator input input-bordered flex items-center gap-2 w-full">
Filter name
<span class="indicator-item indicator-top indicator-start badge border-none mask mask-circle text-warning text-base"></span>
<input name="filter_name"
type="text"
required
class="appearance-none grow border-none" />
</label>
{% endblock dialogform %}
6 changes: 6 additions & 0 deletions src/argus/htmx/templates/htmx/incident/_filter_select.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<form id="filter-selector-form"
hx-get="{% url 'htmx:filter-list' %}"
hx-trigger="load, unselect"
hx-swap="innerHTML">
<p>Loading existing filters...</p>
</form>
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@
<form id="incident-filter-box"
hx-get="{% url 'htmx:incident-list' %}"
hx-include="#table-refresh-info, #incident-filter-box, .column-filter"
hx-trigger="keydown[keyCode==13], change delay:100ms"
hx-trigger="keydown[keyCode==13], change delay:100ms, load"
hx-target="#table"
hx-swap="outerHTML"
hx-push-url="true"
hx-indicator="#incident-list .htmx-indicator"
onkeydown="if (event.keyCode === 13) event.preventDefault();">
<fieldset>
<fieldset hx-on:change="htmx.ajax('GET', '{% url 'htmx:select-filter' %}');">
<legend class="sr-only">Filter incidents</legend>
<ul class="menu menu-horizontal menu-sm flex items-center gap-2 py-1.5">
{% for field in filter_form %}
{% if not field.field.in_header %}
<li class="form-control">
{% if field.name == "source" %}
{% if field.name == "sourceSystemIds" %}
<div class="flex flex-nowrap">
<label class="label">
<span class="label-text">{{ field.label }}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
checked="checked" />
<div role="tabpanel"
class="filterbox tab-content border-primary [--tab-border:theme(borderWidth.DEFAULT)] rounded-box p-2">
{% include "htmx/incident/_incident_filterbox.html" %}
<div class="flex flex-wrap items-center">
{% include "htmx/incident/_incident_filterbox.html" %}
{% include "htmx/incident/_filter_controls.html" %}
</div>
</div>
<input type="radio"
name="incident_menus"
Expand Down
Loading

0 comments on commit 605b38e

Please sign in to comment.