Explorar o código

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

jeremystretch %!s(int64=2) %!d(string=hai) anos
pai
achega
53abcc0f5c

+ 3 - 3
docs/plugins/development/dashboard-widgets.md

@@ -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()
         )

+ 65 - 9
netbox/extras/dashboard/widgets.py

@@ -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')
         )