Initial work on dashboard widgets

This commit is contained in:
jeremystretch 2023-02-20 13:31:08 -05:00
parent b25c349a27
commit fc362979ad
7 changed files with 145 additions and 154 deletions

View File

@ -0,0 +1,61 @@
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string
from django.utils.translation import gettext as _
__all__ = (
'ChangeLogWidget',
'DashboardWidget',
'ObjectCountsWidget',
'StaticContentWidget',
)
class DashboardWidget:
width = 4
height = 3
def __init__(self, config=None, title=None, width=None, height=None, x=None, y=None):
self.config = config or {}
if title:
self.title = title
if width:
self.width = width
if height:
self.height = height
self.x, self.y = x, y
def render(self, request):
raise NotImplementedError("DashboardWidget subclasses must define a render() method.")
class StaticContentWidget(DashboardWidget):
def render(self, request):
return self.config.get('content', 'Empty!')
class ObjectCountsWidget(DashboardWidget):
template_name = 'extras/dashboard/widgets/objectcounts.html'
def render(self, request):
counts = []
for model_name in self.config['models']:
app_label, name = model_name.lower().split('.')
model = ContentType.objects.get_by_natural_key(app_label, 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,
})
class ChangeLogWidget(DashboardWidget):
width = 12
height = 4
title = _('Change log')
template_name = 'extras/dashboard/widgets/changelog.html'
def render(self, request):
return render_to_string(self.template_name, {})

View File

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

View File

@ -5,27 +5,17 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache from django.core.cache import cache
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.translation import gettext as _
from django.views.generic import View from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from packaging import version from packaging import version
from circuits.models import Circuit, Provider from extras import dashboard
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 netbox.forms import SearchForm from netbox.forms import SearchForm
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
from tenancy.models import Contact, Tenant
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from virtualization.models import Cluster, VirtualMachine
from wireless.models import WirelessLAN, WirelessLink
__all__ = ( __all__ = (
'HomeView', 'HomeView',
@ -42,79 +32,22 @@ class HomeView(View):
if settings.LOGIN_REQUIRED and not request.user.is_authenticated: if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
return redirect('login') return redirect('login')
console_connections = ConsolePort.objects.restrict(request.user, 'view')\ widgets = (
.prefetch_related('_path').filter(_path__is_complete=True).count dashboard.StaticContentWidget({
power_connections = PowerPort.objects.restrict(request.user, 'view')\ 'content': 'First widget!',
.prefetch_related('_path').filter(_path__is_complete=True).count }),
interface_connections = Interface.objects.restrict(request.user, 'view')\ dashboard.StaticContentWidget({
.prefetch_related('_path').filter(_path__is_complete=True).count 'content': 'First widget!',
}, title='Testing'),
def get_count_queryset(model): dashboard.ObjectCountsWidget({
return model.objects.restrict(request.user, 'view').count 'models': [
'dcim.Site',
def build_stats(): 'ipam.Prefix',
org = ( 'tenancy.Tenant',
Link(_('Sites'), 'dcim:site_list', 'dcim.view_site', get_count_queryset(Site)), ],
Link(_('Tenants'), 'tenancy:tenant_list', 'tenancy.view_tenant', get_count_queryset(Tenant)), }, title='Stuff'),
Link(_('Contacts'), 'tenancy:contact_list', 'tenancy.view_contact', get_count_queryset(Contact)), dashboard.ChangeLogWidget(),
) )
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)
# Check whether a new release is available. (Only for staff/superusers.) # Check whether a new release is available. (Only for staff/superusers.)
new_release = None new_release = None
@ -129,9 +62,7 @@ class HomeView(View):
} }
return render(request, self.template_name, { return render(request, self.template_name, {
'search_form': SearchForm(), 'widgets': widgets,
'stats': build_stats(),
'changelog_table': changelog_table,
'new_release': new_release, 'new_release': new_release,
}) })

View File

@ -0,0 +1,18 @@
{% load dashboard %}
<div class="grid-stack-item" gs-w="{{ widget.width }}" gs-h="{{ widget.height }}">
<div class="card grid-stack-item-content">
{% if widget.title %}
<div class="card-header text-center text-light bg-secondary p-1">
<div class="float-end pe-1"><i class="mdi mdi-close"></i></div>
<strong>{{ widget.title }}</strong>
</div>
{% endif %}
<div class="card-body p-2">
{% if not widget.title %}
<div class="float-end pe-1"><i class="mdi mdi-close"></i></div>
{% endif %}
{% render_widget widget %}
</div>
</div>
</div>

View File

@ -0,0 +1,4 @@
<div class="htmx-container"
hx-get="{% url 'extras:objectchange_list' %}?sort=-time"
hx-trigger="load"
></div>

View File

@ -0,0 +1,12 @@
{% if counts %}
<div class="list-group list-group-flush">
{% for model, count in counts %}
<a href="#" 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 %}

View File

@ -3,80 +3,34 @@
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block header %} {% block header %}
{% if new_release %} {% if new_release %}
{# new_release is set only if the current user is a superuser or staff member #} {# new_release is set only if the current user is a superuser or staff member #}
<div class="header-alert-container"> <div class="header-alert-container">
<div class="alert alert-info text-center mw-md-50" role="alert"> <div class="alert alert-info text-center mw-md-50" role="alert">
<h6 class="alert-heading"> <h6 class="alert-heading">
<i class="mdi mdi-information-outline"></i><br/>New Release Available <i class="mdi mdi-information-outline"></i><br/>New Release Available
</h6> </h6>
<small><a href="{{ new_release.url }}">NetBox v{{ new_release.version }}</a> is available.</small> <small><a href="{{ new_release.url }}">NetBox v{{ new_release.version }}</a> is available.</small>
<hr class="my-2" /> <hr class="my-2" />
<small class="mb-0"> <small class="mb-0">
<a href="https://docs.netbox.dev/en/stable/installation/upgrading/">Upgrade Instructions</a> <a href="https://docs.netbox.dev/en/stable/installation/upgrading/">Upgrade Instructions</a>
</small> </small>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% block content-wrapper %} {% block content-wrapper %}
<div class="px-3"> <div class="px-2 py-0">
{# General stats #}
<div class="row masonry"> {# Render the user's customized dashboard #}
{% for section, items, icon in stats %} <div class="grid-stack">
<div class="col col-sm-12 col-lg-6 col-xl-4 my-2 masonry-item"> {% for widget in widgets %}
<div class="card"> {% include 'extras/dashboard/widget.html' %}
<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 %} {% endfor %}
</div> </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 %}
</div> </div>
{% endblock content-wrapper %} {% endblock content-wrapper %}