Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make it possible to create and select incident filters #1122

Merged
merged 29 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6f68d87
Add basic logic to render filter select component
podliashanyk Jan 7, 2025
48dbe1c
Move filter selector HTML to a separate file
podliashanyk Jan 7, 2025
b3bbcbb
Make it possible to select existing filter
podliashanyk Jan 7, 2025
a6f9de2
Persist selected filter in session
podliashanyk Jan 8, 2025
08fbf21
Unselect filter on manual filter params update
podliashanyk Jan 8, 2025
68ef51d
Initialize filter params with chosen filter if selected
podliashanyk Jan 8, 2025
781b8b9
Rename sources field
podliashanyk Jan 13, 2025
ad8423c
Add logic to create filter
podliashanyk Jan 13, 2025
ac42088
Make tristates logic compatible with Filter object
podliashanyk Jan 13, 2025
ebbb149
update styles
podliashanyk Jan 13, 2025
f2de849
fix formatting
podliashanyk Jan 13, 2025
01de5c3
Align filter selector and filter control button
podliashanyk Jan 14, 2025
beba7b2
Align contents of filter-incidents-tab
podliashanyk Jan 14, 2025
9ab86d4
Merge branch 'master' into incidents-filter-selector
podliashanyk Jan 15, 2025
5625987
update changelog
podliashanyk Jan 15, 2025
73c59d8
fix formatting
podliashanyk Jan 15, 2025
55b6cf0
fix imports
podliashanyk Jan 15, 2025
06c8652
rename variable that shadows builtin
podliashanyk Jan 15, 2025
e6d3ad7
fixup
podliashanyk Jan 15, 2025
6034cd8
remove label with no input
podliashanyk Jan 15, 2025
b6311a0
move import
podliashanyk Jan 15, 2025
91a5b79
update tests
podliashanyk Jan 15, 2025
c0a1e83
initialize filter form with selected filter inside filter function
podliashanyk Jan 15, 2025
42dceca
Merge branch 'master' into incidents-filter-selector
podliashanyk Jan 15, 2025
52cd007
move import
podliashanyk Jan 20, 2025
62be934
rename variable
podliashanyk Jan 20, 2025
b1c45b5
fix button border radius
podliashanyk Jan 20, 2025
c9c7b36
persist only existing filter ids in session
podliashanyk Jan 20, 2025
0cd72b9
Merge branch 'master' into incidents-filter-selector
podliashanyk Jan 22, 2025
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/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.
83 changes: 66 additions & 17 deletions src/argus/htmx/incident/filter.py
Original file line number Diff line number Diff line change
@@ -1,21 +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..."},
extra={
Expand All @@ -39,29 +62,29 @@ 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
johannaengland marked this conversation as resolved.
Show resolved Hide resolved

source = self.cleaned_data.get("source", [])
if source:
filterblob["sourceSystemIds"] = source
sources = self.cleaned_data.get("sourceSystemIds", [])
if sources:
filterblob["sourceSystemIds"] = sources
podliashanyk marked this conversation as resolved.
Show resolved Hide resolved

maxlevel = self.cleaned_data.get("maxlevel", 0)
if maxlevel:
Expand All @@ -70,11 +93,37 @@ def to_filterblob(self):
return filterblob


def incident_list_filter(request, qs):
# TODO: initialize with chosen Filter.filter if any
form = IncidentFilterForm(request.GET or None)
class NamedFilterForm(forms.ModelForm):
class Meta:
model = Filter
fields = ["name", "filter"]


class FilterListView(FilterMixin, ListView):
pass


def incident_list_filter(request, qs, filter_obj: Filter = None):
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"),
]
45 changes: 43 additions & 2 deletions 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 @@ -97,6 +99,42 @@ def filter_form(request: HtmxHttpRequest):
return render(request, "htmx/incident/_incident_filterbox.html", context=context)


@require_POST
def create_filter(request: HtmxHttpRequest):
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()
from argus.htmx.incident.filter import create_named_filter
podliashanyk marked this conversation as resolved.
Show resolved Hide resolved

_, 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)
request.session["selected_filter"] = filter_id
podliashanyk marked this conversation as resolved.
Show resolved Hide resolved
if filter_id:
incident_list_filter = get_filter_function()
filter_obj = get_object_or_404(Filter, id=filter_id)
filter_form, _ = incident_list_filter(request, None, filter_obj)
context = {"filter_form": filter_form}
return render(request, "htmx/incident/_incident_filterbox.html", context=context)
else:
if request.htmx.trigger:
return reswap(HttpResponse(), "none")
else:
response = HttpResponse()
retarget(response, "#incident-filter-select")
return response


@require_GET
def incident_list(request: HtmxHttpRequest) -> HttpResponse:
columns = get_incident_table_columns()
Expand All @@ -109,7 +147,10 @@ def incident_list(request: HtmxHttpRequest) -> HttpResponse:
params = dict(request.GET.items())

incident_list_filter = get_filter_function()
filter_form, qs = incident_list_filter(request, qs)
filter_pk, filter_obj = request.session.get("selected_filter", None), None
if filter_pk:
podliashanyk marked this conversation as resolved.
Show resolved Hide resolved
filter_obj = get_object_or_404(Filter, pk=filter_pk)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would suggest placing this inside incident_list_filter instead of in the view. This keeps both the view and the filter-plugin interface clean

Copy link
Contributor

@hmpf hmpf Jan 15, 2025

Choose a reason for hiding this comment

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

Unclear. The session stuff or raising the 404 or both?

I'm not too fond of raising 404 directly in incident_list_filter.

filter_form, qs = incident_list_filter(request, qs, filter_obj)
filtered_count = qs.count()

# Standard Django pagination
Expand Down
40 changes: 40 additions & 0 deletions src/argus/htmx/static/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3191,6 +3191,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 @@ -3798,6 +3802,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 @@ -4505,6 +4523,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 Down Expand Up @@ -4619,6 +4643,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-start {
text-align: start;
}
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" header="Create new filter" explanation="Create new filter from currently selected filter parameters" cancel_text="Cancel" submit_text="Submit" %}
podliashanyk marked this conversation as resolved.
Show resolved Hide resolved
</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
24 changes: 24 additions & 0 deletions src/argus/htmx/templates/htmx/incident/filter_list.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<label class="form-control w-full max-w-xs">
<span class="block label pt-1 pb-0.5">
<span class="label-text">Filter</span>
</span>
<select class="select select-bordered select-sm join-item w-full max-w-xs"
id="incident-filter-select"
name="filter"
hx-get="{% url 'htmx:select-filter' %}"
hx-target="#incident-filter-box"
hx-swap="outerHTML"
hx-on::after-swap="htmx.trigger('#filter-selector-form', 'unselect')">
<option {% if not request.session.selected_filter %}selected{% endif %}
value="">---</option>
{% for object in object_list %}
<option value="{{ object.id }}"
{% if request.session.selected_filter == object.id|stringformat:"i" %}selected{% endif %}>
{{ object.name }}
</option>
{% endfor %}
</select>
<span class="block label pb-1 pt-0.5">
<span class="label-text-alt truncate">Select from your filters</span>
</span>
</label>
Loading