* 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
This commit is contained in:
parent
36771e821c
commit
084a2cc52c
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@ class ExtrasConfig(AppConfig):
|
|||
name = "extras"
|
||||
|
||||
def ready(self):
|
||||
from . import lookups, search, signals
|
||||
from . import dashboard, lookups, search, signals
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
from .utils import *
|
||||
from .widgets import *
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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, {})
|
||||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
|
||||
__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 get_widget(self, id):
|
||||
"""
|
||||
Instantiate and return a widget by its ID
|
||||
"""
|
||||
id = str(id)
|
||||
config = dict(self.config[id]) # Copy to avoid mutating instance data
|
||||
widget_class = get_widget_class(config.pop('class'))
|
||||
return widget_class(id=id, **config)
|
||||
|
||||
def get_layout(self):
|
||||
"""
|
||||
Return the dashboard's configured layout, suitable for rendering with gridstack.js.
|
||||
"""
|
||||
widgets = []
|
||||
for grid_item in self.layout:
|
||||
widget = self.get_widget(grid_item['id'])
|
||||
widget.set_layout(grid_item)
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def add_widget(self, widget, x=None, y=None):
|
||||
"""
|
||||
Add a widget to the dashboard, optionally specifying its X & Y coordinates.
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Delete a widget from the dashboard.
|
||||
"""
|
||||
id = str(id)
|
||||
del self.config[id]
|
||||
self.layout = [
|
||||
item for item in self.layout if item['id'] != id
|
||||
]
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
from django import template
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def render_widget(context, widget):
|
||||
request = context['request']
|
||||
|
||||
return widget.render(request)
|
||||
|
|
@ -87,6 +87,11 @@ urlpatterns = [
|
|||
path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
|
||||
path('changelog/<int:pk>/', include(get_model_urls('extras', 'objectchange'))),
|
||||
|
||||
# User dashboard
|
||||
path('dashboard/widgets/add/', views.DashboardWidgetAddView.as_view(), name='dashboardwidget_add'),
|
||||
path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
|
||||
path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
|
||||
|
||||
# Reports
|
||||
path('reports/', views.ReportListView.as_view(), name='report_list'),
|
||||
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.http import Http404, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
from django_rq.queues import get_connection
|
||||
from rq import Worker
|
||||
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
|
|
@ -664,6 +668,130 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView):
|
|||
table = tables.JournalEntryTable
|
||||
|
||||
|
||||
#
|
||||
# Dashboard widgets
|
||||
#
|
||||
|
||||
class DashboardWidgetAddView(LoginRequiredMixin, View):
|
||||
template_name = 'extras/dashboard/widget_add.html'
|
||||
|
||||
def get(self, request):
|
||||
if not is_htmx(request):
|
||||
return redirect('home')
|
||||
|
||||
initial = request.GET or {
|
||||
'widget_class': 'extras.NoteWidget',
|
||||
}
|
||||
widget_form = DashboardWidgetAddForm(initial=initial)
|
||||
widget_name = get_field_value(widget_form, 'widget_class')
|
||||
widget_class = get_widget_class(widget_name)
|
||||
config_form = widget_class.ConfigForm(prefix='config')
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_class': widget_class,
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
widget_form = DashboardWidgetAddForm(request.POST)
|
||||
config_form = None
|
||||
widget_class = None
|
||||
|
||||
if widget_form.is_valid():
|
||||
widget_class = get_widget_class(widget_form.cleaned_data['widget_class'])
|
||||
config_form = widget_class.ConfigForm(request.POST, prefix='config')
|
||||
|
||||
if config_form.is_valid():
|
||||
data = widget_form.cleaned_data
|
||||
data.pop('widget_class')
|
||||
data['config'] = config_form.cleaned_data
|
||||
widget = widget_class(**data)
|
||||
request.user.dashboard.add_widget(widget)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Added widget {widget.id}')
|
||||
|
||||
return HttpResponse(headers={
|
||||
'HX-Redirect': reverse('home'),
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_class': widget_class,
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
})
|
||||
|
||||
|
||||
class DashboardWidgetConfigView(LoginRequiredMixin, View):
|
||||
template_name = 'extras/dashboard/widget_config.html'
|
||||
|
||||
def get(self, request, id):
|
||||
if not is_htmx(request):
|
||||
return redirect('home')
|
||||
|
||||
widget = request.user.dashboard.get_widget(id)
|
||||
widget_form = DashboardWidgetForm(initial=widget.form_data)
|
||||
config_form = widget.ConfigForm(initial=widget.form_data.get('config'), prefix='config')
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
|
||||
})
|
||||
|
||||
def post(self, request, id):
|
||||
widget = request.user.dashboard.get_widget(id)
|
||||
widget_form = DashboardWidgetForm(request.POST)
|
||||
config_form = widget.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.dashboard.config[str(id)].update(data)
|
||||
request.user.dashboard.save()
|
||||
messages.success(request, f'Updated widget {widget.id}')
|
||||
|
||||
return HttpResponse(headers={
|
||||
'HX-Redirect': reverse('home'),
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_form': widget_form,
|
||||
'config_form': config_form,
|
||||
'form_url': reverse('extras:dashboardwidget_config', kwargs={'id': id})
|
||||
})
|
||||
|
||||
|
||||
class DashboardWidgetDeleteView(LoginRequiredMixin, View):
|
||||
template_name = 'generic/object_delete.html'
|
||||
|
||||
def get(self, request, id):
|
||||
if not is_htmx(request):
|
||||
return redirect('home')
|
||||
|
||||
widget = request.user.dashboard.get_widget(id)
|
||||
form = ConfirmationForm(initial=request.GET)
|
||||
|
||||
return render(request, 'htmx/delete_form.html', {
|
||||
'object_type': widget.__class__.__name__,
|
||||
'object': widget,
|
||||
'form': form,
|
||||
'form_url': reverse('extras:dashboardwidget_delete', kwargs={'id': id})
|
||||
})
|
||||
|
||||
def post(self, request, id):
|
||||
form = ConfirmationForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
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]}')
|
||||
|
||||
return redirect(reverse('home'))
|
||||
|
||||
|
||||
#
|
||||
# Reports
|
||||
#
|
||||
|
|
|
|||
|
|
@ -27,4 +27,5 @@ registry = Registry({
|
|||
'plugins': dict(),
|
||||
'search': dict(),
|
||||
'views': collections.defaultdict(dict),
|
||||
'widgets': dict(),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,27 +5,17 @@ from django.conf import settings
|
|||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
from packaging import version
|
||||
|
||||
from circuits.models import Circuit, Provider
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, Site,
|
||||
)
|
||||
from extras.models import ObjectChange
|
||||
from extras.tables import ObjectChangeTable
|
||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
|
||||
from extras.dashboard.utils import get_dashboard
|
||||
from netbox.forms import SearchForm
|
||||
from netbox.search import LookupTypes
|
||||
from netbox.search.backends import search_backend
|
||||
from netbox.tables import SearchTable
|
||||
from tenancy.models import Contact, Tenant
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
|
||||
__all__ = (
|
||||
'HomeView',
|
||||
|
|
@ -42,79 +32,8 @@ class HomeView(View):
|
|||
if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
|
||||
return redirect('login')
|
||||
|
||||
console_connections = ConsolePort.objects.restrict(request.user, 'view')\
|
||||
.prefetch_related('_path').filter(_path__is_complete=True).count
|
||||
power_connections = PowerPort.objects.restrict(request.user, 'view')\
|
||||
.prefetch_related('_path').filter(_path__is_complete=True).count
|
||||
interface_connections = Interface.objects.restrict(request.user, 'view')\
|
||||
.prefetch_related('_path').filter(_path__is_complete=True).count
|
||||
|
||||
def get_count_queryset(model):
|
||||
return model.objects.restrict(request.user, 'view').count
|
||||
|
||||
def build_stats():
|
||||
org = (
|
||||
Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)),
|
||||
Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)),
|
||||
Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)),
|
||||
)
|
||||
dcim = (
|
||||
Link(_('Racks'), 'dcim:rack_list', 'dcim.view_rack', get_count_queryset(Rack)),
|
||||
Link(_('Device Types'), 'dcim:devicetype_list', 'dcim.view_devicetype', get_count_queryset(DeviceType)),
|
||||
Link(_('Devices'), 'dcim:device_list', 'dcim.view_device', get_count_queryset(Device)),
|
||||
)
|
||||
ipam = (
|
||||
Link(_('VRFs'), 'ipam:vrf_list', 'ipam.view_vrf', get_count_queryset(VRF)),
|
||||
Link(_('Aggregates'), 'ipam:aggregate_list', 'ipam.view_aggregate', get_count_queryset(Aggregate)),
|
||||
Link(_('Prefixes'), 'ipam:prefix_list', 'ipam.view_prefix', get_count_queryset(Prefix)),
|
||||
Link(_('IP Ranges'), 'ipam:iprange_list', 'ipam.view_iprange', get_count_queryset(IPRange)),
|
||||
Link(_('IP Addresses'), 'ipam:ipaddress_list', 'ipam.view_ipaddress', get_count_queryset(IPAddress)),
|
||||
Link(_('VLANs'), 'ipam:vlan_list', 'ipam.view_vlan', get_count_queryset(VLAN)),
|
||||
)
|
||||
circuits = (
|
||||
Link(_('Providers'), 'circuits:provider_list', 'circuits.view_provider', get_count_queryset(Provider)),
|
||||
Link(_('Circuits'), 'circuits:circuit_list', 'circuits.view_circuit', get_count_queryset(Circuit))
|
||||
)
|
||||
virtualization = (
|
||||
Link(_('Clusters'), 'virtualization:cluster_list', 'virtualization.view_cluster',
|
||||
get_count_queryset(Cluster)),
|
||||
Link(_('Virtual Machines'), 'virtualization:virtualmachine_list', 'virtualization.view_virtualmachine',
|
||||
get_count_queryset(VirtualMachine)),
|
||||
)
|
||||
connections = (
|
||||
Link(_('Cables'), 'dcim:cable_list', 'dcim.view_cable', get_count_queryset(Cable)),
|
||||
Link(_('Interfaces'), 'dcim:interface_connections_list', 'dcim.view_interface', interface_connections),
|
||||
Link(_('Console'), 'dcim:console_connections_list', 'dcim.view_consoleport', console_connections),
|
||||
Link(_('Power'), 'dcim:power_connections_list', 'dcim.view_powerport', power_connections),
|
||||
)
|
||||
power = (
|
||||
Link(_('Power Panels'), 'dcim:powerpanel_list', 'dcim.view_powerpanel', get_count_queryset(PowerPanel)),
|
||||
Link(_('Power Feeds'), 'dcim:powerfeed_list', 'dcim.view_powerfeed', get_count_queryset(PowerFeed)),
|
||||
)
|
||||
wireless = (
|
||||
Link(_('Wireless LANs'), 'wireless:wirelesslan_list', 'wireless.view_wirelesslan',
|
||||
get_count_queryset(WirelessLAN)),
|
||||
Link(_('Wireless Links'), 'wireless:wirelesslink_list', 'wireless.view_wirelesslink',
|
||||
get_count_queryset(WirelessLink)),
|
||||
)
|
||||
stats = (
|
||||
(_('Organization'), org, 'domain'),
|
||||
(_('IPAM'), ipam, 'counter'),
|
||||
(_('Virtualization'), virtualization, 'monitor'),
|
||||
(_('Inventory'), dcim, 'server'),
|
||||
(_('Circuits'), circuits, 'transit-connection-variant'),
|
||||
(_('Connections'), connections, 'cable-data'),
|
||||
(_('Power'), power, 'flash'),
|
||||
(_('Wireless'), wireless, 'wifi'),
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
# Compile changelog table
|
||||
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'user', 'changed_object_type'
|
||||
)[:10]
|
||||
changelog_table = ObjectChangeTable(changelog, user=request.user)
|
||||
# Construct the user's custom dashboard layout
|
||||
dashboard = get_dashboard(request.user).get_layout()
|
||||
|
||||
# Check whether a new release is available. (Only for staff/superusers.)
|
||||
new_release = None
|
||||
|
|
@ -129,9 +48,7 @@ class HomeView(View):
|
|||
}
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'search_form': SearchForm(),
|
||||
'stats': build_stats(),
|
||||
'changelog_table': changelog_table,
|
||||
'dashboard': dashboard,
|
||||
'new_release': new_release,
|
||||
})
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -29,9 +29,9 @@
|
|||
"color2k": "^2.0.0",
|
||||
"dayjs": "^1.11.5",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "^7.2.3",
|
||||
"htmx.org": "^1.8.0",
|
||||
"just-debounce-it": "^3.1.1",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"query-string": "^7.1.1",
|
||||
"sass": "^1.55.0",
|
||||
"simplebar": "^5.3.9",
|
||||
|
|
@ -56,4 +56,4 @@
|
|||
"resolutions": {
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap';
|
||||
import Masonry from 'masonry-layout';
|
||||
import { createElement, getElements } from './util';
|
||||
|
||||
type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
|
||||
|
|
@ -12,18 +11,6 @@ window.Popover = Popover;
|
|||
window.Toast = Toast;
|
||||
window.Tooltip = Tooltip;
|
||||
|
||||
/**
|
||||
* Initialize masonry-layout for homepage (or any other masonry layout cards).
|
||||
*/
|
||||
function initMasonry(): void {
|
||||
for (const grid of getElements<HTMLDivElement>('.masonry')) {
|
||||
new Masonry(grid, {
|
||||
itemSelector: '.masonry-item',
|
||||
percentPosition: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function initTooltips() {
|
||||
for (const tooltip of getElements('[data-bs-toggle="tooltip"]')) {
|
||||
new Tooltip(tooltip, { container: 'body' });
|
||||
|
|
@ -194,7 +181,6 @@ export function initBootstrap(): void {
|
|||
for (const func of [
|
||||
initTooltips,
|
||||
initModals,
|
||||
initMasonry,
|
||||
initTabs,
|
||||
initImagePreview,
|
||||
initSidebarAccordions,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { GridStack, GridStackOptions, GridStackWidget } from 'gridstack';
|
||||
import { createToast } from './bs';
|
||||
import { apiPatch, hasError } from './util';
|
||||
|
||||
async function saveDashboardLayout(
|
||||
url: string,
|
||||
gridData: GridStackWidget[] | GridStackOptions,
|
||||
): Promise<APIResponse<APIUserConfig>> {
|
||||
let data = {
|
||||
layout: gridData
|
||||
}
|
||||
return await apiPatch<APIUserConfig>(url, data);
|
||||
}
|
||||
|
||||
export function initDashboard(): void {
|
||||
// Initialize the grid
|
||||
let grid = GridStack.init({
|
||||
cellHeight: 100,
|
||||
});
|
||||
|
||||
// Create a listener for the dashboard save button
|
||||
const gridSaveButton = document.getElementById('save_dashboard') as HTMLButtonElement;
|
||||
if (gridSaveButton === null) {
|
||||
return;
|
||||
}
|
||||
gridSaveButton.addEventListener('click', () => {
|
||||
const url = gridSaveButton.getAttribute('data-url');
|
||||
if (url == null) {
|
||||
return;
|
||||
}
|
||||
let gridData = grid.save(false);
|
||||
saveDashboardLayout(url, gridData).then(res => {
|
||||
if (hasError(res)) {
|
||||
const toast = createToast('danger', 'Error Saving Dashboard Config', res.error);
|
||||
toast.show();
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import { initDateSelector } from './dateSelector';
|
|||
import { initTableConfig } from './tableConfig';
|
||||
import { initInterfaceTable } from './tables';
|
||||
import { initSideNav } from './sidenav';
|
||||
import { initDashboard } from './dashboard';
|
||||
import { initRackElevation } from './racks';
|
||||
import { initLinks } from './links';
|
||||
import { initHtmx } from './htmx';
|
||||
|
|
@ -28,6 +29,7 @@ function initDocument(): void {
|
|||
initTableConfig,
|
||||
initInterfaceTable,
|
||||
initSideNav,
|
||||
initDashboard,
|
||||
initRackElevation,
|
||||
initLinks,
|
||||
initHtmx,
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
@import '../node_modules/@mdi/font/css/materialdesignicons.min.css';
|
||||
@import '../node_modules/flatpickr/dist/flatpickr.css';
|
||||
@import '../node_modules/simplebar/dist/simplebar.css';
|
||||
@import 'gridstack/dist/gridstack.min.css';
|
||||
|
|
|
|||
|
|
@ -875,11 +875,6 @@ delegate@^3.1.2:
|
|||
resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
|
||||
integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
|
||||
|
||||
desandro-matches-selector@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/desandro-matches-selector/-/desandro-matches-selector-2.0.2.tgz#717beed4dc13e7d8f3762f707a6d58a6774218e1"
|
||||
integrity sha1-cXvu1NwT59jzdi9wem1YpndCGOE=
|
||||
|
||||
diff@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
|
||||
|
|
@ -1411,11 +1406,6 @@ esutils@^2.0.2:
|
|||
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
|
||||
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
|
||||
|
||||
ev-emitter@^1.0.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ev-emitter/-/ev-emitter-1.1.1.tgz#8f18b0ce5c76a5d18017f71c0a795c65b9138f2a"
|
||||
integrity sha512-ipiDYhdQSCZ4hSbX4rMW+XzNKMD1prg/sTvoVmSLkuQ1MVlwjJQQA+sW8tMYR3BLUr9KjodFV4pvzunvRhd33Q==
|
||||
|
||||
event-target-shim@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
|
||||
|
|
@ -1496,13 +1486,6 @@ find-up@^5.0.0:
|
|||
locate-path "^6.0.0"
|
||||
path-exists "^4.0.0"
|
||||
|
||||
fizzy-ui-utils@^2.0.0:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/fizzy-ui-utils/-/fizzy-ui-utils-2.0.7.tgz#7df45dcc4eb374a08b65d39bb9a4beedf7330505"
|
||||
integrity sha512-CZXDVXQ1If3/r8s0T+v+qVeMshhfcuq0rqIFgJnrtd+Bu8GmDmqMjntjUePypVtjHXKJ6V4sw9zeyox34n9aCg==
|
||||
dependencies:
|
||||
desandro-matches-selector "^2.0.0"
|
||||
|
||||
flat-cache@^3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
|
||||
|
|
@ -1582,11 +1565,6 @@ get-intrinsic@^1.1.3:
|
|||
has "^1.0.3"
|
||||
has-symbols "^1.0.3"
|
||||
|
||||
get-size@^2.0.2:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/get-size/-/get-size-2.0.3.tgz#54a1d0256b20ea7ac646516756202769941ad2ef"
|
||||
integrity sha512-lXNzT/h/dTjTxRbm9BXb+SGxxzkm97h/PCIKtlN/CBCxxmkkIVV21udumMS93MuVTDX583gqc94v3RjuHmI+2Q==
|
||||
|
||||
get-symbol-description@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
|
||||
|
|
@ -1784,6 +1762,11 @@ graphql-ws@^5.4.1:
|
|||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5"
|
||||
integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA==
|
||||
|
||||
gridstack@^7.2.3:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-7.2.3.tgz#bc04d3588eb5f2b7edd910e31fdac5bea8069ff2"
|
||||
integrity sha512-1s4Fx+Hr4nKl064q/ygrd41XiZaC2gG6R+yz5nbOibP9vODJ6mOtjIM5x8qKN12FknakaMpVBnCa1T6V7H15hQ==
|
||||
|
||||
has-bigints@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
|
||||
|
|
@ -2163,14 +2146,6 @@ markdown-it@^10.0.0:
|
|||
mdurl "^1.0.1"
|
||||
uc.micro "^1.0.5"
|
||||
|
||||
masonry-layout@^4.2.2:
|
||||
version "4.2.2"
|
||||
resolved "https://registry.yarnpkg.com/masonry-layout/-/masonry-layout-4.2.2.tgz#d57b44af13e601bfcdc423f1dd8348b5524de348"
|
||||
integrity sha512-iGtAlrpHNyxaR19CvKC3npnEcAwszXoyJiI8ARV2ePi7fmYhIud25MHK8Zx4P0LCC4d3TNO9+rFa1KoK1OEOaA==
|
||||
dependencies:
|
||||
get-size "^2.0.2"
|
||||
outlayer "^2.1.0"
|
||||
|
||||
mdurl@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
|
||||
|
|
@ -2341,15 +2316,6 @@ optionator@^0.9.1:
|
|||
type-check "^0.4.0"
|
||||
word-wrap "^1.2.3"
|
||||
|
||||
outlayer@^2.1.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/outlayer/-/outlayer-2.1.1.tgz#29863b6de10ea5dadfffcadfa0d728907387e9a2"
|
||||
integrity sha1-KYY7beEOpdrf/8rfoNcokHOH6aI=
|
||||
dependencies:
|
||||
ev-emitter "^1.0.0"
|
||||
fizzy-ui-utils "^2.0.0"
|
||||
get-size "^2.0.2"
|
||||
|
||||
p-limit@3.1.0, p-limit@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
{% load dashboard %}
|
||||
|
||||
<div
|
||||
class="grid-stack-item"
|
||||
gs-w="{{ widget.width }}"
|
||||
gs-h="{{ widget.height }}"
|
||||
gs-x="{{ widget.x }}"
|
||||
gs-y="{{ widget.y }}"
|
||||
gs-id="{{ widget.id }}"
|
||||
>
|
||||
<div class="card grid-stack-item-content">
|
||||
<div class="card-header text-center text-light bg-{% if widget.color %}{{ widget.color }}{% else %}secondary{% endif %} p-1">
|
||||
<div class="float-start ps-1">
|
||||
<a href="#"
|
||||
hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
|
||||
hx-target="#htmx-modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#htmx-modal"
|
||||
><i class="mdi mdi-cog text-gray"></i></a>
|
||||
</div>
|
||||
<div class="float-end pe-1">
|
||||
<a href="#"
|
||||
hx-get="{% url 'extras:dashboardwidget_delete' id=widget.id %}"
|
||||
hx-target="#htmx-modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#htmx-modal"
|
||||
><i class="mdi mdi-close text-gray"></i></a>
|
||||
</div>
|
||||
{% if widget.title %}
|
||||
<strong>{{ widget.title }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
{% render_widget widget %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{% load form_helpers %}
|
||||
|
||||
<form hx-post="{% url 'extras:dashboardwidget_add' %}" id="widget_add_form">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add a Widget</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% block form %}
|
||||
{% render_field widget_form.widget_class %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">Description</label>
|
||||
<div class="col">
|
||||
<div class="form-control-plaintext">{{ widget_class.description|placeholder }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field widget_form.color %}
|
||||
{% render_field widget_form.title %}
|
||||
{% render_form config_form %}
|
||||
{% endblock form %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{% block buttons %}
|
||||
<button class="btn btn-primary">Save</button>
|
||||
{% endblock buttons %}
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{% load form_helpers %}
|
||||
|
||||
<form hx-post="{{ form_url }}">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Widget Configuration</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% block form %}
|
||||
{% render_form widget_form %}
|
||||
{% render_form config_form %}
|
||||
{% endblock form %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
{% block buttons %}
|
||||
<button class="btn btn-primary">Save</button>
|
||||
{% endblock buttons %}
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<div class="htmx-container"
|
||||
hx-get="{% url 'extras:objectchange_list' %}?sort=-time"
|
||||
hx-trigger="load"
|
||||
></div>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{% load helpers %}
|
||||
|
||||
{% if counts %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for model, count in counts %}
|
||||
<a href="{% url model|viewname:"list" %}" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
{{ model|meta:"verbose_name_plural"|bettertitle }}
|
||||
<h6 class="mb-1">{{ count }}</h6>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -3,80 +3,48 @@
|
|||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block header %}
|
||||
{% if new_release %}
|
||||
{# new_release is set only if the current user is a superuser or staff member #}
|
||||
<div class="header-alert-container">
|
||||
<div class="alert alert-info text-center mw-md-50" role="alert">
|
||||
<h6 class="alert-heading">
|
||||
<i class="mdi mdi-information-outline"></i><br/>New Release Available
|
||||
</h6>
|
||||
<small><a href="{{ new_release.url }}">NetBox v{{ new_release.version }}</a> is available.</small>
|
||||
<hr class="my-2" />
|
||||
<small class="mb-0">
|
||||
<a href="https://docs.netbox.dev/en/stable/installation/upgrading/">Upgrade Instructions</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if new_release %}
|
||||
{# new_release is set only if the current user is a superuser or staff member #}
|
||||
<div class="header-alert-container">
|
||||
<div class="alert alert-info text-center mw-md-50" role="alert">
|
||||
<h6 class="alert-heading">
|
||||
<i class="mdi mdi-information-outline"></i><br/>New Release Available
|
||||
</h6>
|
||||
<small><a href="{{ new_release.url }}">NetBox v{{ new_release.version }}</a> is available.</small>
|
||||
<hr class="my-2" />
|
||||
<small class="mb-0">
|
||||
<a href="https://docs.netbox.dev/en/stable/installation/upgrading/">Upgrade Instructions</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block title %}Home{% endblock %}
|
||||
|
||||
{% block content-wrapper %}
|
||||
<div class="px-3">
|
||||
{# General stats #}
|
||||
<div class="row masonry">
|
||||
{% for section, items, icon in stats %}
|
||||
<div class="col col-sm-12 col-lg-6 col-xl-4 my-2 masonry-item">
|
||||
<div class="card">
|
||||
<h6 class="card-header text-center">
|
||||
<i class="mdi mdi-{{ icon }}"></i>
|
||||
<span class="ms-1">{{ section }}</span>
|
||||
</h6>
|
||||
<div class="card-body">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for item in items %}
|
||||
{% if item.permission in perms %}
|
||||
<a href="{% url item.viewname %}" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
{{ item.label }}
|
||||
<h4 class="mb-1">{{ item.count }}</h4>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<li class="list-group-item list-group-item-action disabled">
|
||||
<div class="d-flex w-100 justify-content-between align-items-center">
|
||||
{{ item.label }}
|
||||
<h4 class="mb-1">
|
||||
<i title="No permission" class="mdi mdi-lock"></i>
|
||||
</h4>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Changelog #}
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<div class="row my-4 flex-grow-1 changelog-container">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<h6 class="card-header text-center">
|
||||
<i class="mdi mdi-clipboard-clock"></i>
|
||||
<span class="ms-1">Change Log</span>
|
||||
</h6>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
hx-get="{% url 'extras:objectchange_list' %}?sort=-time"
|
||||
hx-trigger="load"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Render the user's customized dashboard #}
|
||||
<div class="grid-stack">
|
||||
{% for widget in dashboard %}
|
||||
{% include 'extras/dashboard/widget.html' %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-end px-2">
|
||||
<a href="#"
|
||||
hx-get="{% url 'extras:dashboardwidget_add' %}"
|
||||
hx-target="#htmx-modal-content"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#htmx-modal"
|
||||
class="btn btn-success btn-sm"
|
||||
>
|
||||
<i class="mdi mdi-plus"></i> Add Widget
|
||||
</a>
|
||||
<button id="save_dashboard" class="btn btn-primary btn-sm" data-url="{% url 'extras-api:dashboard' %}">
|
||||
<i class="mdi mdi-content-save-outline"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
{% endblock content-wrapper %}
|
||||
|
||||
{% block modals %}
|
||||
{% include 'inc/htmx_modal.html' %}
|
||||
{% endblock modals %}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" id="htmx-modal-content">
|
||||
{# Dynamic content goes here #}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -140,7 +140,10 @@ class UserConfig(models.Model):
|
|||
# Set a key based on the last item in the path. Raise TypeError if attempting to overwrite a non-leaf node.
|
||||
key = keys[-1]
|
||||
if key in d and type(d[key]) is dict:
|
||||
raise TypeError(f"Key '{path}' has child keys; cannot assign a value")
|
||||
if type(value) is dict:
|
||||
d[key].update(value)
|
||||
else:
|
||||
raise TypeError(f"Key '{path}' is a dictionary; cannot assign a non-dictionary value")
|
||||
else:
|
||||
d[key] = value
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue