Closes #12136: Extend object count & list widgets to support filters

This commit is contained in:
jeremystretch 2023-04-03 15:02:11 -04:00
parent 0676ed45c7
commit 53abcc0f5c
2 changed files with 68 additions and 12 deletions

View File

@ -9,7 +9,7 @@ Each NetBox user can customize his or her personal dashboard by adding and remov
All dashboard widgets must inherit from NetBox's `DashboardWidget` base class. Subclasses must provide a `render()` method, and may override the base class' default characteristics.
Widgets which require configuration by a user must also include a `ConfigForm` child class. This form is used to render the user configuration options for the widget.
Widgets which require configuration by a user must also include a `ConfigForm` child class which inherits from `WidgetConfigForm`. This form is used to render the user configuration options for the widget.
::: extras.dashboard.widgets.DashboardWidget
@ -34,7 +34,7 @@ class MyWidget2(DashboardWidget):
```python
from django import forms
from extras.dashboard.utils import register_widget
from extras.dashboard.widgets import DashboardWidget
from extras.dashboard.widgets import DashboardWidget, WidgetConfigForm
@register_widget
@ -42,7 +42,7 @@ class ReminderWidget(DashboardWidget):
default_title = 'Reminder'
description = 'Add a virtual sticky note'
class ConfigForm(forms.Form):
class ConfigForm(WidgetConfigForm):
content = forms.CharField(
widget=forms.Textarea()
)

View File

@ -1,6 +1,7 @@
import uuid
from functools import cached_property
from hashlib import sha256
from urllib.parse import urlencode
import feedparser
from django import forms
@ -24,6 +25,7 @@ __all__ = (
'ObjectCountsWidget',
'ObjectListWidget',
'RSSFeedWidget',
'WidgetConfigForm',
)
@ -36,6 +38,22 @@ def get_content_type_labels():
]
def get_models_from_content_types(content_types):
"""
Return a list of models corresponding to the given content types, identified by natural key.
"""
models = []
for content_type_id in content_types:
app_label, model_name = content_type_id.split('.')
content_type = ContentType.objects.get_by_natural_key(app_label, model_name)
models.append(content_type.model_class())
return models
class WidgetConfigForm(BootstrapMixin, forms.Form):
pass
class DashboardWidget:
"""
Base class for custom dashboard widgets.
@ -53,7 +71,7 @@ class DashboardWidget:
width = 4
height = 3
class ConfigForm(BootstrapMixin, forms.Form):
class ConfigForm(WidgetConfigForm):
"""
The widget's configuration form.
"""
@ -106,7 +124,7 @@ class NoteWidget(DashboardWidget):
default_title = _('Note')
description = _('Display some arbitrary custom content. Markdown is supported.')
class ConfigForm(DashboardWidget.ConfigForm):
class ConfigForm(WidgetConfigForm):
content = forms.CharField(
widget=forms.Textarea()
)
@ -121,19 +139,40 @@ class ObjectCountsWidget(DashboardWidget):
description = _('Display a set of NetBox models and the number of objects created for each type.')
template_name = 'extras/dashboard/widgets/objectcounts.html'
class ConfigForm(DashboardWidget.ConfigForm):
class ConfigForm(WidgetConfigForm):
models = forms.MultipleChoiceField(
choices=get_content_type_labels
)
filters = forms.JSONField(
required=False,
label='Object filters',
help_text=_("Only objects matching the specified filters will be counted")
)
def clean_filters(self):
if data := self.cleaned_data['filters']:
try:
dict(data)
except TypeError:
raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.")
for model in get_models_from_content_types(self.cleaned_data.get('models')):
try:
# Validate the filters by creating a QuerySet
model.objects.filter(**data).none()
except Exception:
model_name = model._meta.verbose_name_plural
raise forms.ValidationError(f"Invalid filter specification for {model_name}.")
return data
def render(self, request):
counts = []
for content_type_id in self.config['models']:
app_label, model_name = content_type_id.split('.')
model = ContentType.objects.get_by_natural_key(app_label, model_name).model_class()
for model in get_models_from_content_types(self.config['models']):
permission = get_permission_for_model(model, 'view')
if request.user.has_perm(permission):
object_count = model.objects.restrict(request.user, 'view').count
qs = model.objects.restrict(request.user, 'view')
if filters := self.config.get('filters'):
qs = qs.filter(**filters)
object_count = qs.count
counts.append((model, object_count))
else:
counts.append((model, None))
@ -151,7 +190,7 @@ class ObjectListWidget(DashboardWidget):
width = 12
height = 4
class ConfigForm(DashboardWidget.ConfigForm):
class ConfigForm(WidgetConfigForm):
model = forms.ChoiceField(
choices=get_content_type_labels
)
@ -161,6 +200,18 @@ class ObjectListWidget(DashboardWidget):
max_value=100,
help_text=_('The default number of objects to display')
)
url_params = forms.JSONField(
required=False,
label='URL parameters'
)
def clean_url_params(self):
if data := self.cleaned_data['url_params']:
try:
urlencode(data)
except (TypeError, ValueError):
raise forms.ValidationError("Invalid format. URL parameters must be passed as a dictionary.")
return data
def render(self, request):
app_label, model_name = self.config['model'].split('.')
@ -176,6 +227,11 @@ class ObjectListWidget(DashboardWidget):
htmx_url = reverse(viewname)
except NoReverseMatch:
htmx_url = None
if parameters := self.config.get('url_params'):
try:
htmx_url = f'{htmx_url}?{urlencode(parameters)}'
except ValueError:
pass
return render_to_string(self.template_name, {
'viewname': viewname,
'has_permission': has_permission,
@ -196,7 +252,7 @@ class RSSFeedWidget(DashboardWidget):
width = 6
height = 4
class ConfigForm(DashboardWidget.ConfigForm):
class ConfigForm(WidgetConfigForm):
feed_url = forms.URLField(
label=_('Feed URL')
)