Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closes #9416: Dashboard widgets #11823

Merged
merged 19 commits into from
Feb 24, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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