Skip to content

Commit

Permalink
Closes #9416: Dashboard widgets (#11823)
Browse files Browse the repository at this point in the history
* Replace masonry with gridstack

* Initial work on dashboard widgets

* Implement function to save dashboard layout

* Define a default dashboard

* Clean up widgets

* Implement widget configuration views & forms

* Permit merging dict value with existing dict in user config

* Add widget deletion view

* Enable HTMX for widget configuration

* Implement view to add dashboard widgets

* ObjectCountsWidget: Identify models by app_label & name

* Add color customization to dashboard widgets

* Introduce Dashboard model to store user dashboard layout & config

* Clean up utility functions

* Remove hard-coded API URL

* Use fixed grid cell height

* Add modal close button

* Clean up dashboard views

* Rebuild JS
  • Loading branch information
jeremystretch authored Feb 24, 2023
1 parent 36771e8 commit 084a2cc
Show file tree
Hide file tree
Showing 40 changed files with 788 additions and 310 deletions.
11 changes: 11 additions & 0 deletions netbox/extras/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
'ContentTypeSerializer',
'CustomFieldSerializer',
'CustomLinkSerializer',
'DashboardSerializer',
'ExportTemplateSerializer',
'ImageAttachmentSerializer',
'JobResultSerializer',
Expand Down Expand Up @@ -563,3 +564,13 @@ class ContentTypeSerializer(BaseModelSerializer):
class Meta:
model = ContentType
fields = ['id', 'url', 'display', 'app_label', 'model']


#
# User dashboard
#

class DashboardSerializer(serializers.ModelSerializer):
class Meta:
model = Dashboard
fields = ('layout', 'config')
7 changes: 6 additions & 1 deletion netbox/extras/api/urls.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from django.urls import include, path

from netbox.api.routers import NetBoxRouter
from . import views

Expand All @@ -22,4 +24,7 @@
router.register('content-types', views.ContentTypeViewSet)

app_name = 'extras-api'
urlpatterns = router.urls
urlpatterns = [
path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
path('', include(router.urls)),
]
13 changes: 13 additions & 0 deletions netbox/extras/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
Expand Down Expand Up @@ -423,3 +424,15 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
queryset = ContentType.objects.order_by('app_label', 'model')
serializer_class = serializers.ContentTypeSerializer
filterset_class = filtersets.ContentTypeFilterSet


#
# User dashboard
#

class DashboardView(RetrieveUpdateDestroyAPIView):
queryset = Dashboard.objects.all()
serializer_class = serializers.DashboardSerializer

def get_object(self):
return Dashboard.objects.filter(user=self.request.user).first()
2 changes: 1 addition & 1 deletion netbox/extras/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ class ExtrasConfig(AppConfig):
name = "extras"

def ready(self):
from . import lookups, search, signals
from . import dashboard, lookups, search, signals
45 changes: 45 additions & 0 deletions netbox/extras/constants.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,47 @@
from django.contrib.contenttypes.models import ContentType

# Webhook content types
HTTP_CONTENT_TYPE_JSON = 'application/json'

# Dashboard
DEFAULT_DASHBOARD = [
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 3,
'title': 'IPAM',
'config': {
'models': [
'ipam.aggregate',
'ipam.prefix',
'ipam.ipaddress',
]
}
},
{
'widget': 'extras.ObjectCountsWidget',
'width': 4,
'height': 3,
'title': 'DCIM',
'config': {
'models': [
'dcim.site',
'dcim.rack',
'dcim.device',
]
}
},
{
'widget': 'extras.NoteWidget',
'width': 4,
'height': 3,
'config': {
'content': 'Welcome to **NetBox**!'
}
},
{
'widget': 'extras.ChangeLogWidget',
'width': 12,
'height': 6,
},
]
2 changes: 2 additions & 0 deletions netbox/extras/dashboard/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .utils import *
from .widgets import *
38 changes: 38 additions & 0 deletions netbox/extras/dashboard/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from django import forms
from django.urls import reverse_lazy

from netbox.registry import registry
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.choices import ButtonColorChoices

__all__ = (
'DashboardWidgetAddForm',
'DashboardWidgetForm',
)


def get_widget_choices():
return registry['widgets'].items()


class DashboardWidgetForm(BootstrapMixin, forms.Form):
title = forms.CharField(
required=False
)
color = forms.ChoiceField(
choices=add_blank_choice(ButtonColorChoices),
required=False,
)


class DashboardWidgetAddForm(DashboardWidgetForm):
widget_class = forms.ChoiceField(
choices=get_widget_choices,
widget=forms.Select(
attrs={
'hx-get': reverse_lazy('extras:dashboardwidget_add'),
'hx-target': '#widget_add_form',
}
)
)
field_order = ('widget_class', 'title', 'color')
76 changes: 76 additions & 0 deletions netbox/extras/dashboard/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import uuid

