diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 5e0a484f8..5764c66ee 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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') diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index f01cdcd00..e796f0fdb 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -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)), +] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 75f0eb464..7665e949d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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() diff --git a/netbox/extras/dashboard/utils.py b/netbox/extras/dashboard/utils.py index cc07ca4e0..97d1b8f0c 100644 --- a/netbox/extras/dashboard/utils.py +++ b/netbox/extras/dashboard/utils.py @@ -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 diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index a3d617e92..6e6113a9e 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -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 diff --git a/netbox/extras/migrations/0087_dashboard.py b/netbox/extras/migrations/0087_dashboard.py new file mode 100644 index 000000000..e64843e0e --- /dev/null +++ b/netbox/extras/migrations/0087_dashboard.py @@ -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)), + ], + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 33936cc4f..14e23366f 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -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', diff --git a/netbox/extras/models/dashboard.py b/netbox/extras/models/dashboard.py new file mode 100644 index 000000000..f17a3be57 --- /dev/null +++ b/netbox/extras/models/dashboard.py @@ -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 + ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 181b26b32..a53b4f05d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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]}') diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py index 7e0e1490a..d949e3733 100644 --- a/netbox/netbox/views/misc.py +++ b/netbox/netbox/views/misc.py @@ -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 diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 864245150..8141d16cb 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/src/dashboard.ts b/netbox/project-static/src/dashboard.ts index 8498dfa4f..3cb2aee77 100644 --- a/netbox/project-static/src/dashboard.ts +++ b/netbox/project-static/src/dashboard.ts @@ -7,9 +7,7 @@ async function saveDashboardLayout( gridData: GridStackWidget[] | GridStackOptions, ): Promise> { let data = { - dashboard: { - layout: gridData - }, + layout: gridData } return await apiPatch(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)) {