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',
'CustomFieldSerializer',
'CustomLinkSerializer',
'DashboardSerializer',
'ExportTemplateSerializer',
'ImageAttachmentSerializer',
'JobResultSerializer',
@ -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')

View File

@ -1,3 +1,5 @@
from django.urls import include, path
from netbox.api.routers import NetBoxRouter
from . import views
@ -22,4 +24,7 @@ router.register('job-results', views.JobResultViewSet)
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)),
]

View File

@ -4,6 +4,7 @@ from django_rq.queues import get_connection
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
@ -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()

View File

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

View File

@ -35,7 +35,7 @@ class DashboardWidget:
pass
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.title = title or self.default_title
self.color = color
@ -59,7 +59,7 @@ class DashboardWidget:
@property
def name(self):
return f'{self.__class__.__module__}.{self.__class__.__name__}'
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
@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 .configs import *
from .customfields import CustomField
from .dashboard import *
from .models import *
from .search import *
from .staging import *
@ -15,6 +16,7 @@ __all__ = (
'ConfigTemplate',
'CustomField',
'CustomLink',
'Dashboard',
'ExportTemplate',
'ImageAttachment',
'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
widget = widget_class(**data)
data['class'] = class_name
request.user.config.set(f'dashboard.widgets.{widget.id}', data)
request.user.config.get(f'dashboard.layout').append({
'h': widget.height,
'w': widget.width,
'id': str(widget.id),
})
request.user.config.save()
request.user.dashboard.add_widget(widget)
request.user.dashboard.save()
response = HttpResponse()
response['HX-Redirect'] = reverse('home')
@ -730,7 +725,8 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
template_name = 'extras/dashboard/widget_config.html'
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)
config_form = widget_class.ConfigForm(initial=config.get('config'), prefix='config')
@ -744,14 +740,16 @@ class DashboardWidgetConfigView(LoginRequiredMixin, View):
})
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)
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)
request.user.dashboard.config[id].update(data)
request.user.dashboard.save()
response = HttpResponse()
response['HX-Redirect'] = reverse('home')
@ -768,7 +766,8 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
template_name = 'generic/object_delete.html'
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)
form = ConfirmationForm(initial=request.GET)
@ -786,15 +785,12 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
})
def post(self, request, id):
id = str(id)
form = ConfirmationForm(request.POST)
if form.is_valid():
config = request.user.config
config.clear(f'dashboard.widgets.{id}')
config.set('dashboard.layout', [
item for item in config.get('dashboard.layout') if item['id'] != str(id)
])
config.save()
request.user.dashboard.delete_widget(id)
request.user.dashboard.save()
messages.success(request, f'Deleted widget {id}')
else:
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 netbox.forms import SearchForm
from netbox.registry import registry
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
from netbox.tables import SearchTable

Binary file not shown.

View File

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