from django.core.exceptions import ObjectDoesNotExist

from netbox.registry import registry
from extras.constants import DEFAULT_DASHBOARD

__all__ = (
'get_dashboard',
'get_default_dashboard',
'get_widget_class',
'register_widget',
)


def register_widget(cls):
"""
Decorator for registering a DashboardWidget class.
"""
app_label = cls.__module__.split('.', maxsplit=1)[0]
label = f'{app_label}.{cls.__name__}'
registry['widgets'][label] = cls

return cls


def get_widget_class(name):
"""
Return a registered DashboardWidget class identified by its name.
"""
try:
return registry['widgets'][name]
except KeyError:
raise ValueError(f"Unregistered widget class: {name}")


def get_dashboard(user):
"""
Return the Dashboard for a given User if one exists, or generate a default dashboard.
"""
if user.is_anonymous:
dashboard = get_default_dashboard()
else:
try:
dashboard = user.dashboard
except ObjectDoesNotExist:
# Create a dashboard for this user
dashboard = get_default_dashboard()
dashboard.user = user
dashboard.save()

return dashboard


def get_default_dashboard():
from extras.models import Dashboard
dashboard = Dashboard(
layout=[],
config={}
)
for widget in DEFAULT_DASHBOARD:
id = str(uuid.uuid4())
dashboard.layout.append({
'id': id,
'w': widget['width'],
'h': widget['height'],
'x': widget.get('x'),
'y': widget.get('y'),
})
dashboard.config[id] = {
'class': widget['widget'],
'title': widget.get('title'),
'config': widget.get('config', {}),
}

return dashboard
119 changes: 119 additions & 0 deletions netbox/extras/dashboard/widgets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import uuid

from django import forms
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.utils.translation import gettext as _

from utilities.forms import BootstrapMixin
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import content_type_identifier, content_type_name
from .utils import register_widget

__all__ = (
'ChangeLogWidget',
'DashboardWidget',
'NoteWidget',
'ObjectCountsWidget',
)


def get_content_type_labels():
return [
(content_type_identifier(ct), content_type_name(ct))
for ct in ContentType.objects.order_by('app_label', 'model')
]


class DashboardWidget:
default_title = None
description = None
width = 4
height = 3

class ConfigForm(forms.Form):
pass

def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
self.id = id or str(uuid.uuid4())
self.config = config or {}
self.title = title or self.default_title
self.color = color
if width:
self.width = width
if height:
self.height = height
self.x, self.y = x, y

def __str__(self):
return self.title or self.__class__.__name__

def set_layout(self, grid_item):
self.width = grid_item['w']
self.height = grid_item['h']
self.x = grid_item.get('x')
self.y = grid_item.get('y')

def render(self, request):
raise NotImplementedError(f"{self.__class__} must define a render() method.")

@property
def name(self):
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'

@property
def form_data(self):
return {
'title': self.title,
'color': self.color,
'config': self.config,
}


@register_widget
class NoteWidget(DashboardWidget):
description = _('Display some arbitrary custom content. Markdown is supported.')

class ConfigForm(BootstrapMixin, forms.Form):
content = forms.CharField(
widget=forms.Textarea()
)

def render(self, request):
return render_markdown(self.config.get('content'))


@register_widget
class ObjectCountsWidget(DashboardWidget):
default_title = _('Objects')
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):
models = forms.MultipleChoiceField(
choices=get_content_type_labels
)

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()
object_count = model.objects.restrict(request.user, 'view').count
counts.append((model, object_count))

return render_to_string(self.template_name, {
'counts': counts,
})


@register_widget
class ChangeLogWidget(DashboardWidget):
default_title = _('Change Log')
description = _('Display the most recent records from the global change log.')
template_name = 'extras/dashboard/widgets/changelog.html'
width = 12
height = 4

def render(self, request):
return render_to_string(self.template_name, {})
25 changes: 25 additions & 0 deletions netbox/extras/migrations/0087_dashboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Generated by Django 4.1.7 on 2023-02-24 00:56

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('extras', '0086_configtemplate'),
]

operations = [
migrations.CreateModel(
name='Dashboard',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('layout', models.JSONField()),
('config', models.JSONField()),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard', to=settings.AUTH_USER_MODEL)),
],
),
]
2 changes: 2 additions & 0 deletions netbox/extras/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from .change_logging import ObjectChange
from .configs import *
from .customfields import CustomField
from .dashboard import *
from .models import *
from .search import *
from .staging import *
Expand All @@ -15,6 +16,7 @@
'ConfigTemplate',
'CustomField',
'CustomLink',
'Dashboard',
'ExportTemplate',
'ImageAttachment',
'JobResult',
Expand Down
Loading

0 comments on commit 084a2cc

Please sign in to comment.