Introduce Dashboard model to store user dashboard layout & config

This commit is contained in:
jeremystretch 2023-02-23 21:26:46 -05:00
parent e9e0738d5d
commit 892fce1518
12 changed files with 130 additions and 41 deletions

View File

@ -34,6 +34,7 @@ __all__ = (
'ContentTypeSerializer', 'ContentTypeSerializer',
'CustomFieldSerializer', 'CustomFieldSerializer',
'CustomLinkSerializer', 'CustomLinkSerializer',
'DashboardSerializer',
'ExportTemplateSerializer', 'ExportTemplateSerializer',
'ImageAttachmentSerializer', 'ImageAttachmentSerializer',
'JobResultSerializer', 'JobResultSerializer',
@ -563,3 +564,13 @@ class ContentTypeSerializer(BaseModelSerializer):
class Meta: class Meta:
model = ContentType model = ContentType
fields = ['id', 'url', 'display', 'app_label', 'model'] fields = ['id', 'url', 'display', 'app_label', 'model']
#
# User dashboard
#
class DashboardSerializer(serializers.ModelSerializer):
class Meta:
model = Dashboard
fields = ('layout', 'config')

View File

@ -1,3 +1,5 @@
from django.urls import include, path
from netbox.api.routers import NetBoxRouter from netbox.api.routers import NetBoxRouter
from . import views from . import views
@ -22,4 +24,7 @@ router.register('job-results', views.JobResultViewSet)
router.register('content-types', views.ContentTypeViewSet) router.register('content-types', views.ContentTypeViewSet)
app_name = 'extras-api' app_name = 'extras-api'
urlpatterns = router.urls urlpatterns = [
path('dashboard/', views.DashboardView.as_view(), name='dashboard'),
path('', include(router.urls)),
]

View File

@ -4,6 +4,7 @@ from django_rq.queues import get_connection
from rest_framework import status from rest_framework import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import RetrieveUpdateDestroyAPIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
@ -423,3 +424,15 @@ class ContentTypeViewSet(ReadOnlyModelViewSet):
queryset = ContentType.objects.order_by('app_label', 'model') queryset = ContentType.objects.order_by('app_label', 'model')
serializer_class = serializers.ContentTypeSerializer serializer_class = serializers.ContentTypeSerializer
filterset_class = filtersets.ContentTypeFilterSet 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()

View File

@ -2,6 +2,7 @@ import uuid
from netbox.registry import registry from netbox.registry import registry
from extras.constants import DEFAULT_DASHBOARD from extras.constants import DEFAULT_DASHBOARD
from extras.models import Dashboard
__all__ = ( __all__ = (
'get_dashboard', 'get_dashboard',
@ -22,8 +23,8 @@ def register_widget(cls):
return cls return cls
def get_widget_class_and_config(user, id): def get_widget_class_and_config(dashboard, id):
config = dict(user.config.get(f'dashboard.widgets.{id}')) # Copy to avoid mutating userconfig data config = dict(dashboard.config[id]) # Copy to avoid mutating userconfig data
widget_class = registry['widgets'].get(config.pop('class')) widget_class = registry['widgets'].get(config.pop('class'))
return widget_class, config return widget_class, config
@ -32,16 +33,14 @@ def get_dashboard(user):
""" """
Return the dashboard layout for a given User. Return the dashboard layout for a given User.
""" """
if not user.is_anonymous and user.config.get('dashboard'): if not user.is_anonymous and hasattr(user, 'dashboard'):
config = user.config.get('dashboard') dashboard = user.dashboard
else: else:
config = get_default_dashboard_config() dashboard = get_default_dashboard_config()
if not user.is_anonymous:
user.config.set('dashboard', config, commit=True)
widgets = [] widgets = []
for grid_item in config['layout']: for grid_item in dashboard.layout:
widget_class, widget_config = get_widget_class_and_config(user, grid_item['id']) widget_class, widget_config = get_widget_class_and_config(dashboard, grid_item['id'])
widget = widget_class(id=grid_item['id'], **widget_config) widget = widget_class(id=grid_item['id'], **widget_config)
widget.set_layout(grid_item) widget.set_layout(grid_item)
widgets.append(widget) widgets.append(widget)
@ -50,23 +49,23 @@ def get_dashboard(user):
def get_default_dashboard_config(): def get_default_dashboard_config():
config = { dashboard = Dashboard(
'layout': [], layout=[],
'widgets': {}, config={}
} )
for widget in DEFAULT_DASHBOARD: for widget in DEFAULT_DASHBOARD:
id = str(uuid.uuid4()) id = str(uuid.uuid4())
config['layout'].append({ dashboard.layout.append({
'id': id, 'id': id,
'w': widget['width'], 'w': widget['width'],
'h': widget['height'], 'h': widget['height'],
'x': widget.get('x'), 'x': widget.get('x'),
'y': widget.get('y'), 'y': widget.get('y'),
}) })
config['widgets'][id] = { dashboard.config[id] = {
'class': widget['widget'], 'class': widget['widget'],
'title': widget.get('title'), 'title': widget.get('title'),
'config': widget.get('config', {}), 'config': widget.get('config', {}),
} }
return config return dashboard

View File

@ -35,7 +35,7 @@ class DashboardWidget:
pass pass
def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None): def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
self.id = id or uuid.uuid4() self.id = id or str(uuid.uuid4())
self.config = config or {} self.config = config or {}
self.title = title or self.default_title self.title = title or self.default_title
self.color = color self.color = color
@ -59,7 +59,7 @@ class DashboardWidget:
@property @property
def name(self): def name(self):
return f'{self.__class__.__module__}.{self.__class__.__name__}' return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
@register_widget @register_widget

View File

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

View File

@ -1,6 +1,7 @@
from .change_logging import ObjectChange from .change_logging import ObjectChange
from .configs import * from .configs import *
from .customfields import CustomField from .customfields import CustomField
from .dashboard import *
from .models import * from .models import *
from .search import * from .search import *
from .staging import * from .staging import *
@ -15,6 +16,7 @@ __all__ = (
'ConfigTemplate', 'ConfigTemplate',
'CustomField', 'CustomField',
'CustomLink', 'CustomLink',
'Dashboard',
'ExportTemplate', 'ExportTemplate',
'ImageAttachment', 'ImageAttachment',
'JobResult', 'JobResult',

View File

@ -0,0 +1,41 @@
from django.contrib.auth import get_user_model
from django.db import models
__all__ = (
'Dashboard',
)
class Dashboard(models.Model):
user = models.OneToOneField(
to=get_user_model(),
on_delete=models.CASCADE,
related_name='dashboard'
)
layout = models.JSONField()
config = models.JSONField()
class Meta:
pass
def add_widget(self, widget, x=None, y=None):
id = str(widget.id)
self.config[id] = {
'class': widget.name,
'title': widget.title,
'color': widget.color,
'config': widget.config,
}
self.layout.append({
'id': id,
'h': widget.height,
'w': widget.width,
'x': x,
'y': y,
})
def delete_widget(self, id):
del self.config[id]
self.layout = [
item for item in self.layout if item['id'] != id
]

View File

@ -707,13 +707,8 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
data['config'] = config_form.cleaned_data data['config'] = config_form.cleaned_data
widget = widget_class(**data) widget = widget_class(**data)
data['class'] = class_name data['class'] = class_name
request.user.config.set(f'dashboard.widgets.{widget.id}', data) request.user.dashboard.add_widget(widget)
request.user.config.get(f'dashboard.layout').append({ request.user.dashboard.save()
'h': widget.height,
'w': widget.width,
'id': str(widget.id),
})
request.user.config.save()
response = HttpResponse() response = HttpResponse()
response['HX-Redirect'] = reverse('home') response['HX-Redirect'] = reverse('home')
@ -730,7 +725,8 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
template_name = 'extras/dashboard/widget_config.html' template_name = 'extras/dashboard/widget_config.html'
def get(self, request, id): def get(self, request, id):
widget_class, config = get_widget_class_and_config(request.user, id) id = str(id)
widget_class, config = get_widget_class_and_config(request.user.dashboard, id)
widget_form = DashboardWidgetForm(initial=config) widget_form = DashboardWidgetForm(initial=config)
config_form = widget_class.ConfigForm(initial=config.get('config'), prefix='config') config_form = widget_class.ConfigForm(initial=config.get('config'), prefix='config')
@ -744,14 +740,16 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
}) })
def post(self, request, id): def post(self, request, id):
widget_class, config = get_widget_class_and_config(request.user, id) id = str(id)
widget_class, config = get_widget_class_and_config(request.user.dashboard, id)
widget_form = DashboardWidgetForm(request.POST) widget_form = DashboardWidgetForm(request.POST)
config_form = widget_class.ConfigForm(request.POST, prefix='config') config_form = widget_class.ConfigForm(request.POST, prefix='config')
if widget_form.is_valid() and config_form.is_valid(): if widget_form.is_valid() and config_form.is_valid():
data = widget_form.cleaned_data data = widget_form.cleaned_data
data['config'] = config_form.cleaned_data data['config'] = config_form.cleaned_data
request.user.config.set(f'dashboard.widgets.{id}', data, commit=True) request.user.dashboard.config[id].update(data)
request.user.dashboard.save()
response = HttpResponse() response = HttpResponse()
response['HX-Redirect'] = reverse('home') response['HX-Redirect'] = reverse('home')
@ -768,7 +766,8 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
template_name = 'generic/object_delete.html' template_name = 'generic/object_delete.html'
def get(self, request, id): def get(self, request, id):
widget_class, config = get_widget_class_and_config(request.user, id) id = str(id)
widget_class, config = get_widget_class_and_config(request.user.dashboard, id)
widget = widget_class(**config) widget = widget_class(**config)
form = ConfirmationForm(initial=request.GET) form = ConfirmationForm(initial=request.GET)
@ -786,15 +785,12 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
}) })
def post(self, request, id): def post(self, request, id):
id = str(id)
form = ConfirmationForm(request.POST) form = ConfirmationForm(request.POST)
if form.is_valid(): if form.is_valid():
config = request.user.config request.user.dashboard.delete_widget(id)
config.clear(f'dashboard.widgets.{id}') request.user.dashboard.save()
config.set('dashboard.layout', [
item for item in config.get('dashboard.layout') if item['id'] != str(id)
])
config.save()
messages.success(request, f'Deleted widget {id}') messages.success(request, f'Deleted widget {id}')
else: else:
messages.error(request, f'Error deleting widget: {form.errors[0]}') messages.error(request, f'Error deleting widget: {form.errors[0]}')

