Implement widget configuration views & forms

This commit is contained in:
jeremystretch 2023-02-21 16:25:02 -05:00
parent f876e9ed04
commit 7e1f4ef07e
8 changed files with 142 additions and 27 deletions

View File

@ -1,3 +1,5 @@
from django.contrib.contenttypes.models import ContentType
# Webhook content types # Webhook content types
HTTP_CONTENT_TYPE_JSON = 'application/json' HTTP_CONTENT_TYPE_JSON = 'application/json'
@ -10,10 +12,9 @@ DEFAULT_DASHBOARD = [
'title': 'IPAM', 'title': 'IPAM',
'config': { 'config': {
'models': [ 'models': [
'ipam.Aggregate', ContentType.objects.get_by_natural_key('ipam', 'aggregate').pk,
'ipam.Prefix', ContentType.objects.get_by_natural_key('ipam', 'prefix').pk,
'ipam.IPRange', ContentType.objects.get_by_natural_key('ipam', 'ipaddress').pk,
'ipam.IPAddress',
] ]
} }
}, },
@ -24,15 +25,14 @@ DEFAULT_DASHBOARD = [
'title': 'DCIM', 'title': 'DCIM',
'config': { 'config': {
'models': [ 'models': [
'dcim.Site', ContentType.objects.get_by_natural_key('dcim', 'site').pk,
'dcim.Rack', ContentType.objects.get_by_natural_key('dcim', 'rack').pk,
'dcim.Device', ContentType.objects.get_by_natural_key('dcim', 'device').pk,
'dcim.Cable',
] ]
} }
}, },
{ {
'widget': 'extras.StaticContentWidget', 'widget': 'extras.NoteWidget',
'width': 4, 'width': 4,
'height': 3, 'height': 3,
'config': { 'config': {

View File

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

View File

@ -5,6 +5,8 @@ from extras.constants import DEFAULT_DASHBOARD
__all__ = ( __all__ = (
'get_dashboard', 'get_dashboard',
'get_default_dashboard_config',
'get_widget_class_and_config',
'register_widget', 'register_widget',
) )
@ -20,6 +22,12 @@ def register_widget(cls):
return 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): def get_dashboard(user):
""" """
Return the dashboard layout for a given User. Return the dashboard layout for a given User.
@ -33,10 +41,8 @@ def get_dashboard(user):
widgets = [] widgets = []
for grid_item in config['layout']: for grid_item in config['layout']:
widget_id = grid_item['id'] widget_class, widget_config = get_widget_class_and_config(user, grid_item['id'])
widget_config = config['widgets'][widget_id] widget = widget_class(id=grid_item['id'], **widget_config)
widget_class = registry['widgets'].get(widget_config.pop('class'))
widget = widget_class(id=widget_id, **widget_config)
widget.set_layout(grid_item) widget.set_layout(grid_item)
widgets.append(widget) widgets.append(widget)

View File

@ -1,18 +1,21 @@
import uuid import uuid
from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ 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 utilities.templatetags.builtins.filters import render_markdown
from .utils import register_widget from .utils import register_widget
__all__ = ( __all__ = (
'ChangeLogWidget', 'ChangeLogWidget',
'DashboardWidget', 'DashboardWidget',
'NoteWidget',
'ObjectCountsWidget', 'ObjectCountsWidget',
'StaticContentWidget',
) )
@ -22,7 +25,10 @@ class DashboardWidget:
width = 4 width = 4
height = 3 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.id = id or uuid.uuid4()
self.config = config or {} self.config = config or {}
if title: if title:
@ -48,7 +54,7 @@ class DashboardWidget:
@register_widget @register_widget
class StaticContentWidget(DashboardWidget): class NoteWidget(DashboardWidget):
description = _('Display some arbitrary custom content. Markdown is supported.') description = _('Display some arbitrary custom content. Markdown is supported.')
default_content = """ default_content = """
<div class="d-flex justify-content-center align-items-center" style="height: 100%"> <div class="d-flex justify-content-center align-items-center" style="height: 100%">
@ -56,6 +62,11 @@ class StaticContentWidget(DashboardWidget):
</div> </div>
""" """
class ConfigForm(BootstrapMixin, forms.Form):
content = forms.CharField(
widget=forms.Textarea()
)
def render(self, request): def render(self, request):
if content := self.config.get('content'): if content := self.config.get('content'):
return render_markdown(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.') description = _('Display a set of NetBox models and the number of objects created for each type.')
template_name = 'extras/dashboard/widgets/objectcounts.html' 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): def render(self, request):
counts = [] counts = []
for model_name in self.config['models']: for content_type_id in self.config['models']:
app_label, name = model_name.lower().split('.') model = ContentType.objects.get(pk=content_type_id).model_class()
model = ContentType.objects.get_by_natural_key(app_label, name).model_class()
object_count = model.objects.restrict(request.user, 'view').count object_count = model.objects.restrict(request.user, 'view').count
counts.append((model, object_count)) counts.append((model, object_count))

View File

@ -87,6 +87,9 @@ urlpatterns = [
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))), path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
# User dashboard
path('dashboard/widgets/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_edit'),
# Reports # Reports
path('reports/', views.ReportListView.as_view(), name='report_list'), path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'), path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),

View File

@ -1,4 +1,5 @@
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q from django.db.models import Count, Q
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseForbidden
@ -8,6 +9,8 @@ from django.views.generic import View
from django_rq.queues import get_connection from django_rq.queues import get_connection
from rq import Worker 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 netbox.views import generic
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict 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 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 # Reports
# #

View File

@ -9,16 +9,18 @@
gs-id="{{ widget.id }}" gs-id="{{ widget.id }}"
> >
<div class="card grid-stack-item-content"> <div class="card grid-stack-item-content">
{% if widget.title %} <div class="card-header text-center text-light bg-secondary p-1">
<div class="card-header text-center text-light bg-secondary p-1"> <div class="float-start ps-1">
<div class="float-end pe-1"><i class="mdi mdi-close"></i></div> <a href="{% url 'extras:dashboardwidget_edit' %}?id={{ widget.id }}"><i class="mdi mdi-cog text-gray"></i></a>
<strong>{{ widget.title }}</strong>
</div> </div>
{% endif %} <div class="float-end pe-1">
<div class="card-body p-2"> <a href="#"><i class="mdi mdi-close text-gray"></i></a>
{% if not widget.title %} </div>
<div class="float-end pe-1"><i class="mdi mdi-close"></i></div> {% if widget.title %}
<strong>{{ widget.title }}</strong>
{% endif %} {% endif %}
</div>
<div class="card-body p-2">
{% render_widget widget %} {% render_widget widget %}
</div> </div>
</div> </div>

View File

@ -0,0 +1,33 @@
{% extends 'base/layout.html' %}
{% load form_helpers %}
{% block title %}
Editing {{ widget }}
{% endblock title %}
{% block content-wrapper %}
<div class="tab-content">
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="object-list-tab">
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit mt-5">
{% csrf_token %}
<div id="form_fields">
{% block form %}
{% render_form widget_form %}
{% render_form config_form %}
{% endblock form %}
</div>
<div class="text-end my-3">
{% block buttons %}
<button type="submit" name="_update" class="btn btn-primary">Save</button>
<a class="btn btn-outline-danger" href="{% url 'home' %}">Cancel</a>
{% endblock buttons %}
</div>
</form>
</div>
</div>
{% endblock content-wrapper %}