From 7e1f4ef07e84df4447814af2b5114034b0863b73 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 21 Feb 2023 16:25:02 -0500 Subject: [PATCH] Implement widget configuration views & forms --- netbox/extras/constants.py | 18 ++++----- netbox/extras/dashboard/forms.py | 13 +++++++ netbox/extras/dashboard/utils.py | 14 +++++-- netbox/extras/dashboard/widgets.py | 31 ++++++++++++--- netbox/extras/urls.py | 3 ++ netbox/extras/views.py | 39 +++++++++++++++++++ netbox/templates/extras/dashboard/widget.html | 18 +++++---- .../extras/dashboardwidget_edit.html | 33 ++++++++++++++++ 8 files changed, 142 insertions(+), 27 deletions(-) create mode 100644 netbox/extras/dashboard/forms.py create mode 100644 netbox/templates/extras/dashboardwidget_edit.html diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index e1ce7b713..0474582ee 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,3 +1,5 @@ +from django.contrib.contenttypes.models import ContentType + # Webhook content types HTTP_CONTENT_TYPE_JSON = 'application/json' @@ -10,10 +12,9 @@ DEFAULT_DASHBOARD = [ 'title': 'IPAM', 'config': { 'models': [ - 'ipam.Aggregate', - 'ipam.Prefix', - 'ipam.IPRange', - 'ipam.IPAddress', + ContentType.objects.get_by_natural_key('ipam', 'aggregate').pk, + ContentType.objects.get_by_natural_key('ipam', 'prefix').pk, + ContentType.objects.get_by_natural_key('ipam', 'ipaddress').pk, ] } }, @@ -24,15 +25,14 @@ DEFAULT_DASHBOARD = [ 'title': 'DCIM', 'config': { 'models': [ - 'dcim.Site', - 'dcim.Rack', - 'dcim.Device', - 'dcim.Cable', + ContentType.objects.get_by_natural_key('dcim', 'site').pk, + ContentType.objects.get_by_natural_key('dcim', 'rack').pk, + ContentType.objects.get_by_natural_key('dcim', 'device').pk, ] } }, { - 'widget': 'extras.StaticContentWidget', + 'widget': 'extras.NoteWidget', 'width': 4, 'height': 3, 'config': { diff --git a/netbox/extras/dashboard/forms.py b/netbox/extras/dashboard/forms.py new file mode 100644 index 000000000..04896cf80 --- /dev/null +++ b/netbox/extras/dashboard/forms.py @@ -0,0 +1,13 @@ +from django import forms + +from utilities.forms import BootstrapMixin + +__all__ = ( + 'DashboardWidgetForm', +) + + +class DashboardWidgetForm(BootstrapMixin, forms.Form): + title = forms.CharField( + required=False + ) diff --git a/netbox/extras/dashboard/utils.py b/netbox/extras/dashboard/utils.py index 345b00ff6..cc07ca4e0 100644 --- a/netbox/extras/dashboard/utils.py +++ b/netbox/extras/dashboard/utils.py @@ -5,6 +5,8 @@ from extras.constants import DEFAULT_DASHBOARD __all__ = ( 'get_dashboard', + 'get_default_dashboard_config', + 'get_widget_class_and_config', 'register_widget', ) @@ -20,6 +22,12 @@ def register_widget(cls): return cls +def get_widget_class_and_config(user, id): + config = dict(user.config.get(f'dashboard.widgets.{id}')) # Copy to avoid mutating userconfig data + widget_class = registry['widgets'].get(config.pop('class')) + return widget_class, config + + def get_dashboard(user): """ Return the dashboard layout for a given User. @@ -33,10 +41,8 @@ def get_dashboard(user): widgets = [] for grid_item in config['layout']: - widget_id = grid_item['id'] - widget_config = config['widgets'][widget_id] - widget_class = registry['widgets'].get(widget_config.pop('class')) - widget = widget_class(id=widget_id, **widget_config) + widget_class, widget_config = get_widget_class_and_config(user, grid_item['id']) + widget = widget_class(id=grid_item['id'], **widget_config) widget.set_layout(grid_item) widgets.append(widget) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 5107b276d..4a5078a93 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -1,18 +1,21 @@ import uuid +from django import forms from django.contrib.contenttypes.models import ContentType from django.template.loader import render_to_string from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ +from utilities.forms import BootstrapMixin +from utilities.forms.fields import ContentTypeMultipleChoiceField from utilities.templatetags.builtins.filters import render_markdown from .utils import register_widget __all__ = ( 'ChangeLogWidget', 'DashboardWidget', + 'NoteWidget', 'ObjectCountsWidget', - 'StaticContentWidget', ) @@ -22,7 +25,10 @@ class DashboardWidget: width = 4 height = 3 - def __init__(self, id=None, config=None, title=None, width=None, height=None, x=None, y=None): + class ConfigForm(forms.Form): + pass + + def __init__(self, id=None, title=None, config=None, width=None, height=None, x=None, y=None): self.id = id or uuid.uuid4() self.config = config or {} if title: @@ -48,7 +54,7 @@ class DashboardWidget: @register_widget -class StaticContentWidget(DashboardWidget): +class NoteWidget(DashboardWidget): description = _('Display some arbitrary custom content. Markdown is supported.') default_content = """
@@ -56,6 +62,11 @@ class StaticContentWidget(DashboardWidget):
""" + class ConfigForm(BootstrapMixin, forms.Form): + content = forms.CharField( + widget=forms.Textarea() + ) + def render(self, request): if content := self.config.get('content'): return render_markdown(content) @@ -68,11 +79,19 @@ 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(BootstrapMixin, forms.Form): + # TODO: Track models by app label & name rather than ContentType ID + models = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all() + ) + + def clean_models(self): + return [obj.pk for obj in self.cleaned_data['models']] + def render(self, request): counts = [] - for model_name in self.config['models']: - app_label, name = model_name.lower().split('.') - model = ContentType.objects.get_by_natural_key(app_label, name).model_class() + for content_type_id in self.config['models']: + model = ContentType.objects.get(pk=content_type_id).model_class() object_count = model.objects.restrict(request.user, 'view').count counts.append((model, object_count)) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index dfbaa1bc6..78483d419 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -87,6 +87,9 @@ urlpatterns = [ path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', include(get_model_urls('extras', 'objectchange'))), + # User dashboard + path('dashboard/widgets/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_edit'), + # Reports path('reports/', views.ReportListView.as_view(), name='report_list'), path('reports/results//', views.ReportResultView.as_view(), name='report_result'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3edb70cf1..8a9d62709 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1,4 +1,5 @@ from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.db.models import Count, Q from django.http import Http404, HttpResponseForbidden @@ -8,6 +9,8 @@ from django.views.generic import View from django_rq.queues import get_connection from rq import Worker +from extras.dashboard.forms import DashboardWidgetForm +from extras.dashboard.utils import get_widget_class_and_config from netbox.views import generic from utilities.htmx import is_htmx from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict @@ -664,6 +667,42 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView): table = tables.JournalEntryTable +# +# Dashboard widgets +# + +class DashboardWidgetConfigView(LoginRequiredMixin, View): + template_name = 'extras/dashboardwidget_edit.html' + + def get(self, request): + widget_class, config = get_widget_class_and_config(request.user, request.GET['id']) + widget_form = DashboardWidgetForm(initial=config) + config_form = widget_class.ConfigForm(initial=config.get('config'), prefix='config') + + return render(request, self.template_name, { + 'widget_form': widget_form, + 'config_form': config_form, + }) + + def post(self, request): + id = request.GET['id'] + widget_class, config = get_widget_class_and_config(request.user, id) + widget_form = DashboardWidgetForm(request.POST) + config_form = widget_class.ConfigForm(request.POST, prefix='config') + + if widget_form.is_valid() and config_form.is_valid(): + data = widget_form.cleaned_data + data['config'] = config_form.cleaned_data + request.user.config.set(f'dashboard.widgets.{id}', data, commit=True) + + return redirect('home') + + return render(request, self.template_name, { + 'widget_form': widget_form, + 'config_form': config_form, + }) + + # # Reports # diff --git a/netbox/templates/extras/dashboard/widget.html b/netbox/templates/extras/dashboard/widget.html index cd95564ed..eefa7f0df 100644 --- a/netbox/templates/extras/dashboard/widget.html +++ b/netbox/templates/extras/dashboard/widget.html @@ -9,16 +9,18 @@ gs-id="{{ widget.id }}" >
- {% if widget.title %} -
-
- {{ widget.title }} +
+
+
- {% endif %} -
- {% if not widget.title %} -
+
+ +
+ {% if widget.title %} + {{ widget.title }} {% endif %} +
+
{% render_widget widget %}
diff --git a/netbox/templates/extras/dashboardwidget_edit.html b/netbox/templates/extras/dashboardwidget_edit.html new file mode 100644 index 000000000..31b68eeaf --- /dev/null +++ b/netbox/templates/extras/dashboardwidget_edit.html @@ -0,0 +1,33 @@ +{% extends 'base/layout.html' %} +{% load form_helpers %} + + +{% block title %} + Editing {{ widget }} +{% endblock title %} + +{% block content-wrapper %} +
+
+ +
+ {% csrf_token %} + +
+ {% block form %} + {% render_form widget_form %} + {% render_form config_form %} + {% endblock form %} +
+ +
+ {% block buttons %} + + Cancel + {% endblock buttons %} +
+
+
+
+ +{% endblock content-wrapper %}