View File

@ -11,7 +11,6 @@ from packaging import version
from extras.dashboard.utils import get_dashboard from extras.dashboard.utils import get_dashboard
from netbox.forms import SearchForm from netbox.forms import SearchForm
from netbox.registry import registry
from netbox.search import LookupTypes from netbox.search import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from netbox.tables import SearchTable from netbox.tables import SearchTable

Binary file not shown.

View File

@ -7,9 +7,7 @@ async function saveDashboardLayout(
gridData: GridStackWidget[] | GridStackOptions, gridData: GridStackWidget[] | GridStackOptions,
): Promise<APIResponse<APIUserConfig>> { ): Promise<APIResponse<APIUserConfig>> {
let data = { let data = {
dashboard: { layout: gridData
layout: gridData
},
} }
return await apiPatch<APIUserConfig>(url, data); return await apiPatch<APIUserConfig>(url, data);
} }
@ -24,7 +22,7 @@ export function initDashboard(): void {
return; return;
} }
gridSaveButton.addEventListener('click', () => { gridSaveButton.addEventListener('click', () => {
const url = '/api/users/config/'; const url = '/api/extras/dashboard/';
let gridData = grid.save(false); let gridData = grid.save(false);
saveDashboardLayout(url, gridData).then(res => { saveDashboardLayout(url, gridData).then(res => {
if (hasError(res)) { if (hasError(res)) {